Compare commits

...

22 Commits

Author SHA1 Message Date
手瓜一十雪
c9e91a9b94 fix: defalut config 2025-02-04 19:49:56 +08:00
Mlikiowa
43fb62c5bd release: v4.5.4 2025-02-04 11:35:51 +00:00
手瓜一十雪
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
68 changed files with 1185 additions and 543 deletions

View File

@@ -5,5 +5,6 @@
".env.universal": ".env.*", ".env.universal": ".env.*",
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts", "tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE" "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", "name": "NapCatQQ",
"slug": "NapCat.Framework", "slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现", "description": "高性能的 OneBot 11 协议实现",
"version": "4.4.20", "version": "4.5.4",
"icon": "./logo.png", "icon": "./logo.png",
"authors": [ "authors": [
{ {

View File

@@ -46,9 +46,9 @@
"@react-aria/visually-hidden": "^3.8.19", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -70,6 +70,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-responsive": "^10.0.0", "react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4", "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 WebLoginPage = lazy(() => import('@/pages/web_login'))
const IndexPage = lazy(() => import('@/pages/index')) const IndexPage = lazy(() => import('@/pages/index'))
const QQLoginPage = lazy(() => import('@/pages/qq_login')) const QQLoginPage = lazy(() => import('@/pages/qq_login'))
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
function App() { function App() {
return ( return (
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
function AppRoutes() { function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route element={<IndexPage />} path="/*" /> <Route path="/" element={<IndexPage />}>
<Route element={<QQLoginPage />} path="/qq_login" /> <Route index element={<DashboardIndexPage />} />
<Route element={<WebLoginPage />} path="/web_login" /> <Route path="network" element={<NetworkPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="debug" element={<DebugPage />}>
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route path="file_manager" element={<FileManagerPage />} />
<Route path="terminal" element={<TerminalPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="/qq_login" element={<QQLoginPage />} />
<Route path="/web_login" element={<WebLoginPage />} />
</Routes> </Routes>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ export default function HoverTiltedCard({
rotateAmplitude = 14, rotateAmplitude = 14,
showTooltip = false, showTooltip = false,
overlayContent = ( 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 NapCat
</div> </div>
), ),

View File

@@ -20,7 +20,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
enable: false, enable: false,
name: '', name: '',
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 3001,
reportSelfMessage: false, reportSelfMessage: false,
enableForcePushEvent: true, enableForcePushEvent: true,
messagePostFormat: 'array', messagePostFormat: 'array',

View File

@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
shadow="sm" shadow="sm"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20" 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> <span className="mr-2"></span>
<Button <Button
color="warning" color="warning"
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
className="my-4 relative bg-opacity-50 backdrop-blur-md" className="my-4 relative bg-opacity-50 backdrop-blur-md"
> >
<PageLoading loading={isFetching} /> <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> <span className="mr-2"></span>
<Button <Button
color="warning" color="warning"

View File

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

View File

@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
<PageLoading loading={loading} /> <PageLoading loading={loading} />
{error ? ( {error ? (
<CardBody className="items-center gap-1 justify-center"> <CardBody className="items-center gap-1 justify-center">
<div className="font-outfit flex-1 text-content1-foreground"> <div className="flex-1 text-content1-foreground">Error</div>
Error
</div>
<div className="whitespace-nowrap text-nowrap flex-shrink-0"> <div className="whitespace-nowrap text-nowrap flex-shrink-0">
{error.message} {error.message}
</div> </div>
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
></div> ></div>
</div> </div>
<div className="flex-col justify-center"> <div className="flex-col justify-center">
<div className="font-outfit text-lg truncate">{data?.nick}</div> <div className="text-lg truncate">{data?.nick}</div>
<div className="font-ubuntu text-danger-500 text-sm"> <div className="text-danger-500 text-sm">{data?.uin}</div>
{data?.uin}
</div>
</div> </div>
</CardBody> </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' }} 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"> <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" /> <Image radius="none" height={40} src={logo} className="mb-2" />
<div <div
className={clsx( className={clsx(
'flex items-center hm-medium', 'flex items-center font-bold',
'!text-2xl shiny-text' '!text-2xl shiny-text'
)} )}
> >

View File

@@ -16,7 +16,6 @@ import { compareVersion } from '@/utils/version'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
import { GithubRelease } from '@/types/github' import { GithubRelease } from '@/types/github'
import packageJson from '../../package.json'
import TailwindMarkdown from './tailwind_markdown' import TailwindMarkdown from './tailwind_markdown'
export interface SystemInfoItemProps { export interface SystemInfoItemProps {
@@ -198,11 +197,6 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
<CardBody className="flex-1"> <CardBody className="flex-1">
<div className="flex flex-col justify-between h-full"> <div className="flex flex-col justify-between h-full">
<NapCatVersion /> <NapCatVersion />
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value={packageJson.version}
/>
<SystemInfoItem <SystemInfoItem
title="QQ 版本" title="QQ 版本"
icon={<FaQq className="text-lg" />} icon={<FaQq className="text-lg" />}
@@ -216,6 +210,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
) )
} }
/> />
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value="Next"
/>
<SystemInfoItem <SystemInfoItem
title="系统版本" title="系统版本"
icon={<RiMacFill className="text-xl" />} icon={<RiMacFill className="text-xl" />}

View File

@@ -10,23 +10,24 @@ interface TerminalInstanceProps {
export function TerminalInstance({ id }: TerminalInstanceProps) { export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null) 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(() => { 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 () => { return () => {
TerminalManager.disconnectTerminal(id, handleData) if (connected.current) {
TerminalManager.disconnectTerminal(id, handleData)
}
} }
}, [id]) }, [id])
@@ -34,5 +35,22 @@ export function TerminalInstance({ id }: TerminalInstanceProps) {
TerminalManager.sendInput(id, data) 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 { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links' 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 { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import clsx from 'clsx' import clsx from 'clsx'
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTheme } from '@/hooks/use-theme' import { useTheme } from '@/hooks/use-theme'
import { gradientText } from '@/utils/terminal'
export type XTermRef = { export type XTermRef = {
write: ( write: (
...args: Parameters<Terminal['write']> ...args: Parameters<Terminal['write']>
@@ -20,53 +19,44 @@ export type XTermRef = {
) => ReturnType<Terminal['writeln']> ) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void> writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void clear: () => void
terminalRef: React.RefObject<Terminal | null>
} }
export interface XTermProps export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void onKey?: (key: string, event: KeyboardEvent) => void
onResize?: (cols: number, rows: number) => void // 新增属性
} }
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => { const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null) const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null) const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, onKey, ...rest } = props const { className, onInput, onKey, onResize, ...rest } = props
const { theme } = useTheme() const { theme } = useTheme()
useEffect(() => { useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({ const terminal = new Terminal({
allowTransparency: true, allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', fontFamily:
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline', cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false drawBoldTextInBrightColors: false,
fontSize: 14,
lineHeight: 1.2
}) })
terminalRef.current = terminal terminalRef.current = terminal
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
terminal.loadAddon( terminal.loadAddon(
new WebLinksAddon((event, uri) => { new WebLinksAddon((event, uri) => {
if (event.ctrlKey) { if (event.ctrlKey || event.metaKey) {
window.open(uri, '_blank') window.open(uri, '_blank')
} }
}) })
) )
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon()) terminal.open(domRef.current!)
terminal.open(domRef.current)
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
terminal.loadAddon(new CanvasAddon())
terminal.onData((data) => { terminal.onData((data) => {
if (onInput) { if (onInput) {
onInput(data) onInput(data)
@@ -81,6 +71,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
fitAddon.fit() 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(() => { useEffect(() => {
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.options.theme = { if (theme === 'dark') {
background: theme === 'dark' ? '#00000000' : '#ffffff00', terminalRef.current.options.theme = {
foreground: theme === 'dark' ? '#fff' : '#000', background: '#00000000',
selectionBackground: black: '#ffffff',
theme === 'dark' red: '#cd3131',
? 'rgba(179, 0, 0, 0.3)' green: '#0dbc79',
: 'rgba(255, 167, 167, 0.3)', yellow: '#e5e510',
cursor: theme === 'dark' ? '#fff' : '#000', blue: '#2472c8',
cursorAccent: theme === 'dark' ? '#000' : '#fff', cyan: '#11a8cd',
black: theme === 'dark' ? '#fff' : '#000' 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]) }, [theme])
@@ -139,7 +163,8 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
}, },
clear: () => { clear: () => {
terminalRef.current?.clear() terminalRef.current?.clear()
} },
terminalRef: terminalRef
}), }),
[] []
) )

View File

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

View File

@@ -41,9 +41,16 @@ class TerminalManager {
return data.data 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) let conn = this.connections.get(id)
const { cols = 80, rows = 24 } = config || {}
if (!conn) { if (!conn) {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.protocol = url.protocol.replace('http', 'ws') url.protocol = url.protocol.replace('http', 'ws')
@@ -74,6 +81,7 @@ class TerminalManager {
ws.onopen = () => { ws.onopen = () => {
if (conn) conn.isConnected = true if (conn) conn.isConnected = true
this.sendResize(id, cols, rows)
} }
ws.onclose = () => { ws.onclose = () => {
@@ -111,6 +119,13 @@ class TerminalManager {
conn.ws.send(JSON.stringify({ type: 'input', data })) 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() 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 <div
className={clsx( 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', 'dark:bg-background dark:shadow-danger-100',
'bg-background !bg-opacity-50', 'bg-background !bg-opacity-50',
'shadow-sm shadow-danger-50', 'shadow-sm shadow-danger-50',

View File

@@ -1,4 +1,5 @@
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import 'react-photo-view/dist/react-photo-view.css'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from '@/App.tsx' 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 { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Spinner } from '@heroui/spinner' import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks' import { useRequest } from 'ahooks'
import clsx from 'clsx' import { useMemo } from 'react'
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
import { IoDocument } from 'react-icons/io5'
import HoverTiltedCard from '@/components/hover_titled_card' import HoverTiltedCard from '@/components/hover_titled_card'
import NapCatRepoInfo from '@/components/napcat_repo_info' import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives' import RotatingText from '@/components/rotating_text'
import { usePreloadImages } from '@/hooks/use-preload-images'
import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png' import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
@@ -14,54 +20,181 @@ import WebUIManager from '@/controllers/webui_manager'
function VersionInfo() { function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo) const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return ( return (
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-2 text-2xl font-bold">
<Chip <div className="flex items-center gap-2">
startContent={ <div className="text-danger-500 drop-shadow-md">NapCat</div>
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
NapCat
</Chip>
}
>
{error ? ( {error ? (
error.message error.message
) : loading ? ( ) : loading ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
data?.version <RotatingText
texts={['WebUI', data?.version ?? '']}
mainClassName="overflow-hidden flex items-center bg-danger-500 px-2 rounded-lg text-default-50 shadow-md"
staggerFrom={'last'}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '-120%' }}
staggerDuration={0.025}
splitLevelClassName="overflow-hidden"
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
rotationInterval={2000}
/>
)} )}
</Chip> </div>
</div> </div>
) )
} }
export default function AboutPage() { export default function AboutPage() {
const { isDark } = useTheme()
const imageUrls = useMemo(
() => [
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
],
[]
)
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
const getImageUrl = useMemo(
() => (baseUrl: string) => {
const theme = isDark ? 'dark' : 'light'
const fullUrl = baseUrl.replace(
/color_scheme=(?:light|dark)/,
`color_scheme=${theme}`
)
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
},
[isDark, isLoading, loadedUrls]
)
const renderImage = useMemo(
() => (baseUrl: string, alt: string) => {
const imageUrl = getImageUrl(baseUrl)
if (!imageUrl) {
return (
<div className="flex-1 h-32 flex items-center justify-center bg-default-100 rounded-lg">
<Spinner />
</div>
)
}
return (
<Image
className="flex-1 pointer-events-none select-none"
src={imageUrl}
alt={alt}
/>
)
},
[getImageUrl]
)
return ( return (
<> <>
<title> NapCat WebUI</title> <title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10"> <section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center"> <div className="w-full flex flex-col md:flex-row gap-4">
<div className="flex flex-col md:flex-row items-center mb-6"> <div className="flex flex-col md:flex-row items-center">
<HoverTiltedCard imageSrc={logo} /> <HoverTiltedCard imageSrc={logo} overlayContent="" />
</div> </div>
<VersionInfo /> <div className="flex-1 flex flex-col gap-2 py-2">
<div className="mb-6 flex flex-col items-center gap-4"> <VersionInfo />
<p <div className="space-y-1">
className={clsx( <p className="font-bold text-danger-400">NapCat ?</p>
title({ <p className="text-default-800">
color: 'cyan', TypeScript构建的Bot框架,,QQ
shadow: true Node模块提供给客户端的接口,Bot的功能.
}), </p>
'!text-3xl' <p className="font-bold text-danger-400"></p>
)} <p className="text-default-800">
> QQ
NapCat Contributors 便使 OneBot HTTP /
</p> WebSocket
<Image QQ发送接口之类的接口
className="w-[600px] max-w-full pointer-events-none select-none" </p>
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ" </div>
alt="Contributors"
/>
</div> </div>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-around">
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/F9cgs1N3Mc"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTencentQq size={16} />
</span>
<span>1</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/hSt0u9PVn"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTencentQq size={16} />
</span>
<span>2</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://t.me/MelodicMoonlight"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTelegram size={16} />
</span>
<span>Telegram</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://napcat.napneko.icu/"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<IoDocument size={16} />
</span>
<span>使</span>
</CardBody>
</Card>
</div>
<div className="flex flex-col md:flex-row md:items-start gap-4">
<div className="w-full flex flex-col gap-4">
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'Contributors'
)}
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'Activity Trends'
)}
</div>
<NapCatRepoInfo /> <NapCatRepoInfo />
</div> </div>
</section> </section>

View File

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

View File

@@ -100,7 +100,7 @@ export default function TerminalPage() {
) )
return ( 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 <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}

View File

@@ -1,46 +1,35 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { Route, Routes, useLocation } from 'react-router-dom' import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import DefaultLayout from '@/layouts/default' import DefaultLayout from '@/layouts/default'
import DashboardIndexPage from './dashboard'
import AboutPage from './dashboard/about'
import ConfigPage from './dashboard/config'
import DebugPage from './dashboard/debug'
import HttpDebug from './dashboard/debug/http'
import WSDebug from './dashboard/debug/websocket'
import FileManagerPage from './dashboard/file_manager'
import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network'
import TerminalPage from './dashboard/terminal'
export default function IndexPage() { export default function IndexPage() {
const location = useLocation() const location = useLocation()
return ( return (
<DefaultLayout> <DefaultLayout>
<AnimatePresence mode="wait"> <Suspense
<motion.div fallback={
key={location.pathname} <div className="flex justify-center px-10">
initial={{ opacity: 0, y: 50 }} <Spinner />
animate={{ opacity: 1, y: 0 }} </div>
exit={{ opacity: 0, y: -50 }} }
transition={{ duration: 0.3 }} >
> <AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}> <motion.div
<Route element={<DashboardIndexPage />} path="/" /> key={location.pathname}
<Route element={<NetworkPage />} path="/network" /> initial={{ opacity: 0, y: 20 }}
<Route element={<ConfigPage />} path="/config" /> animate={{ opacity: 1, y: 0 }}
<Route element={<LogsPage />} path="/logs" /> transition={{
<Route element={<DebugPage />} path="/debug"> type: 'tween',
<Route path="ws" element={<WSDebug />} /> ease: 'easeInOut'
<Route path="http" element={<HttpDebug />} /> }}
</Route> >
<Route element={<FileManagerPage />} path="/file_manager" /> <Outlet />
<Route element={<TerminalPage />} path="/terminal" /> </motion.div>
<Route element={<AboutPage />} path="/about" /> </AnimatePresence>
</Routes> </Suspense>
</motion.div>
</AnimatePresence>
</DefaultLayout> </DefaultLayout>
) )
} }

View File

@@ -1,111 +1,13 @@
/* HarmonyOS Sans SC */
@font-face { @font-face {
font-family: 'Harmony'; font-family: 'Aa偷吃可爱长大的';
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype'); src: url('/fonts/AaCute.woff') format('woff');
font-weight: bold;
font-style: normal;
} }
@font-face { @font-face {
font-family: 'Harmony'; font-family: 'JetBrains Mono';
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype'); src: url('/fonts/JetBrainsMono.ttf') format('truetype');
font-weight: normal;
font-style: normal;
} }
/* Ubuntu */
@font-face { @font-face {
font-family: 'Ubuntu'; font-family: 'JetBrains Mono';
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype'); src: url('/fonts/JetBrainsMono-Italic.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-style: italic; 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 { body {
font-family: font-family:
'Aa偷吃可爱长大的',
PingFang SC, PingFang SC,
'Harmony',
Helvetica Neue, Helvetica Neue,
Microsoft YaHei, Microsoft YaHei,
sans-serif !important; sans-serif !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-smooth: always;
} }
@layer components { @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 { .hide-scrollbar::-webkit-scrollbar {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
@@ -105,7 +88,7 @@ body {
.context-view.monaco-menu-container * { .context-view.monaco-menu-container * {
font-family: font-family:
PingFang SC, PingFang SC,
'Harmony', 'Aa偷吃可爱长大的',
Helvetica Neue, Helvetica Neue,
Microsoft YaHei, Microsoft YaHei,
sans-serif !important; sans-serif !important;
@@ -116,4 +99,4 @@ body {
} }
.ql-editor img { .ql-editor img {
@apply inline-block; @apply inline-block;
} }

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,21 @@
import { ConfigBase } from '@/common/config-base'; import { ConfigBase } from '@/common/config-base';
import napCatDefaultConfig from '@/core/external/napcat.json';
import { NapCatCore } from '@/core'; 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> { export type NapcatConfig = Static<typeof NapcatConfigSchema>;
constructor(core: NapCatCore, configPath: string) {
super('napcat', core, configPath); 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 { hostname, systemName, systemVersion } from '@/common/system';
import { NTEventWrapper } from '@/common/event'; import { NTEventWrapper } from '@/common/event';
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types'; 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 os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners'; import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
import { proxiedListenerOf } from '@/common/proxy-handler'; import { proxiedListenerOf } from '@/common/proxy-handler';
@@ -99,7 +99,7 @@ export class NapCatCore {
this.context = context; this.context = context;
this.util = this.context.wrapper.NodeQQNTWrapperUtil; this.util = this.context.wrapper.NodeQQNTWrapperUtil;
this.eventWrapper = new NTEventWrapper(context.session); 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 = { this.apis = {
FileApi: new NTQQFileApi(this.context, this), FileApi: new NTQQFileApi(this.context, this),
SystemApi: new NTQQSystemApi(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) { async _handle(payload: Payload) {
const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus( const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus(
payload.face_id.toString(), payload.face_id.toString(),
payload.face_type.toString(),
payload.wording, payload.wording,
payload.face_type.toString(),
); );
if (ret.result !== 0) { if (ret.result !== 0) {
throw new Error('设置在线状态失败'); throw new Error('设置在线状态失败');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5'; import json5 from 'json5';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> { export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer: WebSocketServer; wsServer?: WebSocketServer;
wsClients: WebSocket[] = []; wsClients: WebSocket[] = [];
wsClientsMutex = new Mutex(); wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null; 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, host: this.config.host === '0.0.0.0' ? '' : this.config.host,
maxPayload: 1024 * 1024 * 1024, 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) { if (!this.isEnable) {
wsClient.close(); wsClient.close();
return; 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 paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/'; const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) { 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)); 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)); }).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
} }
connectEvent(core: NapCatCore, wsClient: WebSocket) { connectEvent(core: NapCatCore, wsClient: WebSocket) {
try { try {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient); 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'); this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
return; 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.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true; this.isEnable = true;
@@ -108,7 +111,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
async close() { async close() {
this.isEnable = false; this.isEnable = false;
this.wsServer.close((err) => { this.wsServer?.close((err) => {
if (err) { if (err) {
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message); this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
} else { } else {
@@ -205,6 +208,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host, host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024, maxPayload: 1024 * 1024 * 1024,
}); });
this.createServer(this.wsServer);
if (newConfig.enable) { if (newConfig.enable) {
this.open(); this.open();
} }

View File

@@ -268,7 +268,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存) // 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => { export const DownloadHandler: RequestHandler = async (req, res) => {
try { 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); const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream'); 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) => { 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('..')) { if (filename.includes('..')) {
return sendError(res, 'ID不合法'); return sendError(res, 'ID不合法');
} }
@@ -40,7 +44,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
// 终端相关处理器 // 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => { export const CreateTerminalHandler: RequestHandler = async (req, res) => {
try { try {
const { id } = terminalManager.createTerminal(); const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);
return sendSuccess(res, { id }); return sendSuccess(res, { id });
} catch (error) { } catch (error) {
console.error('Failed to create terminal:', error); console.error('Failed to create terminal:', error);
@@ -54,7 +59,10 @@ export const GetTerminalListHandler: RequestHandler = (_, res) => {
}; };
export const CloseTerminalHandler: RequestHandler = (req, 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); terminalManager.closeTerminal(id);
return sendSuccess(res, {}); return sendSuccess(res, {});
}; };

View File

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