mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
bd4b0885a1 | ||
![]() |
e3c7af3d91 | ||
![]() |
a7ee21bfd8 | ||
![]() |
d0f51d92ac | ||
![]() |
e6dc148ea2 | ||
![]() |
514ab6637f | ||
![]() |
377794abe8 | ||
![]() |
0f3251f35b | ||
![]() |
8002dc5bc5 | ||
![]() |
c75a13dcf4 | ||
![]() |
91d153bb9d |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -5,5 +5,6 @@
|
||||
".env.universal": ".env.*",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
}
|
||||
},
|
||||
"css.customData": [".vscode/tailwindcss.json"],
|
||||
}
|
55
.vscode/tailwindcss.json
vendored
Normal file
55
.vscode/tailwindcss.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@tailwind",
|
||||
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@responsive",
|
||||
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@screen",
|
||||
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@variants",
|
||||
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.4.20",
|
||||
"version": "4.5.1",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -46,9 +46,9 @@
|
||||
"@react-aria/visually-hidden": "^3.8.19",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.7.9",
|
||||
@@ -70,6 +70,7 @@
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
|
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
|
@@ -18,11 +18,10 @@ interface FilePreviewModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||
const videoExts = ['.mp4', '.webm']
|
||||
const audioExts = ['.mp3', '.wav']
|
||||
export const videoExts = ['.mp4', '.webm']
|
||||
export const audioExts = ['.mp3', '.wav']
|
||||
|
||||
const supportedPreviewExts = [...imageExts, ...videoExts, ...audioExts]
|
||||
export const supportedPreviewExts = [...videoExts, ...audioExts]
|
||||
|
||||
export default function FilePreviewModal({
|
||||
isOpen,
|
||||
@@ -31,7 +30,7 @@ export default function FilePreviewModal({
|
||||
}: FilePreviewModalProps) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async (path: string) => FileManager.downloadToURL(path),
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
refreshDepsAction: () => {
|
||||
@@ -39,7 +38,7 @@ export default function FilePreviewModal({
|
||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||
return
|
||||
}
|
||||
run(filePath)
|
||||
run()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -55,20 +54,20 @@ export default function FilePreviewModal({
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
} else if (imageExts.includes(ext)) {
|
||||
contentElement = (
|
||||
<img src={data} alt="预览" className="max-w-full max-h-96" />
|
||||
)
|
||||
} else if (videoExts.includes(ext)) {
|
||||
contentElement = (
|
||||
<video src={data} controls className="max-w-full max-h-96" />
|
||||
)
|
||||
contentElement = <video src={data} controls className="max-w-full" />
|
||||
} else if (audioExts.includes(ext)) {
|
||||
contentElement = <audio src={data} controls className="w-full" />
|
||||
} else {
|
||||
contentElement = (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className="flex justify-center items-center">
|
||||
|
@@ -12,15 +12,19 @@ import {
|
||||
TableRow
|
||||
} from '@heroui/table'
|
||||
import path from 'path-browserify'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
||||
import { PhotoSlider } from 'react-photo-view'
|
||||
|
||||
import FileIcon from '@/components/file_icon'
|
||||
|
||||
import type { FileInfo } from '@/controllers/file_manager'
|
||||
|
||||
interface FileTableProps {
|
||||
import { supportedPreviewExts } from './file_preview_modal'
|
||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
|
||||
|
||||
export interface FileTableProps {
|
||||
files: FileInfo[]
|
||||
currentPath: string
|
||||
loading: boolean
|
||||
@@ -58,147 +62,184 @@ export default function FileTable({
|
||||
onDownload
|
||||
}: FileTableProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const pages = Math.ceil(files.length / PAGE_SIZE)
|
||||
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
|
||||
const start = (page - 1) * PAGE_SIZE
|
||||
const end = start + PAGE_SIZE
|
||||
const displayFiles = files.slice(start, end)
|
||||
const [showImage, setShowImage] = useState(false)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||
|
||||
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||
setPreviewImages((prev) => {
|
||||
const exists = prev.some((p) => p.key === image.key)
|
||||
if (exists) return prev
|
||||
return [...prev, image]
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewImages([])
|
||||
setPreviewIndex(0)
|
||||
setShowImage(false)
|
||||
}, [files])
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
const index = images.findIndex((image) => image.key === name)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
setPreviewIndex(index)
|
||||
setShowImage(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
aria-label="文件列表"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
defaultSelectedKeys={[]}
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode="multiple"
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="danger"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn key="name" allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key="type" allowsSorting>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key="size" allowsSorting>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key="mtime" allowsSorting>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key="actions">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
isLoading={loading}
|
||||
loadingContent={
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
<>
|
||||
<PhotoSlider
|
||||
images={previewImages}
|
||||
visible={showImage}
|
||||
onClose={() => setShowImage(false)}
|
||||
index={previewIndex}
|
||||
onIndexChange={setPreviewIndex}
|
||||
/>
|
||||
<Table
|
||||
aria-label="文件列表"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
defaultSelectedKeys={[]}
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode="multiple"
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="danger"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
items={displayFiles}
|
||||
>
|
||||
{(file: FileInfo) => {
|
||||
const filePath = path.join(currentPath, file.name)
|
||||
// 判断预览类型
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
const previewable = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.mp4',
|
||||
'.webm',
|
||||
'.mp3',
|
||||
'.wav'
|
||||
].includes(ext)
|
||||
return (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
? onDirectoryClick(file.name)
|
||||
: previewable
|
||||
? onPreview(filePath)
|
||||
: onEdit(filePath)
|
||||
}
|
||||
className="text-left justify-start"
|
||||
startContent={
|
||||
<FileIcon name={file.name} isDirectory={file.isDirectory} />
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableHeader>
|
||||
<TableColumn key="name" allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key="type" allowsSorting>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key="size" allowsSorting>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key="mtime" allowsSorting>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key="actions">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
isLoading={loading}
|
||||
loadingContent={
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{displayFiles.map((file: FileInfo) => {
|
||||
const filePath = path.join(currentPath, file.name)
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
const previewable = supportedPreviewExts.includes(ext)
|
||||
const images = previewImages
|
||||
return (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
{imageExts.includes(ext) ? (
|
||||
<ImageNameButton
|
||||
name={file.name}
|
||||
filePath={filePath}
|
||||
onPreview={() => onPreviewImage(file.name, images)}
|
||||
onAddPreview={addPreviewImage}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
? onDirectoryClick(file.name)
|
||||
: previewable
|
||||
? onPreview(filePath)
|
||||
: onEdit(filePath)
|
||||
}
|
||||
className="text-left justify-start"
|
||||
startContent={
|
||||
<FileIcon
|
||||
name={file.name}
|
||||
isDirectory={file.isDirectory}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
@@ -36,7 +36,7 @@ export default function Hitokoto() {
|
||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-noto-serif">{data?.hitokoto}</div>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className="text-right">
|
||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
|
@@ -34,7 +34,7 @@ export default function HoverTiltedCard({
|
||||
rotateAmplitude = 14,
|
||||
showTooltip = false,
|
||||
overlayContent = (
|
||||
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
||||
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
||||
NapCat
|
||||
</div>
|
||||
),
|
||||
|
@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
shadow="sm"
|
||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||
>
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
|
@@ -67,7 +67,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
||||
<h2 className="font-bold">{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-danger-200', {
|
||||
'!text-danger-400': apiName === selectedApi
|
||||
|
@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<PageLoading loading={loading} />
|
||||
{error ? (
|
||||
<CardBody className="items-center gap-1 justify-center">
|
||||
<div className="font-outfit flex-1 text-content1-foreground">
|
||||
Error
|
||||
</div>
|
||||
<div className="flex-1 text-content1-foreground">Error</div>
|
||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||
{error.message}
|
||||
</div>
|
||||
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex-col justify-center">
|
||||
<div className="font-outfit text-lg truncate">{data?.nick}</div>
|
||||
<div className="font-ubuntu text-danger-500 text-sm">
|
||||
{data?.uin}
|
||||
</div>
|
||||
<div className="text-lg truncate">{data?.nick}</div>
|
||||
<div className="text-danger-500 text-sm">{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
|
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
@@ -10,23 +10,24 @@ interface TerminalInstanceProps {
|
||||
|
||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||
const termRef = useRef<XTermRef>(null)
|
||||
const connected = useRef(false)
|
||||
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data)
|
||||
}
|
||||
} catch (e) {
|
||||
termRef.current?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data)
|
||||
}
|
||||
} catch (e) {
|
||||
termRef.current?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
TerminalManager.connectTerminal(id, handleData)
|
||||
|
||||
return () => {
|
||||
TerminalManager.disconnectTerminal(id, handleData)
|
||||
if (connected.current) {
|
||||
TerminalManager.disconnectTerminal(id, handleData)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -34,5 +35,22 @@ export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||
TerminalManager.sendInput(id, data)
|
||||
}
|
||||
|
||||
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
|
||||
const handleResize = (cols: number, rows: number) => {
|
||||
if (!connected.current) {
|
||||
connected.current = true
|
||||
console.log('instance', rows, cols)
|
||||
TerminalManager.connectTerminal(id, handleData, { rows, cols })
|
||||
} else {
|
||||
TerminalManager.sendResize(id, cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<XTerm
|
||||
ref={termRef}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||
className="w-full h-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { CanvasAddon } from '@xterm/addon-canvas'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
// import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import clsx from 'clsx'
|
||||
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import { gradientText } from '@/utils/terminal'
|
||||
|
||||
export type XTermRef = {
|
||||
write: (
|
||||
...args: Parameters<Terminal['write']>
|
||||
@@ -20,53 +19,44 @@ export type XTermRef = {
|
||||
) => ReturnType<Terminal['writeln']>
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
terminalRef: React.RefObject<Terminal | null>
|
||||
}
|
||||
|
||||
export interface XTermProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||
onInput?: (data: string) => void
|
||||
onKey?: (key: string, event: KeyboardEvent) => void
|
||||
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const { className, onInput, onKey, ...rest } = props
|
||||
const { className, onInput, onKey, onResize, ...rest } = props
|
||||
const { theme } = useTheme()
|
||||
useEffect(() => {
|
||||
if (!domRef.current) {
|
||||
return
|
||||
}
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2
|
||||
})
|
||||
terminalRef.current = terminal
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
if (event.ctrlKey) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(uri, '_blank')
|
||||
}
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebglAddon())
|
||||
terminal.open(domRef.current)
|
||||
|
||||
terminal.writeln(
|
||||
gradientText(
|
||||
'Welcome to NapCat WebUI',
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
)
|
||||
terminal.open(domRef.current!)
|
||||
|
||||
terminal.loadAddon(new CanvasAddon())
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
onInput(data)
|
||||
@@ -81,6 +71,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
// 获取当前终端尺寸
|
||||
const cols = terminal.cols
|
||||
const rows = terminal.rows
|
||||
if (onResize) {
|
||||
onResize(cols, rows)
|
||||
}
|
||||
})
|
||||
|
||||
// 字体加载完成后重新调整终端大小
|
||||
@@ -100,21 +96,49 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.options.theme = {
|
||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
||||
selectionBackground:
|
||||
theme === 'dark'
|
||||
? 'rgba(179, 0, 0, 0.3)'
|
||||
: 'rgba(255, 167, 167, 0.3)',
|
||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
||||
black: theme === 'dark' ? '#fff' : '#000'
|
||||
if (theme === 'dark') {
|
||||
terminalRef.current.options.theme = {
|
||||
background: '#00000000',
|
||||
black: '#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
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
@@ -51,7 +51,7 @@ export const siteConfig = {
|
||||
href: '/config'
|
||||
},
|
||||
{
|
||||
label: 'NapCat日志',
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<LogIcon />
|
||||
|
@@ -41,9 +41,16 @@ class TerminalManager {
|
||||
return data.data
|
||||
}
|
||||
|
||||
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
|
||||
connectTerminal(
|
||||
id: string,
|
||||
callback: TerminalCallback,
|
||||
config?: {
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
): WebSocket {
|
||||
let conn = this.connections.get(id)
|
||||
|
||||
const { cols = 80, rows = 24 } = config || {}
|
||||
if (!conn) {
|
||||
const url = new URL(window.location.href)
|
||||
url.protocol = url.protocol.replace('http', 'ws')
|
||||
@@ -74,6 +81,7 @@ class TerminalManager {
|
||||
|
||||
ws.onopen = () => {
|
||||
if (conn) conn.isConnected = true
|
||||
this.sendResize(id, cols, rows)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -111,6 +119,13 @@ class TerminalManager {
|
||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
}
|
||||
|
||||
sendResize(id: string, cols: number, rows: number) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalManager = new TerminalManager()
|
||||
|
@@ -98,7 +98,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
||||
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
|
||||
'dark:bg-background dark:shadow-danger-100',
|
||||
'bg-background !bg-opacity-50',
|
||||
'shadow-sm shadow-danger-50',
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import 'react-photo-view/dist/react-photo-view.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import App from '@/App.tsx'
|
||||
|
@@ -100,7 +100,7 @@ export default function TerminalPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
|
@@ -1,111 +1,13 @@
|
||||
/* HarmonyOS Sans SC */
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-family: 'Aa偷吃可爱长大的';
|
||||
src: url('/fonts/AaCute.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Ubuntu */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* LibreBaskerville */
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* NotoSerifSC */
|
||||
@font-face {
|
||||
font-family: 'Noto Serif SC';
|
||||
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Outfit */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* FiraCode */
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
@@ -6,35 +6,18 @@
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Aa偷吃可爱长大的',
|
||||
PingFang SC,
|
||||
'Harmony',
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.hm-medium {
|
||||
font-family:
|
||||
PingFang SC,
|
||||
'Harmony',
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
sans-serif !important;
|
||||
@apply font-bold;
|
||||
}
|
||||
.font-ubuntu {
|
||||
font-family: 'Ubuntu', sans-serif;
|
||||
}
|
||||
.font-outfit {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
.font-libre {
|
||||
font-family: 'Libre Baskerville', serif;
|
||||
}
|
||||
.font-noto-serif {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
@@ -105,7 +88,7 @@ body {
|
||||
.context-view.monaco-menu-container * {
|
||||
font-family:
|
||||
PingFang SC,
|
||||
'Harmony',
|
||||
'Aa偷吃可爱长大的',
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
sans-serif !important;
|
||||
@@ -116,4 +99,4 @@ body {
|
||||
}
|
||||
.ql-editor img {
|
||||
@apply inline-block;
|
||||
}
|
||||
}
|
@@ -195,7 +195,7 @@ export interface OneBot11GroupUpload extends NoticeBase {
|
||||
name: string
|
||||
/** 文件大小(字节数) */
|
||||
size: number
|
||||
/** busid(目前不清楚有什么作用) */
|
||||
/** busid 无作用 */
|
||||
busid: number
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.4.20",
|
||||
"version": "4.5.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",
|
||||
|
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.4.20';
|
||||
export const napCatVersion = '4.5.1';
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -17,8 +17,8 @@ export class SetDiyOnlineStatus extends OneBotAction<Payload, string> {
|
||||
async _handle(payload: Payload) {
|
||||
const ret = await this.core.apis.UserApi.setDiySelfOnlineStatus(
|
||||
payload.face_id.toString(),
|
||||
payload.face_type.toString(),
|
||||
payload.wording,
|
||||
payload.face_type.toString(),
|
||||
);
|
||||
if (ret.result !== 0) {
|
||||
throw new Error('设置在线状态失败');
|
||||
|
@@ -22,7 +22,6 @@ import { OB11GroupEssenceEvent } from '@/onebot/event/notice/OB11GroupEssenceEve
|
||||
import { OB11GroupTitleEvent } from '@/onebot/event/notice/OB11GroupTitleEvent';
|
||||
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
|
||||
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
|
||||
import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent';
|
||||
|
||||
@@ -202,8 +201,7 @@ export class OneBotGroupApi {
|
||||
id: FileNapCatOneBotUUID.encode({
|
||||
chatType: ChatType.KCHATTYPEGROUP,
|
||||
peerUid: msg.peerUid,
|
||||
}, msg.msgId, elementWrapper.elementId, elementWrapper?.fileElement?.fileUuid, element.fileName),
|
||||
url: pathToFileURL(element.filePath).href,
|
||||
}, msg.msgId, elementWrapper.elementId, elementWrapper?.fileElement?.fileUuid, element.fileMd5 ?? element.fileUuid),
|
||||
name: element.fileName,
|
||||
size: parseInt(element.fileSize),
|
||||
busid: element.fileBizId ?? 0,
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
|
||||
import { MessageUnique } from '@/common/message-unique';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
NTMsgAtType,
|
||||
ChatType,
|
||||
@@ -146,7 +145,6 @@ export class OneBotMsgApi {
|
||||
data: {
|
||||
file: file,
|
||||
path: element.filePath,
|
||||
url: pathToFileURL(element.filePath).href,
|
||||
file_id: file,
|
||||
file_size: element.fileSize,
|
||||
},
|
||||
@@ -334,7 +332,7 @@ export class OneBotMsgApi {
|
||||
data: {
|
||||
file: fileCode,
|
||||
path: videoDownUrl,
|
||||
url: videoDownUrl ?? pathToFileURL(element.filePath).href,
|
||||
url: videoDownUrl,
|
||||
file_size: element.fileSize,
|
||||
},
|
||||
};
|
||||
@@ -352,7 +350,6 @@ export class OneBotMsgApi {
|
||||
data: {
|
||||
file: fileCode,
|
||||
path: element.filePath,
|
||||
url: pathToFileURL(element.filePath).href,
|
||||
file_size: element.fileSize,
|
||||
},
|
||||
};
|
||||
|
@@ -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>;
|
||||
|
@@ -6,7 +6,6 @@ export interface GroupUploadFile {
|
||||
name: string,
|
||||
size: number,
|
||||
busid: number,
|
||||
url:string;
|
||||
}
|
||||
|
||||
export class OB11GroupUploadNoticeEvent extends OB11GroupNoticeEvent {
|
||||
|
@@ -6,7 +6,11 @@ import { terminalManager } from '../terminal/terminal_manager';
|
||||
|
||||
// 日志记录
|
||||
export const LogHandler: RequestHandler = async (req, res) => {
|
||||
const filename = req.query.id as string;
|
||||
const filename = req.query['id'];
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
|
||||
if (filename.includes('..')) {
|
||||
return sendError(res, 'ID不合法');
|
||||
}
|
||||
@@ -40,7 +44,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
|
||||
// 终端相关处理器
|
||||
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = terminalManager.createTerminal();
|
||||
const { cols, rows } = req.body;
|
||||
const { id } = terminalManager.createTerminal(cols, rows);
|
||||
return sendSuccess(res, { id });
|
||||
} catch (error) {
|
||||
console.error('Failed to create terminal:', error);
|
||||
@@ -54,7 +59,10 @@ export const GetTerminalListHandler: RequestHandler = (_, res) => {
|
||||
};
|
||||
|
||||
export const CloseTerminalHandler: RequestHandler = (req, res) => {
|
||||
const id = req.params.id;
|
||||
const id = req.params['id'];
|
||||
if (!id) {
|
||||
return sendError(res, 'ID不能为空');
|
||||
}
|
||||
terminalManager.closeTerminal(id);
|
||||
return sendSuccess(res, {});
|
||||
};
|
||||
|
@@ -13,6 +13,8 @@ interface TerminalInstance {
|
||||
sockets: Set<WebSocket>;
|
||||
// 新增标识,用于防止重复关闭
|
||||
isClosing: boolean;
|
||||
// 新增:存储终端历史输出
|
||||
buffer: string;
|
||||
}
|
||||
|
||||
class TerminalManager {
|
||||
@@ -67,21 +69,24 @@ class TerminalManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataHandler = (data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
}
|
||||
};
|
||||
|
||||
instance.sockets.add(ws);
|
||||
instance.lastAccess = Date.now();
|
||||
|
||||
// 新增:发送当前终端内容给新连接
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
|
||||
}
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (instance) {
|
||||
const result = JSON.parse(data.toString());
|
||||
if (result.type === 'input') {
|
||||
instance.pty.write(result.data);
|
||||
}
|
||||
// 新增:处理 resize 消息
|
||||
if (result.type === 'resize') {
|
||||
instance.pty.resize(result.cols, result.rows);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,18 +108,17 @@ class TerminalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// 修改:移除参数 id,使用 crypto.randomUUID 生成终端 id
|
||||
createTerminal() {
|
||||
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
|
||||
createTerminal(cols: number, rows: number) {
|
||||
const id = randomUUID();
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const pty = ptySpawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cols, // 使用客户端传入的 cols
|
||||
rows, // 使用客户端传入的 rows
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
// 统一编码设置
|
||||
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
@@ -125,9 +129,13 @@ class TerminalManager {
|
||||
lastAccess: Date.now(),
|
||||
sockets: new Set(),
|
||||
isClosing: false,
|
||||
buffer: '', // 初始化终端内容缓存
|
||||
};
|
||||
|
||||
pty.onData((data: any) => {
|
||||
// 追加数据到 buffer
|
||||
instance.buffer += data;
|
||||
// 发送数据给已连接的 websocket
|
||||
instance.sockets.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
|
Reference in New Issue
Block a user