Compare commits

..

19 Commits

Author SHA1 Message Date
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
bietiaop
b32f9fa397 feat: 文件下载/上传 2025-02-03 19:56:33 +08:00
Mlikiowa
80593730ae release: v4.4.20 2025-02-03 08:36:59 +00:00
手瓜一十雪
090d54a78d fix: typo 2025-02-03 16:36:25 +08:00
Mlikiowa
b7d1fb181c release: v4.4.19 2025-02-03 08:34:24 +00:00
手瓜一十雪
6e56693ca7 feat: 支持set_diy_online_status 2025-02-03 16:33:31 +08:00
Mlikiowa
7403db9b20 release: v4.4.18 2025-02-03 07:05:56 +00:00
手瓜一十雪
9d167cd883 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-03 15:05:16 +08:00
Mlikiowa
07819a6618 release: v4.4.17 2025-02-03 06:50:58 +00:00
62 changed files with 1052 additions and 382 deletions

View File

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

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

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

View File

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

View File

@@ -29,6 +29,7 @@
"@heroui/listbox": "2.3.10",
"@heroui/modal": "2.2.8",
"@heroui/navbar": "2.2.9",
"@heroui/pagination": "^2.2.9",
"@heroui/popover": "2.3.10",
"@heroui/select": "2.4.10",
"@heroui/slider": "2.4.8",
@@ -45,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",
@@ -63,11 +64,13 @@
"quill": "^2.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -0,0 +1,84 @@
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import FileManager from '@/controllers/file_manager'
interface FilePreviewModalProps {
isOpen: boolean
filePath: string
onClose: () => void
}
export const videoExts = ['.mp4', '.webm']
export const audioExts = ['.mp3', '.wav']
export const supportedPreviewExts = [...videoExts, ...audioExts]
export default function FilePreviewModal({
isOpen,
filePath,
onClose
}: FilePreviewModalProps) {
const ext = path.extname(filePath).toLowerCase()
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !supportedPreviewExts.includes(ext)) {
return
}
run()
}
}
)
let contentElement = null
if (!supportedPreviewExts.includes(ext)) {
contentElement = <div></div>
} else if (error) {
contentElement = <div></div>
} else if (loading || !data) {
contentElement = (
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
)
} else if (videoExts.includes(ext)) {
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>
)
}
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className="flex justify-center items-center">
{contentElement}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -1,4 +1,5 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Pagination } from '@heroui/pagination'
import { Spinner } from '@heroui/spinner'
import {
type Selection,
@@ -10,16 +11,20 @@ import {
TableHeader,
TableRow
} from '@heroui/table'
import { Tooltip } from '@heroui/tooltip'
import path from 'path-browserify'
import { useCallback, useEffect, useState } from 'react'
import { BiRename } from 'react-icons/bi'
import { FiCopy, 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 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
@@ -29,12 +34,16 @@ interface FileTableProps {
onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void
onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void
onDownload: (filePath: string) => void
}
const PAGE_SIZE = 20
export default function FileTable({
files,
currentPath,
@@ -45,115 +54,192 @@ export default function FileTable({
onSelectionChange,
onDirectoryClick,
onEdit,
onPreview,
onRenameRequest,
onMoveRequest,
onCopyPath,
onDelete
onDelete,
onDownload
}: FileTableProps) {
const [page, setPage] = useState(1)
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"
>
<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={files}
>
{(file: FileInfo) => (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: onEdit(path.join(currentPath, file.name))
}
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">
<Tooltip content="重命名">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
</Tooltip>
<Tooltip content="移动">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
</Tooltip>
<Tooltip content="复制路径">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
</Tooltip>
<Tooltip content="删除">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(path.join(currentPath, file.name))}
>
<FiTrash2 />
</Button>
</Tooltip>
</ButtonGroup>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
>
{displayFiles.map((file: FileInfo) => {
const filePath = path.join(currentPath, file.name)
const ext = path.extname(file.name).toLowerCase()
const previewable = supportedPreviewExts.includes(ext)
const images = previewImages
return (
<TableRow key={file.name}>
<TableCell>
{imageExts.includes(ext) ? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
) : (
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
className="text-left justify-start"
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</>
)
}

View File

@@ -0,0 +1,73 @@
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],
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])
return (
<Button
variant="light"
className="text-left justify-start"
onPress={onPreview}
startContent={
error ? (
<FileIcon name={name} isDirectory={false} />
) : loading || !data ? (
<Spinner size="sm" />
) : (
<Image src={data} alt={name} className="w-8 h-8" radius="sm" />
)
}
>
{name}
</Button>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,11 +47,11 @@ const SideBar: React.FC<SideBarProps> = (props) => {
style={{ overflow: 'hidden' }}
>
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
<div className="flex justify-center items-center mt-2 gap-2">
<div className="flex justify-center items-center my-2 gap-2">
<Image radius="none" height={40} src={logo} className="mb-2" />
<div
className={clsx(
'flex items-center hm-medium',
'flex items-center font-bold',
'!text-2xl shiny-text'
)}
>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import toast from 'react-hot-toast'
import { serverRequest } from '@/utils/request'
export interface FileInfo {
@@ -95,4 +97,103 @@ export default class FileManager {
)
return data.data
}
public static download(path: string) {
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
toast
.promise(
serverRequest
.post(downloadUrl, void 0, {
responseType: 'blob'
})
.catch((e) => {
console.error(e)
throw new Error('下载失败')
}),
{
loading: '正在下载文件...',
success: '下载成功',
error: '下载失败'
}
)
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
let fileName = path.split('/').pop() || ''
if (path.split('.').length === 1) {
fileName += '.zip'
}
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
.catch((e) => {
console.error(e)
})
}
public static async batchDownload(paths: string[]) {
const downloadUrl = `/File/batchDownload`
toast
.promise(
serverRequest
.post(
downloadUrl,
{ paths },
{
responseType: 'blob'
}
)
.catch((e) => {
console.error(e)
throw new Error('下载失败')
}),
{
loading: '正在下载文件...',
success: '下载成功',
error: '下载失败'
}
)
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
const fileName = 'files.zip'
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
.catch((e) => {
console.error(e)
})
}
public static async downloadToURL(path: string) {
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
const response = await serverRequest.post(downloadUrl, void 0, {
responseType: 'blob'
})
return window.URL.createObjectURL(new Blob([response.data]))
}
public static async upload(path: string, files: File[]) {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
const { data } = await serverRequest.post<ServerResponse<boolean>>(
`/File/upload?path=${encodeURIComponent(path)}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
return data.data
}
}

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,13 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import type { Selection, SortDescriptor } from '@react-types/shared'
import clsx from 'clsx'
import { motion } from 'motion/react'
import path from 'path-browserify'
import { useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import toast from 'react-hot-toast'
import { FiMove, FiPlus } from 'react-icons/fi'
import { FiDownload, FiMove, FiPlus, FiUpload } from 'react-icons/fi'
import { MdRefresh } from 'react-icons/md'
import { TbTrash } from 'react-icons/tb'
import { TiArrowBack } from 'react-icons/ti'
@@ -13,6 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import CreateFileModal from '@/components/file_manage/create_file_modal'
import FileEditModal from '@/components/file_manage/file_edit_modal'
import FilePreviewModal from '@/components/file_manage/file_preview_modal'
import FileTable from '@/components/file_manage/file_table'
import MoveModal from '@/components/file_manage/move_modal'
import RenameModal from '@/components/file_manage/rename_modal'
@@ -49,6 +53,8 @@ export default function FileManagerPage() {
const [renamingFile, setRenamingFile] = useState<string>('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [jumpPath, setJumpPath] = useState('')
const [previewFile, setPreviewFile] = useState<string>('')
const [showUpload, setShowUpload] = useState<boolean>(false)
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
return [...files].sort((a, b) => {
@@ -266,6 +272,62 @@ export default function FileManagerPage() {
setIsMoveModalOpen(true)
}
const handleDownload = (filePath: string) => {
FileManager.download(filePath)
}
const handleBatchDownload = async () => {
const selectedArray =
selectedFiles instanceof Set
? Array.from(selectedFiles)
: files.map((f) => f.name)
if (selectedArray.length === 0) return
const paths = selectedArray.map((key) =>
path.join(currentPath, key.toString())
)
await FileManager.batchDownload(paths)
}
const handlePreview = (filePath: string) => {
setPreviewFile(filePath)
}
const onDrop = async (acceptedFiles: File[]) => {
try {
// 遍历处理文件,保持文件夹结构
const processedFiles = acceptedFiles.map((file) => {
const relativePath = file.webkitRelativePath || file.name
// 不需要额外的编码处理,浏览器会自动处理
return new File([file], relativePath, {
type: file.type,
lastModified: file.lastModified
})
})
toast
.promise(FileManager.upload(currentPath, processedFiles), {
loading: '正在上传文件...',
success: '上传成功',
error: '上传失败'
})
.then(() => {
loadFiles()
})
} catch (error) {
toast.error('上传失败')
}
}
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
noClick: true,
onDragOver: (e) => {
e.preventDefault()
e.stopPropagation()
},
useFsAccessApi: false // 添加此选项以避免某些浏览器的文件系统API问题
})
return (
<div className="p-4">
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
@@ -302,6 +364,17 @@ export default function FileManagerPage() {
>
<MdRefresh />
</Button>
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => setShowUpload((prev) => !prev)}
className="text-lg"
>
<FiUpload />
</Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<>
@@ -332,6 +405,18 @@ export default function FileManagerPage() {
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={handleBatchDownload}
className="text-sm"
startContent={<FiDownload className="text-lg" />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
</>
)}
<Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
@@ -362,6 +447,26 @@ export default function FileManagerPage() {
/>
</div>
<motion.div
initial={{ height: 0 }}
animate={{ height: showUpload ? 'auto' : 0 }}
transition={{ duration: 0.2 }}
className={clsx(
'border-dashed rounded-lg text-center',
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
showUpload ? 'mb-4 border-2' : 'border-none'
)}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div {...getRootProps()} className="w-full h-full p-4">
<input {...getInputProps()} multiple />
<p></p>
</div>
</motion.div>
<FileTable
files={files}
currentPath={currentPath}
@@ -372,6 +477,7 @@ export default function FileManagerPage() {
onSelectionChange={setSelectedFiles}
onDirectoryClick={handleDirectoryClick}
onEdit={handleEdit}
onPreview={handlePreview}
onRenameRequest={(name) => {
setRenamingFile(name)
setNewFileName(name)
@@ -380,6 +486,7 @@ export default function FileManagerPage() {
onMoveRequest={handleMoveClick}
onCopyPath={handleCopyPath}
onDelete={handleDelete}
onDownload={handleDownload}
/>
<FileEditModal
@@ -394,6 +501,12 @@ export default function FileManagerPage() {
}
/>
<FilePreviewModal
isOpen={!!previewFile}
filePath={previewFile}
onClose={() => setPreviewFile('')}
/>
<CreateFileModal
isOpen={isCreateModalOpen}
fileType={fileType}

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,10 @@ serverRequest.interceptors.request.use((config) => {
})
serverRequest.interceptors.response.use((response) => {
// 如果是流式传输的文件
if (response.headers['content-type'] === 'application/octet-stream') {
return response
}
if (response.data.code !== 0) {
if (response.data.message === 'Unauthorized') {
const token = localStorage.getItem(key.token)

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.4.16",
"version": "4.5.1",
"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",
@@ -21,6 +21,8 @@
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-node-resolve": "^16.0.0",
@@ -28,6 +30,7 @@
"@sinclair/typebox": "^0.34.9",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.1",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.5.12",
@@ -41,25 +44,25 @@
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
"file-type": "^20.0.0",
"globals": "^15.12.0",
"image-size": "^1.1.1",
"json5": "^2.2.3",
"multer": "^1.4.5-lts.1",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
"winston": "^3.17.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@ffmpeg.wasm/main": "^0.13.1",
"express-rate-limit": "^7.5.0"
"winston": "^3.17.0"
},
"dependencies": {
"piscina": "^4.7.0",
"@ffmpeg.wasm/core-mt": "^0.13.2",
"compressing": "^1.10.1",
"express": "^5.0.0",
"piscina": "^4.7.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.4.16';
export const napCatVersion = '4.5.1';

View File

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

View File

@@ -46,6 +46,15 @@ export class NTQQUserApi {
});
}
async setDiySelfOnlineStatus(faceId: string, wording: string, faceType: string) {
return this.context.session.getMsgService().setStatus({
status: 10,
extStatus: 2000,
customStatus: { faceId: faceId, wording: wording, faceType: faceType },
batteryStatus: 0
});
}
async getBuddyRecommendContactArkJson(uin: string, sencenID = '') {
return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID);
}

View File

@@ -30,7 +30,7 @@ export interface NodeIKernelMsgService {
kickOffLine(DevInfo: unknown): unknown;
setStatus(args: { status: number, extStatus: number, batteryStatus: number }): Promise<GeneralCallResult>;
setStatus(args: { status: number, extStatus: number, batteryStatus: number, customStatus?: { faceId: string, wording: string, faceType: string } }): Promise<GeneralCallResult>;
fetchStatusMgrInfo(): unknown;

View File

@@ -0,0 +1,28 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
face_id: Type.Union([Type.Number(), Type.String()]),// 参考 face_config.json 的 QSid
face_type: Type.Union([Type.Number(), Type.String()], { default: '1' }),
wording: Type.String({ default: ' ' }),
});
type Payload = Static<typeof SchemaData>;
export class SetDiyOnlineStatus extends OneBotAction<Payload, string> {
override actionName = ActionName.SetDiyOnlineStatus;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus(
payload.face_id.toString(),
payload.wording,
payload.face_type.toString(),
);
if (ret.result !== 0) {
throw new Error('设置在线状态失败');
}
return ret.errMsg;
}
}

View File

@@ -103,6 +103,7 @@ import { GetGuildProfile } from './guild/GetGuildProfile';
import { GetClientkey } from './extends/GetClientkey';
import { SendPacket } from './extends/SendPacket';
import { SendPoke } from '@/onebot/action/packet/SendPoke';
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -209,6 +210,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new GetUserStatus(obContext, core),
new GetRkey(obContext, core),
new SetSpecialTittle(obContext, core),
new SetDiyOnlineStatus(obContext, core),
// new UploadForwardMsg(obContext, core),
new GetGroupShutList(obContext, core),
new GetGroupFileUrl(obContext, core),

View File

@@ -90,6 +90,7 @@ export const ActionName = {
// 以下为扩展napcat扩展
Unknown: 'unknown',
SetDiyOnlineStatus: 'set_diy_online_status',
SharePeer: 'ArkSharePeer',
ShareGroupEx: 'ArkShareGroup',
// RebootNormal : 'reboot_normal', //无快速登录重新启动

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ const OneBotConfigSchema = Type.Object({
network: NetworkConfigSchema,
musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: true })
parseMultMsg: Type.Boolean({ default: false })
});
export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

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

View File

@@ -1,8 +1,13 @@
import type { RequestHandler } from 'express';
import type { RequestHandler, Request } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fs from 'fs/promises';
import fsProm from 'fs/promises';
import fs from 'fs';
import path from 'path';
import os from 'os';
import compressing from 'compressing';
import { PassThrough } from 'stream';
import multer from 'multer';
import { randomUUID } from 'crypto';
const isWindows = os.platform() === 'win32';
@@ -15,7 +20,7 @@ const getRootDirs = async (): Promise<string[]> => {
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fs.access(`${driveLetter}:\\`);
await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
@@ -48,7 +53,7 @@ const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'S
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
const stat = await fs.stat(pathToCheck);
const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
@@ -59,9 +64,9 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query.onlyDirectory === 'true';
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
@@ -69,7 +74,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fs.stat(`${drive}\\`);
const stat = await fsProm.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
@@ -89,7 +94,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendSuccess(res, driveInfos);
}
const files = await fs.readdir(normalizedPath);
const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
@@ -98,7 +103,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fs.stat(fullPath);
const stat = await fsProm.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
@@ -135,7 +140,7 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名目录已存在');
}
await fs.mkdir(normalizedPath, { recursive: true });
await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
@@ -147,11 +152,11 @@ export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
await fsProm.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
@@ -165,11 +170,11 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
await fsProm.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
@@ -181,8 +186,8 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query.path as string);
const content = await fs.readFile(filePath, 'utf-8');
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@@ -194,7 +199,7 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fs.writeFile(normalizedPath, content, 'utf-8');
await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@@ -212,7 +217,7 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名文件已存在');
}
await fs.writeFile(normalizedPath, '', 'utf-8');
await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
@@ -225,7 +230,7 @@ export const RenameHandler: RequestHandler = async (req, res) => {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fs.rename(normalizedOldPath, normalizedNewPath);
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
@@ -238,7 +243,7 @@ export const MoveHandler: RequestHandler = async (req, res) => {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
@@ -252,10 +257,140 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream');
let filename = path.basename(filePath);
if (stat.isDirectory()) {
filename = path.basename(filePath) + '.zip';
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const zipStream = new PassThrough();
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
console.error('压缩目录失败:', err);
res.end();
});
zipStream.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const stream = fs.createReadStream(filePath);
stream.pipe(res);
} catch (error) {
return sendError(res, '下载失败');
}
};
// 批量下载:将多个文件/目录打包为 zip 文件下载
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body as { paths: string[] };
if (!paths || !Array.isArray(paths) || paths.length === 0) {
return sendError(res, '参数错误');
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
}
}
zipStream.pipe(res);
res.on('finish', () => {
zipStream.destroy();
});
} catch (error) {
return sendError(res, '下载失败');
}
};
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
}
};
// 修改上传处理方法
export const UploadHandler: RequestHandler = (req, res) => {
const uploadPath = (req.query['path'] || '') as string;
const storage = multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
const upload = multer({ storage }).array('files');
upload(req, res, (err: any) => {
if (err) {
return sendError(res, err.message || '文件上传失败');
}
return sendSuccess(res, true);
});
};

View File

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

View File

@@ -11,6 +11,9 @@ import {
RenameHandler,
MoveHandler,
BatchMoveHandler,
DownloadHandler,
BatchDownloadHandler, // 新增下载处理方法
UploadHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
@@ -32,5 +35,7 @@ router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
router.post('/download', DownloadHandler);
router.post('/batchDownload', BatchDownloadHandler);
router.post('/upload', UploadHandler); // 添加上传处理路由
export { router as FileRouter };

View File

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