Compare commits

...

20 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
手瓜一十雪
197eec40ad fix 2025-02-03 15:05:12 +08:00
Mlikiowa
07819a6618 release: v4.4.17 2025-02-03 06:50:58 +00:00
63 changed files with 1054 additions and 383 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.16", "version": "4.5.1",
"icon": "./logo.png", "icon": "./logo.png",
"authors": [ "authors": [
{ {

View File

@@ -29,6 +29,7 @@
"@heroui/listbox": "2.3.10", "@heroui/listbox": "2.3.10",
"@heroui/modal": "2.2.8", "@heroui/modal": "2.2.8",
"@heroui/navbar": "2.2.9", "@heroui/navbar": "2.2.9",
"@heroui/pagination": "^2.2.9",
"@heroui/popover": "2.3.10", "@heroui/popover": "2.3.10",
"@heroui/select": "2.4.10", "@heroui/select": "2.4.10",
"@heroui/slider": "2.4.8", "@heroui/slider": "2.4.8",
@@ -45,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",
@@ -63,11 +64,13 @@
"quill": "^2.0.3", "quill": "^2.0.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"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

@@ -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

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

@@ -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

@@ -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

@@ -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: '#000000',
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

@@ -1,3 +1,5 @@
import toast from 'react-hot-toast'
import { serverRequest } from '@/utils/request' import { serverRequest } from '@/utils/request'
export interface FileInfo { export interface FileInfo {
@@ -95,4 +97,103 @@ export default class FileManager {
) )
return data.data 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 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

@@ -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

@@ -2,10 +2,13 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button' import { Button } from '@heroui/button'
import { Input } from '@heroui/input' import { Input } from '@heroui/input'
import type { Selection, SortDescriptor } from '@react-types/shared' import type { Selection, SortDescriptor } from '@react-types/shared'
import clsx from 'clsx'
import { motion } from 'motion/react'
import path from 'path-browserify' import path from 'path-browserify'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import toast from 'react-hot-toast' 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 { MdRefresh } from 'react-icons/md'
import { TbTrash } from 'react-icons/tb' import { TbTrash } from 'react-icons/tb'
import { TiArrowBack } from 'react-icons/ti' 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 CreateFileModal from '@/components/file_manage/create_file_modal'
import FileEditModal from '@/components/file_manage/file_edit_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 FileTable from '@/components/file_manage/file_table'
import MoveModal from '@/components/file_manage/move_modal' import MoveModal from '@/components/file_manage/move_modal'
import RenameModal from '@/components/file_manage/rename_modal' import RenameModal from '@/components/file_manage/rename_modal'
@@ -49,6 +53,8 @@ export default function FileManagerPage() {
const [renamingFile, setRenamingFile] = useState<string>('') const [renamingFile, setRenamingFile] = useState<string>('')
const [moveTargetPath, setMoveTargetPath] = useState('') const [moveTargetPath, setMoveTargetPath] = useState('')
const [jumpPath, setJumpPath] = useState('') const [jumpPath, setJumpPath] = useState('')
const [previewFile, setPreviewFile] = useState<string>('')
const [showUpload, setShowUpload] = useState<boolean>(false)
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => { const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
return [...files].sort((a, b) => { return [...files].sort((a, b) => {
@@ -266,6 +272,62 @@ export default function FileManagerPage() {
setIsMoveModalOpen(true) 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 ( return (
<div className="p-4"> <div className="p-4">
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1"> <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 /> <MdRefresh />
</Button> </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 instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && ( selectedFiles === 'all') && (
<> <>
@@ -332,6 +405,18 @@ export default function FileManagerPage() {
{selectedFiles instanceof Set ? selectedFiles.size : files.length} {selectedFiles instanceof Set ? selectedFiles.size : files.length}
) )
</Button> </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"> <Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
@@ -362,6 +447,26 @@ export default function FileManagerPage() {
/> />
</div> </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 <FileTable
files={files} files={files}
currentPath={currentPath} currentPath={currentPath}
@@ -372,6 +477,7 @@ export default function FileManagerPage() {
onSelectionChange={setSelectedFiles} onSelectionChange={setSelectedFiles}
onDirectoryClick={handleDirectoryClick} onDirectoryClick={handleDirectoryClick}
onEdit={handleEdit} onEdit={handleEdit}
onPreview={handlePreview}
onRenameRequest={(name) => { onRenameRequest={(name) => {
setRenamingFile(name) setRenamingFile(name)
setNewFileName(name) setNewFileName(name)
@@ -380,6 +486,7 @@ export default function FileManagerPage() {
onMoveRequest={handleMoveClick} onMoveRequest={handleMoveClick}
onCopyPath={handleCopyPath} onCopyPath={handleCopyPath}
onDelete={handleDelete} onDelete={handleDelete}
onDownload={handleDownload}
/> />
<FileEditModal <FileEditModal
@@ -394,6 +501,12 @@ export default function FileManagerPage() {
} }
/> />
<FilePreviewModal
isOpen={!!previewFile}
filePath={previewFile}
onClose={() => setPreviewFile('')}
/>
<CreateFileModal <CreateFileModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
fileType={fileType} fileType={fileType}

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,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

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

View File

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

@@ -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 = '') { async getBuddyRecommendContactArkJson(uin: string, sencenID = '') {
return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID); return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID);
} }

View File

@@ -30,7 +30,7 @@ export interface NodeIKernelMsgService {
kickOffLine(DevInfo: unknown): unknown; 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; 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 { GetClientkey } from './extends/GetClientkey';
import { SendPacket } from './extends/SendPacket'; import { SendPacket } from './extends/SendPacket';
import { SendPoke } from '@/onebot/action/packet/SendPoke'; import { SendPoke } from '@/onebot/action/packet/SendPoke';
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) { export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -209,6 +210,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new GetUserStatus(obContext, core), new GetUserStatus(obContext, core),
new GetRkey(obContext, core), new GetRkey(obContext, core),
new SetSpecialTittle(obContext, core), new SetSpecialTittle(obContext, core),
new SetDiyOnlineStatus(obContext, core),
// new UploadForwardMsg(obContext, core), // new UploadForwardMsg(obContext, core),
new GetGroupShutList(obContext, core), new GetGroupShutList(obContext, core),
new GetGroupFileUrl(obContext, core), new GetGroupFileUrl(obContext, core),

View File

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

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,
}, },
}; };

View File

@@ -82,7 +82,7 @@ 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

@@ -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

@@ -1,8 +1,13 @@
import type { RequestHandler } from 'express'; import type { RequestHandler, Request } from 'express';
import { sendError, sendSuccess } from '../utils/response'; 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 path from 'path';
import os from 'os'; 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'; const isWindows = os.platform() === 'win32';
@@ -15,7 +20,7 @@ const getRootDirs = async (): Promise<string[]> => {
for (let i = 65; i <= 90; i++) { for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i); const driveLetter = String.fromCharCode(i);
try { try {
await fs.access(`${driveLetter}:\\`); await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`); drives.push(`${driveLetter}:`);
} catch { } 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> => { const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try { try {
const stat = await fs.stat(pathToCheck); const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突 // 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory; return stat.isDirectory() === isDirectory;
} catch { } catch {
@@ -59,9 +64,9 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容 // 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => { export const ListFilesHandler: RequestHandler = async (req, res) => {
try { try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/'); const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath); const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query.onlyDirectory === 'true'; const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表 // 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) { if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
@@ -69,7 +74,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
const driveInfos: FileInfo[] = await Promise.all( const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => { drives.map(async (drive) => {
try { try {
const stat = await fs.stat(`${drive}\\`); const stat = await fsProm.stat(`${drive}\\`);
return { return {
name: drive, name: drive,
isDirectory: true, isDirectory: true,
@@ -89,7 +94,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendSuccess(res, driveInfos); return sendSuccess(res, driveInfos);
} }
const files = await fs.readdir(normalizedPath); const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = []; let fileInfos: FileInfo[] = [];
for (const file of files) { for (const file of files) {
@@ -98,7 +103,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
try { try {
const fullPath = path.join(normalizedPath, file); const fullPath = path.join(normalizedPath, file);
const stat = await fs.stat(fullPath); const stat = await fsProm.stat(fullPath);
fileInfos.push({ fileInfos.push({
name: file, name: file,
isDirectory: stat.isDirectory(), isDirectory: stat.isDirectory(),
@@ -135,7 +140,7 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名目录已存在'); return sendError(res, '同名目录已存在');
} }
await fs.mkdir(normalizedPath, { recursive: true }); await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '创建目录失败'); return sendError(res, '创建目录失败');
@@ -147,11 +152,11 @@ export const DeleteHandler: RequestHandler = async (req, res) => {
try { try {
const { path: targetPath } = req.body; const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath); const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath); const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true }); await fsProm.rm(normalizedPath, { recursive: true });
} else { } else {
await fs.unlink(normalizedPath); await fsProm.unlink(normalizedPath);
} }
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
@@ -165,11 +170,11 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
const { paths } = req.body; const { paths } = req.body;
for (const targetPath of paths) { for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath); const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath); const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true }); await fsProm.rm(normalizedPath, { recursive: true });
} else { } else {
await fs.unlink(normalizedPath); await fsProm.unlink(normalizedPath);
} }
} }
return sendSuccess(res, true); return sendSuccess(res, true);
@@ -181,8 +186,8 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容 // 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => { export const ReadFileHandler: RequestHandler = async (req, res) => {
try { try {
const filePath = normalizePath(req.query.path as string); const filePath = normalizePath(req.query['path'] as string);
const content = await fs.readFile(filePath, 'utf-8'); const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content); return sendSuccess(res, content);
} catch (error) { } catch (error) {
return sendError(res, '读取文件失败'); return sendError(res, '读取文件失败');
@@ -194,7 +199,7 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
try { try {
const { path: filePath, content } = req.body; const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath); const normalizedPath = normalizePath(filePath);
await fs.writeFile(normalizedPath, content, 'utf-8'); await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '写入文件失败'); return sendError(res, '写入文件失败');
@@ -212,7 +217,7 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名文件已存在'); return sendError(res, '同名文件已存在');
} }
await fs.writeFile(normalizedPath, '', 'utf-8'); await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '创建文件失败'); return sendError(res, '创建文件失败');
@@ -225,7 +230,7 @@ export const RenameHandler: RequestHandler = async (req, res) => {
const { oldPath, newPath } = req.body; const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath); const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath); const normalizedNewPath = normalizePath(newPath);
await fs.rename(normalizedOldPath, normalizedNewPath); await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '重命名失败'); return sendError(res, '重命名失败');
@@ -238,7 +243,7 @@ export const MoveHandler: RequestHandler = async (req, res) => {
const { sourcePath, targetPath } = req.body; const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath); const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath); const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath); await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '移动失败'); return sendError(res, '移动失败');
@@ -252,10 +257,140 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
for (const { sourcePath, targetPath } of items) { for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath); const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath); const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath); await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
} }
return sendSuccess(res, true); return sendSuccess(res, true);
} catch (error) { } catch (error) {
return sendError(res, '批量移动失败'); 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) => { 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

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

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 }));

View File

@@ -8,7 +8,8 @@ const external = [
'silk-wasm', 'silk-wasm',
'ws', 'ws',
'express', 'express',
'@ffmpeg.wasm/core-mt' '@ffmpeg.wasm/core-mt',
'piscina'
]; ];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();