mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
cb8727d487 | ||
![]() |
a94e03e2fd | ||
![]() |
425c3c6432 | ||
![]() |
89b9610016 | ||
![]() |
62fe88f868 | ||
![]() |
11a7f5fade | ||
![]() |
fbde997f7c | ||
![]() |
26734a35ef | ||
![]() |
715c4ac534 | ||
![]() |
bd4b0885a1 | ||
![]() |
e3c7af3d91 | ||
![]() |
a7ee21bfd8 | ||
![]() |
d0f51d92ac | ||
![]() |
e6dc148ea2 | ||
![]() |
514ab6637f | ||
![]() |
377794abe8 | ||
![]() |
0f3251f35b | ||
![]() |
8002dc5bc5 | ||
![]() |
c75a13dcf4 | ||
![]() |
91d153bb9d |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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
55
.vscode/tailwindcss.json
vendored
Normal 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 you’d 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.4.20",
|
||||
"version": "4.5.3",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -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",
|
||||
|
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
)
|
||||
}
|
||||
|
@@ -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',
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
|
@@ -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>
|
||||
),
|
||||
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
)}
|
||||
|
265
napcat.webui/src/components/rotating_text.tsx
Normal file
265
napcat.webui/src/components/rotating_text.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
AnimatePresence,
|
||||
HTMLMotionProps,
|
||||
TargetAndTransition,
|
||||
Transition,
|
||||
motion
|
||||
} from 'motion/react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export interface RotatingTextRef {
|
||||
next: () => void
|
||||
previous: () => void
|
||||
jumpTo: (index: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface RotatingTextProps
|
||||
extends Omit<
|
||||
HTMLMotionProps<'span'>,
|
||||
'children' | 'transition' | 'initial' | 'animate' | 'exit'
|
||||
> {
|
||||
texts: string[]
|
||||
transition?: Transition
|
||||
initial?: TargetAndTransition
|
||||
animate?: TargetAndTransition
|
||||
exit?: TargetAndTransition
|
||||
animatePresenceMode?: 'sync' | 'wait'
|
||||
animatePresenceInitial?: boolean
|
||||
rotationInterval?: number
|
||||
staggerDuration?: number
|
||||
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
|
||||
loop?: boolean
|
||||
auto?: boolean
|
||||
splitBy?: string
|
||||
onNext?: (index: number) => void
|
||||
mainClassName?: string
|
||||
splitLevelClassName?: string
|
||||
elementLevelClassName?: string
|
||||
}
|
||||
|
||||
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
|
||||
(
|
||||
{
|
||||
texts,
|
||||
transition = { type: 'spring', damping: 25, stiffness: 300 },
|
||||
initial = { y: '100%', opacity: 0 },
|
||||
animate = { y: 0, opacity: 1 },
|
||||
exit = { y: '-120%', opacity: 0 },
|
||||
animatePresenceMode = 'wait',
|
||||
animatePresenceInitial = false,
|
||||
rotationInterval = 2000,
|
||||
staggerDuration = 0,
|
||||
staggerFrom = 'first',
|
||||
loop = true,
|
||||
auto = true,
|
||||
splitBy = 'characters',
|
||||
onNext,
|
||||
mainClassName,
|
||||
splitLevelClassName,
|
||||
elementLevelClassName,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
return Array.from(text)
|
||||
}
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const currentText: string = texts[currentTextIndex]
|
||||
if (splitBy === 'characters') {
|
||||
const words = currentText.split(' ')
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1
|
||||
}))
|
||||
}
|
||||
if (splitBy === 'words') {
|
||||
return currentText.split(' ').map((word, i, arr) => ({
|
||||
characters: [word],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}))
|
||||
}
|
||||
if (splitBy === 'lines') {
|
||||
return currentText.split('\n').map((line, i, arr) => ({
|
||||
characters: [line],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}))
|
||||
}
|
||||
|
||||
return currentText.split(splitBy).map((part, i, arr) => ({
|
||||
characters: [part],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}))
|
||||
}, [texts, currentTextIndex, splitBy])
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index: number, totalChars: number): number => {
|
||||
const total = totalChars
|
||||
if (staggerFrom === 'first') return index * staggerDuration
|
||||
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
|
||||
if (staggerFrom === 'center') {
|
||||
const center = Math.floor(total / 2)
|
||||
return Math.abs(center - index) * staggerDuration
|
||||
}
|
||||
if (staggerFrom === 'random') {
|
||||
const randomIndex = Math.floor(Math.random() * total)
|
||||
return Math.abs(randomIndex - index) * staggerDuration
|
||||
}
|
||||
return Math.abs((staggerFrom as number) - index) * staggerDuration
|
||||
},
|
||||
[staggerFrom, staggerDuration]
|
||||
)
|
||||
|
||||
const handleIndexChange = useCallback(
|
||||
(newIndex: number) => {
|
||||
setCurrentTextIndex(newIndex)
|
||||
if (onNext) onNext(newIndex)
|
||||
},
|
||||
[onNext]
|
||||
)
|
||||
|
||||
const next = useCallback(() => {
|
||||
const nextIndex =
|
||||
currentTextIndex === texts.length - 1
|
||||
? loop
|
||||
? 0
|
||||
: currentTextIndex
|
||||
: currentTextIndex + 1
|
||||
if (nextIndex !== currentTextIndex) {
|
||||
handleIndexChange(nextIndex)
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||
|
||||
const previous = useCallback(() => {
|
||||
const prevIndex =
|
||||
currentTextIndex === 0
|
||||
? loop
|
||||
? texts.length - 1
|
||||
: currentTextIndex
|
||||
: currentTextIndex - 1
|
||||
if (prevIndex !== currentTextIndex) {
|
||||
handleIndexChange(prevIndex)
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||
|
||||
const jumpTo = useCallback(
|
||||
(index: number) => {
|
||||
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
|
||||
if (validIndex !== currentTextIndex) {
|
||||
handleIndexChange(validIndex)
|
||||
}
|
||||
},
|
||||
[texts.length, currentTextIndex, handleIndexChange]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (currentTextIndex !== 0) {
|
||||
handleIndexChange(0)
|
||||
}
|
||||
}, [currentTextIndex, handleIndexChange])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
next,
|
||||
previous,
|
||||
jumpTo,
|
||||
reset
|
||||
}),
|
||||
[next, previous, jumpTo, reset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!auto) return
|
||||
const intervalId = setInterval(next, rotationInterval)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [next, rotationInterval, auto])
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
'flex flex-wrap whitespace-pre-wrap relative',
|
||||
mainClassName
|
||||
)}
|
||||
{...rest}
|
||||
layout
|
||||
transition={transition}
|
||||
>
|
||||
<span className="sr-only">{texts[currentTextIndex]}</span>
|
||||
<AnimatePresence
|
||||
mode={animatePresenceMode}
|
||||
initial={animatePresenceInitial}
|
||||
>
|
||||
<motion.div
|
||||
key={currentTextIndex}
|
||||
className={cn(
|
||||
splitBy === 'lines'
|
||||
? 'flex flex-col w-full'
|
||||
: 'flex flex-wrap whitespace-pre-wrap relative'
|
||||
)}
|
||||
layout
|
||||
aria-hidden="true"
|
||||
initial={initial as HTMLMotionProps<'div'>['initial']}
|
||||
animate={animate as HTMLMotionProps<'div'>['animate']}
|
||||
exit={exit as HTMLMotionProps<'div'>['exit']}
|
||||
>
|
||||
{elements.map((wordObj, wordIndex, array) => {
|
||||
const previousCharsCount = array
|
||||
.slice(0, wordIndex)
|
||||
.reduce((sum, word) => sum + word.characters.length, 0)
|
||||
return (
|
||||
<span
|
||||
key={wordIndex}
|
||||
className={cn('inline-flex', splitLevelClassName)}
|
||||
>
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<motion.span
|
||||
key={charIndex}
|
||||
initial={initial as HTMLMotionProps<'span'>['initial']}
|
||||
animate={animate as HTMLMotionProps<'span'>['animate']}
|
||||
exit={exit as HTMLMotionProps<'span'>['exit']}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: getStaggerDelay(
|
||||
previousCharsCount + charIndex,
|
||||
array.reduce(
|
||||
(sum, word) => sum + word.characters.length,
|
||||
0
|
||||
)
|
||||
)
|
||||
}}
|
||||
className={cn('inline-block', elementLevelClassName)}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
{wordObj.needsSpace && (
|
||||
<span className="whitespace-pre"> </span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RotatingText.displayName = 'RotatingText'
|
||||
export default RotatingText
|
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
@@ -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" />}
|
||||
|
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@@ -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
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
@@ -51,7 +51,7 @@ export const siteConfig = {
|
||||
href: '/config'
|
||||
},
|
||||
{
|
||||
label: 'NapCat日志',
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<LogIcon />
|
||||
|
@@ -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()
|
||||
|
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 全局图片缓存
|
||||
const imageCache = new Map<string, HTMLImageElement>()
|
||||
|
||||
export function usePreloadImages(urls: string[]) {
|
||||
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
// 检查是否所有图片都已缓存
|
||||
const allCached = urls.every((url) => imageCache.has(url))
|
||||
if (allCached) {
|
||||
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const loadedImages: Record<string, boolean> = {}
|
||||
let pendingCount = urls.length
|
||||
|
||||
urls.forEach((url) => {
|
||||
// 如果已经缓存,直接标记为已加载
|
||||
if (imageCache.has(url)) {
|
||||
loadedImages[url] = true
|
||||
pendingCount--
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = true
|
||||
imageCache.set(url, img)
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = false
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [urls])
|
||||
|
||||
return { loadedUrls, isLoading }
|
||||
}
|
@@ -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',
|
||||
|
@@ -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'
|
||||
|
@@ -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>
|
||||
|
@@ -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, {
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -195,7 +195,7 @@ export interface OneBot11GroupUpload extends NoticeBase {
|
||||
name: string
|
||||
/** 文件大小(字节数) */
|
||||
size: number
|
||||
/** busid(目前不清楚有什么作用) */
|
||||
/** busid 无作用 */
|
||||
busid: number
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.4.20';
|
||||
export const napCatVersion = '4.5.3';
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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('设置在线状态失败');
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
},
|
||||
));
|
||||
|
||||
|
@@ -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>;
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -6,7 +6,6 @@ export interface GroupUploadFile {
|
||||
name: string,
|
||||
size: number,
|
||||
busid: number,
|
||||
url:string;
|
||||
}
|
||||
|
||||
export class OB11GroupUploadNoticeEvent extends OB11GroupNoticeEvent {
|
||||
|
@@ -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);
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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, {});
|
||||
};
|
||||
|
@@ -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 }));
|
||||
|
Reference in New Issue
Block a user