Compare commits

..

20 Commits

Author SHA1 Message Date
手瓜一十雪
cb8727d487 fix: reload and parse msg 2025-02-04 19:34:51 +08:00
Mlikiowa
a94e03e2fd release: v4.5.3 2025-02-04 10:16:07 +00:00
手瓜一十雪
425c3c6432 fix: 避免重复reload 2025-02-04 18:14:13 +08:00
手瓜一十雪
89b9610016 fix: 避免read异常 2025-02-04 18:13:42 +08:00
手瓜一十雪
62fe88f868 Merge pull request #760 from NapNeko/config-refactor
refactor
2025-02-04 18:09:57 +08:00
手瓜一十雪
11a7f5fade refactor 2025-02-04 18:09:30 +08:00
bietiaop
fbde997f7c style: 调整样式 2025-02-04 17:58:38 +08:00
bietiaop
26734a35ef fix: 文件下载 2025-02-04 15:31:10 +08:00
Mlikiowa
715c4ac534 release: v4.5.2 2025-02-04 06:52:11 +00:00
bietiaop
bd4b0885a1 fix: 预览 2025-02-04 14:47:38 +08:00
手瓜一十雪
e3c7af3d91 fix: 解决nonebot可能卡死问题 2025-02-04 14:42:14 +08:00
手瓜一十雪
a7ee21bfd8 fix: #757 2025-02-04 14:34:55 +08:00
手瓜一十雪
d0f51d92ac feat: tailwind css 2025-02-04 13:52:53 +08:00
手瓜一十雪
e6dc148ea2 fix: diy status问题 2025-02-04 13:44:35 +08:00
手瓜一十雪
514ab6637f feat: 全局字体优化 2025-02-04 13:37:11 +08:00
bietiaop
377794abe8 style: font & terminal
style: font & terminal
2025-02-04 13:09:00 +08:00
bietiaop
0f3251f35b fix: 字体、终端样式 2025-02-04 12:59:51 +08:00
手瓜一十雪
8002dc5bc5 fix 2025-02-04 00:16:59 +08:00
手瓜一十雪
c75a13dcf4 fix 2025-02-04 00:14:15 +08:00
Mlikiowa
91d153bb9d release: v4.5.1 2025-02-03 12:48:00 +00:00
67 changed files with 1184 additions and 542 deletions

View File

@@ -5,5 +5,6 @@
".env.universal": ".env.*",
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
}
},
"css.customData": [".vscode/tailwindcss.json"],
}

55
.vscode/tailwindcss.json vendored Normal file
View File

@@ -0,0 +1,55 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.4.20",
"version": "4.5.3",
"icon": "./logo.png",
"authors": [
{

View File

@@ -46,9 +46,9 @@
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.4",
"axios": "^1.7.9",
@@ -70,6 +70,7 @@
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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 (
<Routes>
<Route element={<IndexPage />} path="/*" />
<Route element={<QQLoginPage />} path="/qq_login" />
<Route element={<WebLoginPage />} path="/web_login" />
<Route path="/" element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<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>
)
}

View File

@@ -27,7 +27,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
<CardBody className="items-center md:gap-1 p-1 md:p-2">
<div
className={clsx(
'font-outfit flex-1',
'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',

View File

@@ -9,6 +9,7 @@ import {
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager'
@@ -18,11 +19,10 @@ interface FilePreviewModalProps {
onClose: () => void
}
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
const videoExts = ['.mp4', '.webm']
const audioExts = ['.mp3', '.wav']
export const videoExts = ['.mp4', '.webm']
export const audioExts = ['.mp3', '.wav']
const supportedPreviewExts = [...imageExts, ...videoExts, ...audioExts]
export const supportedPreviewExts = [...videoExts, ...audioExts]
export default function FilePreviewModal({
isOpen,
@@ -31,15 +31,16 @@ export default function FilePreviewModal({
}: FilePreviewModalProps) {
const ext = path.extname(filePath).toLowerCase()
const { data, loading, error, run } = useRequest(
async (path: string) => FileManager.downloadToURL(path),
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !supportedPreviewExts.includes(ext)) {
return
}
run(filePath)
run()
}
}
)
@@ -55,20 +56,26 @@ export default function FilePreviewModal({
<Spinner />
</div>
)
} else if (imageExts.includes(ext)) {
contentElement = (
<img src={data} alt="预览" className="max-w-full max-h-96" />
)
} else if (videoExts.includes(ext)) {
contentElement = (
<video src={data} controls className="max-w-full max-h-96" />
)
contentElement = <video src={data} controls className="max-w-full" />
} else if (audioExts.includes(ext)) {
contentElement = <audio src={data} controls className="w-full" />
} else {
contentElement = (
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
)
}
useEffect(() => {
if (filePath) {
run()
}
}, [])
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className="flex justify-center items-center">

View File

@@ -12,15 +12,19 @@ import {
TableRow
} from '@heroui/table'
import path from 'path-browserify'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { BiRename } from 'react-icons/bi'
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
import { PhotoSlider } from 'react-photo-view'
import FileIcon from '@/components/file_icon'
import type { FileInfo } from '@/controllers/file_manager'
interface FileTableProps {
import { supportedPreviewExts } from './file_preview_modal'
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
export interface FileTableProps {
files: FileInfo[]
currentPath: string
loading: boolean
@@ -58,147 +62,184 @@ export default function FileTable({
onDownload
}: FileTableProps) {
const [page, setPage] = useState(1)
const pages = Math.ceil(files.length / PAGE_SIZE)
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
const start = (page - 1) * PAGE_SIZE
const end = start + PAGE_SIZE
const displayFiles = files.slice(start, end)
const [showImage, setShowImage] = useState(false)
const [previewIndex, setPreviewIndex] = useState(0)
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
const addPreviewImage = useCallback((image: PreviewImage) => {
setPreviewImages((prev) => {
const exists = prev.some((p) => p.key === image.key)
if (exists) return prev
return [...prev, image]
})
}, [])
useEffect(() => {
setPreviewImages([])
setPreviewIndex(0)
setShowImage(false)
}, [files])
const onPreviewImage = (name: string, images: PreviewImage[]) => {
const index = images.findIndex((image) => image.key === name)
if (index === -1) {
return
}
setPreviewIndex(index)
setShowImage(true)
}
return (
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
bottomContent={
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="danger"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>
</div>
}
>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
<>
<PhotoSlider
images={previewImages}
visible={showImage}
onClose={() => setShowImage(false)}
index={previewIndex}
onIndexChange={setPreviewIndex}
/>
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
bottomContent={
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="danger"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>
</div>
}
items={displayFiles}
>
{(file: FileInfo) => {
const filePath = path.join(currentPath, file.name)
// 判断预览类型
const ext = path.extname(file.name).toLowerCase()
const previewable = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.mp4',
'.webm',
'.mp3',
'.wav'
].includes(ext)
return (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
className="text-left justify-start"
startContent={
<FileIcon name={file.name} isDirectory={file.isDirectory} />
}
>
{file.name}
</Button>
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
)
}}
</TableBody>
</Table>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
>
{displayFiles.map((file: FileInfo) => {
const filePath = path.join(currentPath, file.name)
const ext = path.extname(file.name).toLowerCase()
const previewable = supportedPreviewExts.includes(ext)
const images = previewImages
return (
<TableRow key={file.name}>
<TableCell>
{imageExts.includes(ext) ? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
) : (
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
className="text-left justify-start"
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</>
)
}

View File

@@ -0,0 +1,79 @@
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager'
import FileIcon from '../file_icon'
export interface PreviewImage {
key: string
src: string
alt: string
}
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
export interface ImageNameButtonProps {
name: string
filePath: string
onPreview: () => void
onAddPreview: (image: PreviewImage) => void
}
export default function ImageNameButton({
name,
filePath,
onPreview,
onAddPreview
}: ImageNameButtonProps) {
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !imageExts.includes(ext)) {
return
}
run()
}
}
)
useEffect(() => {
if (data) {
onAddPreview({
key: name,
src: data,
alt: name
})
}
}, [data, name, onAddPreview])
useEffect(() => {
if (filePath) {
run()
}
}, [])
return (
<Button
variant="light"
className="text-left justify-start"
onPress={onPreview}
startContent={
error ? (
<FileIcon name={name} isDirectory={false} />
) : loading || !data ? (
<Spinner size="sm" />
) : (
<Image src={data} alt={name} className="w-8 h-8" radius="sm" />
)
}
>
{name}
</Button>
)
}

View File

@@ -36,7 +36,7 @@ export default function Hitokoto() {
<div className="text-danger-400">{error.message}</div>
) : (
<>
<div className="font-noto-serif">{data?.hitokoto}</div>
<div>{data?.hitokoto}</div>
<div className="text-right">
<span className="text-default-400">{data?.from}</span>{' '}
{data?.from_who}

View File

@@ -34,7 +34,7 @@ export default function HoverTiltedCard({
rotateAmplitude = 14,
showTooltip = false,
overlayContent = (
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
NapCat
</div>
),

View File

@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
shadow="sm"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
>
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color="warning"
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
className="my-4 relative bg-opacity-50 backdrop-blur-md"
>
<PageLoading loading={isFetching} />
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color="warning"

View File

@@ -67,7 +67,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className="font-ubuntu font-bold">{api.description}</h2>
<h2 className="font-bold">{api.description}</h2>
<div
className={clsx('text-sm text-danger-200', {
'!text-danger-400': apiName === selectedApi

View File

@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
<PageLoading loading={loading} />
{error ? (
<CardBody className="items-center gap-1 justify-center">
<div className="font-outfit flex-1 text-content1-foreground">
Error
</div>
<div className="flex-1 text-content1-foreground">Error</div>
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
{error.message}
</div>
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
></div>
</div>
<div className="flex-col justify-center">
<div className="font-outfit text-lg truncate">{data?.nick}</div>
<div className="font-ubuntu text-danger-500 text-sm">
{data?.uin}
</div>
<div className="text-lg truncate">{data?.nick}</div>
<div className="text-danger-500 text-sm">{data?.uin}</div>
</div>
</CardBody>
)}

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

@@ -47,11 +47,11 @@ 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 mt-2 gap-2">
<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 hm-medium',
'flex items-center font-bold',
'!text-2xl shiny-text'
)}
>

View File

@@ -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<SystemInfoProps> = (props) => {
<CardBody className="flex-1">
<div className="flex flex-col justify-between h-full">
<NapCatVersion />
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value={packageJson.version}
/>
<SystemInfoItem
title="QQ 版本"
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
title="系统版本"
icon={<RiMacFill className="text-xl" />}

View File

@@ -10,23 +10,24 @@ interface TerminalInstanceProps {
export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null)
const connected = useRef(false)
const handleData = (data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.data) {
termRef.current?.write(parsed.data)
}
} catch (e) {
termRef.current?.write(data)
}
}
useEffect(() => {
const handleData = (data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.data) {
termRef.current?.write(parsed.data)
}
} catch (e) {
termRef.current?.write(data)
}
}
TerminalManager.connectTerminal(id, handleData)
return () => {
TerminalManager.disconnectTerminal(id, handleData)
if (connected.current) {
TerminalManager.disconnectTerminal(id, handleData)
}
}
}, [id])
@@ -34,5 +35,22 @@ export function TerminalInstance({ id }: TerminalInstanceProps) {
TerminalManager.sendInput(id, data)
}
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
const handleResize = (cols: number, rows: number) => {
if (!connected.current) {
connected.current = true
console.log('instance', rows, cols)
TerminalManager.connectTerminal(id, handleData, { rows, cols })
} else {
TerminalManager.sendResize(id, cols, rows)
}
}
return (
<XTerm
ref={termRef}
onInput={handleInput}
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
className="w-full h-full"
/>
)
}

View File

@@ -1,6 +1,7 @@
import { CanvasAddon } from '@xterm/addon-canvas'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
// import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import clsx from 'clsx'
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTheme } from '@/hooks/use-theme'
import { gradientText } from '@/utils/terminal'
export type XTermRef = {
write: (
...args: Parameters<Terminal['write']>
@@ -20,53 +19,44 @@ export type XTermRef = {
) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void
terminalRef: React.RefObject<Terminal | null>
}
export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void
onResize?: (cols: number, rows: number) => void // 新增属性
}
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, onKey, ...rest } = props
const { className, onInput, onKey, onResize, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
fontFamily:
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false
drawBoldTextInBrightColors: false,
fontSize: 14,
lineHeight: 1.2
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
if (event.ctrlKey || event.metaKey) {
window.open(uri, '_blank')
}
})
)
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
terminal.open(domRef.current!)
terminal.loadAddon(new CanvasAddon())
terminal.onData((data) => {
if (onInput) {
onInput(data)
@@ -81,6 +71,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
// 获取当前终端尺寸
const cols = terminal.cols
const rows = terminal.rows
if (onResize) {
onResize(cols, rows)
}
})
// 字体加载完成后重新调整终端大小
@@ -100,21 +96,49 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background: theme === 'dark' ? '#00000000' : '#ffffff00',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground:
theme === 'dark'
? 'rgba(179, 0, 0, 0.3)'
: 'rgba(255, 167, 167, 0.3)',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
if (theme === 'dark') {
terminalRef.current.options.theme = {
background: '#00000000',
black: '#ffffff',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
foreground: '#cccccc',
selectionBackground: '#3a3d41',
cursor: '#ffffff'
}
} else {
terminalRef.current.options.theme = {
background: '#ffffff00',
black: '#000000',
red: '#aa3731',
green: '#448c27',
yellow: '#cb9000',
blue: '#325cc0',
cyan: '#0083b2',
white: '#7f7f7f',
brightBlack: '#777777',
brightRed: '#f05050',
brightGreen: '#60cb00',
brightYellow: '#ffbc5d',
brightBlue: '#007acc',
brightCyan: '#00aacb',
brightWhite: '#b0b0b0',
foreground: '#000000',
selectionBackground: '#bfdbfe',
cursor: '#007acc'
}
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])
@@ -139,7 +163,8 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
},
clear: () => {
terminalRef.current?.clear()
}
},
terminalRef: terminalRef
}),
[]
)

View File

@@ -51,7 +51,7 @@ export const siteConfig = {
href: '/config'
},
{
label: 'NapCat日志',
label: '猫猫日志',
icon: (
<div className="w-5 h-5">
<LogIcon />

View File

@@ -41,9 +41,16 @@ class TerminalManager {
return data.data
}
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
connectTerminal(
id: string,
callback: TerminalCallback,
config?: {
cols?: number
rows?: number
}
): WebSocket {
let conn = this.connections.get(id)
const { cols = 80, rows = 24 } = config || {}
if (!conn) {
const url = new URL(window.location.href)
url.protocol = url.protocol.replace('http', 'ws')
@@ -74,6 +81,7 @@ class TerminalManager {
ws.onopen = () => {
if (conn) conn.isConnected = true
this.sendResize(id, cols, rows)
}
ws.onclose = () => {
@@ -111,6 +119,13 @@ class TerminalManager {
conn.ws.send(JSON.stringify({ type: 'input', data }))
}
}
sendResize(id: string, cols: number, rows: number) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
}
}
const terminalManager = new TerminalManager()

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

@@ -98,7 +98,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
>
<div
className={clsx(
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
'dark:bg-background dark:shadow-danger-100',
'bg-background !bg-opacity-50',
'shadow-sm shadow-danger-50',

View File

@@ -1,4 +1,5 @@
import ReactDOM from 'react-dom/client'
import 'react-photo-view/dist/react-photo-view.css'
import { BrowserRouter } from 'react-router-dom'
import App from '@/App.tsx'

View File

@@ -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 (
<div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
NapCat
</Chip>
}
>
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="flex items-center gap-2">
<div className="text-danger-500 drop-shadow-md">NapCat</div>
{error ? (
error.message
) : loading ? (
<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>
)
}
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 (
<>
<title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
<div className="flex flex-col md:flex-row items-center mb-6">
<HoverTiltedCard imageSrc={logo} />
<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>
<VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4">
<p
className={clsx(
title({
color: 'cyan',
shadow: true
}),
'!text-3xl'
)}
>
NapCat Contributors
</p>
<Image
className="w-[600px] max-w-full pointer-events-none select-none"
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
alt="Contributors"
/>
<div className="flex-1 flex flex-col gap-2 py-2">
<VersionInfo />
<div className="space-y-1">
<p className="font-bold text-danger-400">NapCat ?</p>
<p className="text-default-800">
TypeScript构建的Bot框架,,QQ
Node模块提供给客户端的接口,Bot的功能.
</p>
<p className="font-bold text-danger-400"></p>
<p className="text-default-800">
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p>
</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 />
</div>
</section>

View File

@@ -15,7 +15,7 @@ import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
export default function WSDebug() {
const url = new URL(window.location.origin)
url.port = '3000'
url.port = '3001'
url.protocol = 'ws:'
const defaultWsUrl = url.href
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {

View File

@@ -100,7 +100,7 @@ export default function TerminalPage() {
)
return (
<div className="flex flex-col gap-2 p-4">
<div className="flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}

View File

@@ -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 (
<DefaultLayout>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
>
<Routes location={location} key={location.pathname}>
<Route element={<DashboardIndexPage />} path="/" />
<Route element={<NetworkPage />} path="/network" />
<Route element={<ConfigPage />} path="/config" />
<Route element={<LogsPage />} path="/logs" />
<Route element={<DebugPage />} path="/debug">
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route element={<FileManagerPage />} path="/file_manager" />
<Route element={<TerminalPage />} path="/terminal" />
<Route element={<AboutPage />} path="/about" />
</Routes>
</motion.div>
</AnimatePresence>
<Suspense
fallback={
<div className="flex justify-center px-10">
<Spinner />
</div>
}
>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: 'tween',
ease: 'easeInOut'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
</Suspense>
</DefaultLayout>
)
}

View File

@@ -1,111 +1,13 @@
/* HarmonyOS Sans SC */
@font-face {
font-family: 'Harmony';
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-family: 'Aa偷吃可爱长大的';
src: url('/fonts/AaCute.woff') format('woff');
}
@font-face {
font-family: 'Harmony';
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
}
/* Ubuntu */
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
font-weight: bold;
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
font-style: italic;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Ubuntu';
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
/* LibreBaskerville */
@font-face {
font-family: 'Libre Baskerville';
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: 'Libre Baskerville';
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Libre Baskerville';
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
}
/* NotoSerifSC */
@font-face {
font-family: 'Noto Serif SC';
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
}
/* Outfit */
@font-face {
font-family: 'Outfit';
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
}
/* FiraCode */
@font-face {
font-family: 'Fira Code';
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
}

View File

@@ -6,35 +6,18 @@
body {
font-family:
'Aa偷吃可爱长大的',
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-smooth: always;
}
@layer components {
.hm-medium {
font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
@apply font-bold;
}
.font-ubuntu {
font-family: 'Ubuntu', sans-serif;
}
.font-outfit {
font-family: 'Outfit', sans-serif;
}
.font-libre {
font-family: 'Libre Baskerville', serif;
}
.font-noto-serif {
font-family: 'Noto Serif SC', serif;
}
.hide-scrollbar::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
@@ -105,7 +88,7 @@ body {
.context-view.monaco-menu-container * {
font-family:
PingFang SC,
'Harmony',
'Aa偷吃可爱长大的',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
@@ -116,4 +99,4 @@ body {
}
.ql-editor img {
@apply inline-block;
}
}

View File

@@ -195,7 +195,7 @@ export interface OneBot11GroupUpload extends NoticeBase {
name: string
/** 文件大小(字节数) */
size: number
/** busid(目前不清楚有什么作用 */
/** busid作用 */
busid: number
}
}

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.4.20",
"version": "4.5.3",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",

View File

@@ -2,73 +2,73 @@ import path from 'node:path';
import fs from 'node:fs';
import type { NapCatCore } from '@/core';
import json5 from 'json5';
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
export abstract class ConfigBase<T> {
name: string;
core: NapCatCore;
configPath: string;
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.core = core;
this.configPath = configPath;
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
this.validate = this.ajv.compile<T>(ConfigSchema);
fs.mkdirSync(this.configPath, { recursive: true });
this.read(copy_default);
this.read();
}
protected getKeys(): string[] | null {
// 决定 key 在json配置文件中的顺序
return null;
getConfigPath(pathName?: string): string {
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
return path.join(this.configPath, filename);
}
getConfigPath(pathName: string | undefined): string {
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 {
read(): T {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
if (!fs.existsSync(configPath) && copy_default) {
try {
fs.writeFileSync(configPath, fs.readFileSync(this.getConfigPath(undefined), 'utf-8'));
this.core.context.logger.log('[Core] [Config] 配置文件创建成功!\n');
} catch (e: unknown) {
this.core.context.logger.logError('[Core] [Config] 创建配置文件时发生错误:', (e as Error).message);
const defaultConfigPath = this.getConfigPath();
if (!fs.existsSync(configPath)) {
if (fs.existsSync(defaultConfigPath)) {
this.configData = this.loadConfig(defaultConfigPath);
}
} else if (!fs.existsSync(configPath) && !copy_default) {
fs.writeFileSync(configPath, '{}');
this.save();
return this.configData;
}
return this.loadConfig(configPath);
}
private loadConfig(configPath: string): T {
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);
return this.configData;
} catch (e: unknown) {
if (e instanceof SyntaxError) {
this.core.context.logger.logError('[Core] [Config] 配置文件格式错误,请检查配置文件:', e.message);
} else {
this.core.context.logger.logError('[Core] [Config] 读取配置文件时发生错误:', (e as Error).message);
}
this.handleError(e, '读取配置文件时发生错误');
return {} as T;
}
}
save(newConfigData: T = this.configData) {
const selfInfo = this.core.selfInfo;
save(newConfigData: T = this.configData): void {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
this.validate(newConfigData);
this.configData = newConfigData;
const configPath = this.getConfigPath(selfInfo.uin);
try {
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
} 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);
}
}
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.4.20';
export const napCatVersion = '4.5.3';

View File

@@ -34,6 +34,8 @@ export class NTQQFileApi {
core: NapCatCore;
rkeyManager: RkeyManager;
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint }> | undefined;
private fetchRkeyFailures: number = 0;
private readonly MAX_RKEY_FAILURES: number = 8;
constructor(context: InstanceContext, core: NapCatCore) {
this.context = context;
@@ -41,10 +43,26 @@ export class NTQQFileApi {
this.rkeyManager = new RkeyManager([
'https://rkey.napneko.icu/rkeys'
],
this.context.logger
this.context.logger
);
}
private async fetchRkeyWithRetry() {
if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
throw new Error('Native.FetchRkey 已被禁用');
}
try {
let ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
this.fetchRkeyFailures = 0; // Reset failures on success
return ret;
} catch (error) {
this.fetchRkeyFailures++;
this.context.logger.logError('FetchRkey 失败', (error as Error).message);
throw error;
}
}
async copyFile(filePath: string, destPath: string) {
await this.core.util.copyFile(filePath, destPath);
}
@@ -182,7 +200,6 @@ export class NTQQFileApi {
}
}
context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size;
const thumbMd5 = await calculateFileMD5(thumbPath);
context.deleteAfterSentFiles.push(thumbPath);
@@ -283,18 +300,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
break;
}
elementIndex++;
}
@@ -421,7 +438,7 @@ export class NTQQFileApi {
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
if (rkey_expired_private || rkey_expired_group) {
this.packetRkey = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
this.packetRkey = await this.fetchRkeyWithRetry();
}
if (this.packetRkey && this.packetRkey.length > 0) {
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
@@ -430,7 +447,7 @@ export class NTQQFileApi {
}
}
} catch (error: unknown) {
this.context.logger.logError('获取rkey失败', (error as Error).message);
this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
}
if (!rkeyData.online_rkey) {
@@ -439,11 +456,11 @@ export class NTQQFileApi {
rkeyData.group_rkey = tempRkeyData.group_rkey;
rkeyData.private_rkey = tempRkeyData.private_rkey;
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
} catch (e) {
this.context.logger.logDebug('获取rkey失败 Fallback Old Mode', e);
} catch (error: unknown) {
this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
}
}
// 进行 fallback.rkey 模式
return rkeyData;
}

View File

@@ -1,11 +1,21 @@
import { ConfigBase } from '@/common/config-base';
import napCatDefaultConfig from '@/core/external/napcat.json';
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> {
constructor(core: NapCatCore, configPath: string) {
super('napcat', core, configPath);
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
super('napcat', core, configPath, schema);
}
}

View File

@@ -25,7 +25,7 @@ import fs from 'node:fs';
import { hostname, systemName, systemVersion } from '@/common/system';
import { NTEventWrapper } from '@/common/event';
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 { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
import { proxiedListenerOf } from '@/common/proxy-handler';
@@ -99,7 +99,7 @@ export class NapCatCore {
this.context = context;
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
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 = {
FileApi: new NTQQFileApi(this.context, this),
SystemApi: new NTQQSystemApi(this.context, this),

View File

@@ -17,8 +17,8 @@ export class SetDiyOnlineStatus extends OneBotAction<Payload, string> {
async _handle(payload: Payload) {
const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus(
payload.face_id.toString(),
payload.face_type.toString(),
payload.wording,
payload.face_type.toString(),
);
if (ret.result !== 0) {
throw new Error('设置在线状态失败');

View File

@@ -22,7 +22,6 @@ import { OB11GroupEssenceEvent } from '@/onebot/event/notice/OB11GroupEssenceEve
import { OB11GroupTitleEvent } from '@/onebot/event/notice/OB11GroupTitleEvent';
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
import { pathToFileURL } from 'node:url';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent';
@@ -202,8 +201,7 @@ export class OneBotGroupApi {
id: FileNapCatOneBotUUID.encode({
chatType: ChatType.KCHATTYPEGROUP,
peerUid: msg.peerUid,
}, msg.msgId, elementWrapper.elementId, elementWrapper?.fileElement?.fileUuid, element.fileName),
url: pathToFileURL(element.filePath).href,
}, msg.msgId, elementWrapper.elementId, elementWrapper?.fileElement?.fileUuid, element.fileMd5 ?? element.fileUuid),
name: element.fileName,
size: parseInt(element.fileSize),
busid: element.fileBizId ?? 0,

View File

@@ -1,6 +1,5 @@
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { MessageUnique } from '@/common/message-unique';
import { pathToFileURL } from 'node:url';
import {
NTMsgAtType,
ChatType,
@@ -146,7 +145,6 @@ export class OneBotMsgApi {
data: {
file: file,
path: element.filePath,
url: pathToFileURL(element.filePath).href,
file_id: file,
file_size: element.fileSize,
},
@@ -334,7 +332,7 @@ export class OneBotMsgApi {
data: {
file: fileCode,
path: videoDownUrl,
url: videoDownUrl ?? pathToFileURL(element.filePath).href,
url: videoDownUrl,
file_size: element.fileSize,
},
};
@@ -352,7 +350,6 @@ export class OneBotMsgApi {
data: {
file: fileCode,
path: element.filePath,
url: pathToFileURL(element.filePath).href,
file_size: element.fileSize,
},
};
@@ -860,7 +857,7 @@ export class OneBotMsgApi {
return parsedElement;
}
}
return [];
return;
},
));

View File

@@ -78,11 +78,11 @@ const NetworkConfigSchema = Type.Object({
plugins: Type.Array(PluginConfigSchema, { default: [] })
}, { default: {} });
const OneBotConfigSchema = Type.Object({
export const OneBotConfigSchema = Type.Object({
network: NetworkConfigSchema,
musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: true })
parseMultMsg: Type.Boolean({ default: false })
});
export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

@@ -1,9 +1,10 @@
import { ConfigBase } from '@/common/config-base';
import { NapCatCore } from '@/core';
import { OneBotConfig } from './config';
import { AnySchema } from 'ajv';
export class OB11ConfigLoader extends ConfigBase<OneBotConfig> {
constructor(core: NapCatCore, configPath: string) {
super('onebot11', core, configPath, false);
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
super('onebot11', core, configPath, schema);
}
}

View File

@@ -6,7 +6,6 @@ export interface GroupUploadFile {
name: string,
size: number,
busid: number,
url:string;
}
export class OB11GroupUploadNoticeEvent extends OB11GroupNoticeEvent {

View File

@@ -44,8 +44,8 @@ import { LRUCache } from '@/common/lru-cache';
import { BotOfflineEvent } from './event/notice/BotOfflineEvent';
import {
NetworkAdapterConfig,
loadConfig,
OneBotConfig,
OneBotConfigSchema,
} from './config/config';
import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
@@ -66,9 +66,7 @@ export class NapCatOneBot11Adapter {
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath);
this.configLoader.save(this.configLoader.configData);
this.configLoader.save(loadConfig(this.configLoader.configData));
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
this.apis = {
GroupApi: new OneBotGroupApi(this, core),
UserApi: new OneBotUserApi(this, core),
@@ -176,9 +174,6 @@ export class NapCatOneBot11Adapter {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
const prev = this.configLoader.configData;
// 保证默认配置
newConfig = loadConfig(newConfig);
this.configLoader.save(newConfig);
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
await this.reloadNetwork(prev, newConfig);

View File

@@ -15,7 +15,7 @@ import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer: WebSocketServer;
wsServer?: WebSocketServer;
wsClients: WebSocket[] = [];
wsClientsMutex = new Mutex();
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,
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) {
wsClient.close();
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 isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
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));
@@ -74,7 +78,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
});
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
}
connectEvent(core: NapCatCore, wsClient: WebSocket) {
try {
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');
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.isEnable = true;
@@ -108,7 +111,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
async close() {
this.isEnable = false;
this.wsServer.close((err) => {
this.wsServer?.close((err) => {
if (err) {
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
} else {
@@ -205,6 +208,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
if (newConfig.enable) {
this.open();
}

View File

@@ -268,7 +268,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
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);
res.setHeader('Content-Type', 'application/octet-stream');

View File

@@ -6,7 +6,11 @@ import { terminalManager } from '../terminal/terminal_manager';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query.id as string;
const filename = req.query['id'];
if (!filename || typeof filename !== 'string') {
return sendError(res, 'ID不能为空');
}
if (filename.includes('..')) {
return sendError(res, 'ID不合法');
}
@@ -40,7 +44,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
try {
const { id } = terminalManager.createTerminal();
const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);
return sendSuccess(res, { id });
} catch (error) {
console.error('Failed to create terminal:', error);
@@ -54,7 +59,10 @@ export const GetTerminalListHandler: RequestHandler = (_, res) => {
};
export const CloseTerminalHandler: RequestHandler = (req, res) => {
const id = req.params.id;
const id = req.params['id'];
if (!id) {
return sendError(res, 'ID不能为空');
}
terminalManager.closeTerminal(id);
return sendSuccess(res, {});
};

View File

@@ -13,6 +13,8 @@ interface TerminalInstance {
sockets: Set<WebSocket>;
// 新增标识,用于防止重复关闭
isClosing: boolean;
// 新增:存储终端历史输出
buffer: string;
}
class TerminalManager {
@@ -67,21 +69,24 @@ class TerminalManager {
return;
}
const dataHandler = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
};
instance.sockets.add(ws);
instance.lastAccess = Date.now();
// 新增:发送当前终端内容给新连接
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
}
ws.on('message', (data) => {
if (instance) {
const result = JSON.parse(data.toString());
if (result.type === 'input') {
instance.pty.write(result.data);
}
// 新增:处理 resize 消息
if (result.type === 'resize') {
instance.pty.resize(result.cols, result.rows);
}
}
});
@@ -103,18 +108,17 @@ class TerminalManager {
});
}
// 修改:移除参数 id使用 crypto.randomUUID 生成终端 id
createTerminal() {
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
createTerminal(cols: number, rows: number) {
const id = randomUUID();
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const pty = ptySpawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cols, // 使用客户端传入的 cols
rows, // 使用客户端传入的 rows
cwd: process.cwd(),
env: {
...process.env,
// 统一编码设置
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
TERM: 'xterm-256color',
},
@@ -125,9 +129,13 @@ class TerminalManager {
lastAccess: Date.now(),
sockets: new Set(),
isClosing: false,
buffer: '', // 初始化终端内容缓存
};
pty.onData((data: any) => {
// 追加数据到 buffer
instance.buffer += data;
// 发送数据给已连接的 websocket
instance.sockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));