mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
73b80d2482 | ||
![]() |
4a95b17a47 | ||
![]() |
f4a71159fd | ||
![]() |
c0431e3dc2 | ||
![]() |
7f87cee282 | ||
![]() |
c24c704439 | ||
![]() |
232e5d55b8 | ||
![]() |
da24ae7e1c | ||
![]() |
8fc13f8a8f | ||
![]() |
7e1fe31085 | ||
![]() |
c3cba8ba4e | ||
![]() |
ba619986c9 | ||
![]() |
dcef3f3c3b | ||
![]() |
823faa2790 | ||
![]() |
ef4248d2a3 | ||
![]() |
3917cb0dc9 | ||
![]() |
520cec0eaa | ||
![]() |
e7655e0ff6 | ||
![]() |
350ced55c0 | ||
![]() |
2ca6d0a00e | ||
![]() |
844abad0d0 | ||
![]() |
d278e9d8bc | ||
![]() |
6e261f30c2 | ||
![]() |
84f0e43369 | ||
![]() |
3223a06983 | ||
![]() |
1b874a0264 | ||
![]() |
26525a0ff9 | ||
![]() |
49234ea5c7 | ||
![]() |
c474158a09 | ||
![]() |
813a541e7f | ||
![]() |
efd489bfd4 | ||
![]() |
9c88fcb610 | ||
![]() |
c1cac8de19 | ||
![]() |
b03fa54e63 | ||
![]() |
9785731f25 | ||
![]() |
566afde18b | ||
![]() |
bae8dabc5c | ||
![]() |
0a9dfea20a | ||
![]() |
50498aa52d |
@@ -53,7 +53,7 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
|
||||
## 感谢他们
|
||||
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
感谢 Tencent Tdesign / Vue3 强力驱动 NapCat.WebUi
|
||||
感谢 React 强力驱动 NapCat.WebUi
|
||||
|
||||
不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.4.4",
|
||||
"version": "4.4.16",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -10,8 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@heroui/avatar": "2.2.7",
|
||||
"@heroui/breadcrumbs": "2.2.7",
|
||||
"@heroui/button": "2.2.10",
|
||||
@@ -38,6 +36,8 @@
|
||||
"@heroui/tabs": "2.2.8",
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@react-aria/visually-hidden": "3.8.18",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
@@ -62,11 +62,13 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "7.1.0",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwind-variants": "0.3.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"zod": "^3.24.1"
|
||||
|
@@ -187,7 +187,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[9999] w-full md:w-96',
|
||||
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
|
@@ -88,6 +88,34 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="httpSseServers"
|
||||
textValue="httpSseServers"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
HTTP SSE服务器
|
||||
<Tooltip
|
||||
content="「由NapCat建立」一个HTTP SSE服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="httpClients"
|
||||
textValue="httpClients"
|
||||
|
@@ -5,7 +5,7 @@ import { IoMdRefresh } from 'react-icons/io'
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh: () => void
|
||||
refresh?: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
@@ -27,21 +27,23 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
color="danger"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@@ -19,12 +19,6 @@ loader.config({
|
||||
}
|
||||
})
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' }
|
||||
}
|
||||
})
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string
|
||||
}
|
||||
|
@@ -1125,7 +1125,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1173,7 +1173,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1220,7 +1220,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1270,7 +1270,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1320,7 +1320,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1372,7 +1372,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1422,7 +1422,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1469,7 +1469,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1519,7 +1519,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1566,7 +1566,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1613,7 +1613,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1660,7 +1660,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
@@ -1707,7 +1707,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
>
|
||||
<g
|
||||
id="svgGroup"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
stroke="#000"
|
||||
fill="transparent"
|
||||
style={{
|
||||
|
@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.slice(-100)
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
Xterm.current?.clear()
|
||||
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
console.log('subscribeLogs')
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data]
|
||||
|
@@ -13,12 +13,15 @@ export interface ModalProps {
|
||||
content: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||
showCancel?: boolean
|
||||
dismissible?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
size = 'md',
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel
|
||||
onCancel,
|
||||
...rest
|
||||
} = props
|
||||
const { onClose: onNativeClose } = useDisclosure()
|
||||
|
||||
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
onClose?.()
|
||||
onNativeClose()
|
||||
}}
|
||||
size={size}
|
||||
classNames={{
|
||||
backdrop: 'z-[99999999]',
|
||||
wrapper: 'z-[999999999]'
|
||||
backdrop: 'z-[99]',
|
||||
wrapper: 'z-[99]'
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
@@ -56,7 +61,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
{title && (
|
||||
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody>{content}</ModalBody>
|
||||
<ModalBody className="break-all">{content}</ModalBody>
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
@@ -67,17 +72,17 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
取消
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
onConfirm?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
确定
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
@@ -2,12 +2,14 @@ import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoLink, IoSend } from 'react-icons/io5'
|
||||
import { PiCatDuotone } from 'react-icons/pi'
|
||||
|
||||
import key from '@/const/key'
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
@@ -27,9 +29,10 @@ export interface OneBotApiDebugProps {
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props
|
||||
const url = new URL(window.location.origin).href
|
||||
const defaultHttpUrl = url.replace(':6099', ':3000')
|
||||
const [httpConfig, setHttpConfig] = useState({
|
||||
const currentURL = new URL(window.location.origin)
|
||||
currentURL.port = '3000'
|
||||
const defaultHttpUrl = currentURL.href
|
||||
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
|
||||
url: defaultHttpUrl,
|
||||
token: ''
|
||||
})
|
||||
@@ -38,6 +41,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const responseRef = useRef<HTMLDivElement>(null)
|
||||
const parsedRequest = parse(data.request)
|
||||
const parsedResponse = parse(data.response)
|
||||
|
||||
@@ -47,8 +51,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const r = toast.loading('正在发送请求...')
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody)
|
||||
const requestURL = new URL(httpConfig.url)
|
||||
requestURL.pathname = path
|
||||
request
|
||||
.post(httpConfig.url + path, parsedRequestBody, {
|
||||
.post(requestURL.href, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`
|
||||
},
|
||||
@@ -64,6 +70,11 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
setIsResponseOpen(true)
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
toast.dismiss(r)
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -79,8 +90,8 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
|
||||
<section className="p-4 pt-14 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400">
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
@@ -88,6 +99,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
<Snippet
|
||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||
tooltipProps={{
|
||||
content: '点击复制地址'
|
||||
}}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
@@ -120,7 +134,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card shadow="sm" className="my-4 bg-opacity-50 backdrop-blur-md">
|
||||
<Card
|
||||
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">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
@@ -135,7 +152,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
ref={responseRef}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
@@ -218,7 +235,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -19,9 +19,8 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
|
||||
openSideBar &&
|
||||
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
|
||||
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
|
||||
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
transition={{
|
||||
@@ -32,7 +31,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
||||
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
||||
>
|
||||
<div className="w-64 h-full overflow-y-auto px-2 float-right">
|
||||
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
|
||||
<Input
|
||||
className="sticky top-0 z-10 text-danger-600"
|
||||
classNames={{
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
import { useCallback, useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import type { CodeEditorRef } from '@/components/code_editor'
|
||||
|
||||
@@ -72,6 +73,8 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ChatInputModal />
|
||||
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
|
@@ -1,45 +1,189 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { FaCircleInfo } from 'react-icons/fa6'
|
||||
import { FaQq } from 'react-icons/fa6'
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||
import { RiMacFill } from 'react-icons/ri'
|
||||
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { compareVersion } from '@/utils/version'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import { GithubRelease } from '@/types/github'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import TailwindMarkdown from './tailwind_markdown'
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
value?: React.ReactNode
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
title,
|
||||
value = '--',
|
||||
icon
|
||||
icon,
|
||||
endContent
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
|
||||
{icon}
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-danger-200">{value}</div>
|
||||
<div className="ml-auto">{endContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface NewVersionTipProps {
|
||||
currentVersion?: string
|
||||
}
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props
|
||||
const dialog = useDialog()
|
||||
const { data: releaseData, error } = useRequest(() =>
|
||||
request.get<GithubRelease[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip content="检查新版本失败">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.alert({
|
||||
title: '检查新版本失败',
|
||||
content: error.message
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const latestVersion = releaseData?.data?.[0]?.tag_name
|
||||
|
||||
if (!latestVersion || !currentVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const middleVersions: GithubRelease[] = []
|
||||
|
||||
for (let i = 0; i < releaseData.data.length; i++) {
|
||||
const versionInfo = releaseData.data[i]
|
||||
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||
middleVersions.push(versionInfo)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="有新版本可用">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm space-x-2">
|
||||
<span>当前版本</span>
|
||||
<Chip color="primary" variant="flat">
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="text-sm space-x-2">
|
||||
<span>最新版本</span>
|
||||
<Chip color="primary">{latestVersion}</Chip>
|
||||
</div>
|
||||
<div className="text-sm space-y-2 !mt-4">
|
||||
{middleVersions.map((versionInfo) => (
|
||||
<div
|
||||
key={versionInfo.tag_name}
|
||||
className="p-4 bg-content1 rounded-md shadow-small"
|
||||
>
|
||||
<TailwindMarkdown content={versionInfo.body} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
scrollBehavior: 'inside',
|
||||
size: '3xl',
|
||||
confirmText: '前往下载',
|
||||
onConfirm() {
|
||||
window.open(
|
||||
'https://github.com/NapNeko/NapCatQQ/releases',
|
||||
'_blank',
|
||||
'noopener'
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const NapCatVersion = () => {
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError
|
||||
} = useRequest(WebUIManager.getPackageInfo)
|
||||
|
||||
const currentVersion = packageData?.version
|
||||
|
||||
return (
|
||||
<SystemInfoItem
|
||||
title="NapCat 版本"
|
||||
icon={<IoLogoOctocat className="text-xl" />}
|
||||
value={
|
||||
packageError ? (
|
||||
`错误:${packageError.message}`
|
||||
) : packageLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
currentVersion
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SystemInfoProps {
|
||||
archInfo?: string
|
||||
}
|
||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
const { archInfo } = props
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError
|
||||
} = useRequest(WebUIManager.getPackageInfo)
|
||||
const {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
@@ -53,19 +197,7 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
</CardHeader>
|
||||
<CardBody className="flex-1">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<SystemInfoItem
|
||||
title="NapCat 版本"
|
||||
icon={<IoLogoOctocat className="text-xl" />}
|
||||
value={
|
||||
packageError ? (
|
||||
`错误:${packageError.message}`
|
||||
) : packageLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
packageData?.version
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title="WebUI 版本"
|
||||
icon={<IoLogoChrome className="text-xl" />}
|
||||
|
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal file
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-2xl font-bold" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-xl font-bold" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-lg font-bold" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-blue-500 hover:underline"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-inside" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-inside" {...props} />
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-gray-300 pl-4 italic"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node, ...props }) => (
|
||||
<code className="bg-gray-100 p-1 rounded" {...props} />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default TailwindMarkdown
|
@@ -21,6 +21,7 @@ export type XTermRef = {
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
(props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
@@ -33,15 +34,24 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
}
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace'
|
||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.0
|
||||
})
|
||||
terminalRef.current = terminal
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
if (event.ctrlKey) {
|
||||
window.open(uri, '_blank')
|
||||
}
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebglAddon())
|
||||
terminal.open(domRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
terminal.writeln(
|
||||
gradientText(
|
||||
@@ -57,16 +67,16 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
})
|
||||
resizeObserver.observe(domRef.current)
|
||||
|
||||
const handleFontLoad = () => {
|
||||
terminal.refresh(0, terminal.rows - 1)
|
||||
}
|
||||
document.fonts.addEventListener('loadingdone', handleFontLoad)
|
||||
// 字体加载完成后重新调整终端大小
|
||||
document.fonts.ready.then(() => {
|
||||
fitAddon.fit()
|
||||
|
||||
resizeObserver.observe(domRef.current!)
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
document.fonts.removeEventListener('loadingdone', handleFontLoad)
|
||||
setTimeout(() => {
|
||||
terminal.dispose()
|
||||
}, 0)
|
||||
@@ -76,14 +86,20 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.options.theme = {
|
||||
background:
|
||||
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
|
||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
||||
selectionBackground: theme === 'dark' ? '#666' : '#ddd',
|
||||
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'
|
||||
}
|
||||
terminalRef.current.options.fontWeight =
|
||||
theme === 'dark' ? 'normal' : '600'
|
||||
terminalRef.current.options.fontWeightBold =
|
||||
theme === 'dark' ? 'bold' : '900'
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
|
@@ -7,7 +7,9 @@ enum key {
|
||||
autoPlay = 'auto-play',
|
||||
customIcons = 'custom-icons',
|
||||
isCollapsedMusicPlayer = 'is-collapsed-music-player',
|
||||
sideBarOpen = 'side-bar-open'
|
||||
sideBarOpen = 'side-bar-open',
|
||||
httpDebugConfig = 'http-debug-config',
|
||||
wsDebugConfig = 'ws-debug-config'
|
||||
}
|
||||
|
||||
export default key
|
||||
|
@@ -1,22 +1,15 @@
|
||||
// Dialog Context
|
||||
import React from 'react'
|
||||
|
||||
import type { ModalProps } from '@/components/modal'
|
||||
import Modal from '@/components/modal'
|
||||
import type { ModalProps } from '@/components/modal'
|
||||
|
||||
export interface AlertProps {
|
||||
title?: React.ReactNode
|
||||
content: React.ReactNode
|
||||
size?: ModalProps['size']
|
||||
dismissible?: boolean
|
||||
export interface AlertProps
|
||||
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export interface ConfirmProps {
|
||||
title?: React.ReactNode
|
||||
content: React.ReactNode
|
||||
size?: ModalProps['size']
|
||||
dismissible?: boolean
|
||||
export interface ConfirmProps extends ModalProps {
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
|
||||
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
|
||||
const alert = (config: AlertProps) => {
|
||||
const { title, content, dismissible, onConfirm, size = 'md' } = config
|
||||
const { onConfirm, size = 'md', ...rest } = config
|
||||
|
||||
setDialogs((before) => {
|
||||
const id = before[before.length - 1]?.id + 1 || 0
|
||||
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
...before,
|
||||
{
|
||||
id,
|
||||
content,
|
||||
size,
|
||||
title,
|
||||
backdrop: 'blur',
|
||||
showCancel: false,
|
||||
dismissible: dismissible,
|
||||
onConfirm: () => {
|
||||
onConfirm?.()
|
||||
setTimeout(() => {
|
||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
...rest
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const confirm = (config: ConfirmProps) => {
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
dismissible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
size = 'md'
|
||||
} = config
|
||||
const { onConfirm, onCancel, size = 'md', ...rest } = config
|
||||
|
||||
setDialogs((before) => {
|
||||
const id = before[before.length - 1]?.id + 1 || 0
|
||||
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
...before,
|
||||
{
|
||||
id,
|
||||
content,
|
||||
size,
|
||||
title,
|
||||
backdrop: 'blur',
|
||||
showCancel: true,
|
||||
dismissible: dismissible,
|
||||
onConfirm: () => {
|
||||
onConfirm?.()
|
||||
setTimeout(() => {
|
||||
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||
setTimeout(() => {
|
||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
...rest
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@@ -24,6 +24,22 @@ export default class WebUIManager {
|
||||
return data.data.Credential
|
||||
}
|
||||
|
||||
public static async changePassword(oldToken: string, newToken: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/auth/update_token',
|
||||
{ oldToken, newToken }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async proxy<T>(url = '') {
|
||||
const data = await serverRequest.get<ServerResponse<string>>(
|
||||
'/base/proxy?url=' + encodeURIComponent(url)
|
||||
)
|
||||
data.data.data = JSON.parse(data.data.data)
|
||||
return data.data as ServerResponse<T>
|
||||
}
|
||||
|
||||
public static async getPackageInfo() {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<PackageInfo>>('/base/PackageInfo')
|
||||
|
@@ -2,7 +2,7 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
|
||||
import { Button } from '@heroui/button'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import clsx from 'clsx'
|
||||
import { motion } from 'motion/react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { MdMenu, MdMenuOpen } from 'react-icons/md'
|
||||
@@ -79,7 +79,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
}, [location.pathname])
|
||||
return (
|
||||
<div
|
||||
className="h-screen relative flex bg-danger-50 dark:bg-black"
|
||||
className="h-screen relative flex bg-danger-50 dark:bg-black items-stretch"
|
||||
style={{
|
||||
backgroundImage: `url(${b64img})`,
|
||||
backgroundSize: 'cover'
|
||||
@@ -89,13 +89,22 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto relative flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
||||
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
||||
openSideBar ? 'ml-0' : 'ml-1',
|
||||
!b64img && 'shadow-inner',
|
||||
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background'
|
||||
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
|
||||
'overflow-x-hidden'
|
||||
)}
|
||||
>
|
||||
<div className="h-10 flex items-center hm-medium text-xl sticky top-2 left-0 backdrop-blur-lg z-20 shadow-sm bg-background dark:bg-background shadow-danger-50 dark:shadow-danger-100 m-2 rounded-full !bg-opacity-50">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
||||
'dark:bg-background dark:shadow-danger-100',
|
||||
'bg-background !bg-opacity-50',
|
||||
'shadow-sm shadow-danger-50',
|
||||
'z-30 m-2 mb-0 sticky top-2 left-0'
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'mr-1 ease-in-out ml-0 md:relative',
|
||||
@@ -117,7 +126,19 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
</motion.div>
|
||||
<Breadcrumbs isDisabled size="lg">
|
||||
{title?.map((item, index) => (
|
||||
<BreadcrumbItem key={index}>{item}</BreadcrumbItem>
|
||||
<BreadcrumbItem key={index}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={item}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{item}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
|
@@ -1,12 +1,10 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Link } from '@heroui/link'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons'
|
||||
import { BietiaopIcon, WebUIIcon } from '@/components/icons'
|
||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||
import { title } from '@/components/primitives'
|
||||
|
||||
@@ -43,11 +41,6 @@ function VersionInfo() {
|
||||
data?.version
|
||||
)}
|
||||
</Chip>
|
||||
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
|
||||
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
|
||||
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
81
napcat.webui/src/pages/dashboard/config/change_password.tsx
Normal file
81
napcat.webui/src/pages/dashboard/config/change_password.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
const ChangePasswordCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset
|
||||
} = useForm<{
|
||||
oldToken: string
|
||||
newToken: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
oldToken: '',
|
||||
newToken: ''
|
||||
}
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [_, setToken] = useLocalStorage(key.token, '')
|
||||
|
||||
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||
try {
|
||||
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||
toast.success('修改成功')
|
||||
setToken('')
|
||||
localStorage.removeItem(key.token)
|
||||
navigate('/web_login')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`修改失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>修改密码 - NapCat WebUI</title>
|
||||
<Controller
|
||||
control={control}
|
||||
name="oldToken"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="旧密码"
|
||||
placeholder="请输入旧密码"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="newToken"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="新密码"
|
||||
placeholder="请输入新密码"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePasswordCard
|
@@ -1,101 +1,27 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import ChangePasswordCard from './change_password'
|
||||
import OneBotConfigCard from './onebot'
|
||||
import WebUIConfigCard from './webui'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const {
|
||||
control: onebotControl,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting: isOnebotSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const {
|
||||
control: webuiControl,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting: isWebuiSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
|
||||
return (
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">{children}</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
const resetOneBot = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const resetWebUI = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onOneBotSubmit = handleOnebotSubmit((data) => {
|
||||
try {
|
||||
saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onWebuiSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshConfig()
|
||||
toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resetOneBot()
|
||||
resetWebUI()
|
||||
}, [config])
|
||||
export default function ConfigPage() {
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
|
||||
return (
|
||||
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
|
||||
@@ -106,28 +32,26 @@ export default function ConfigPage() {
|
||||
isVertical={isMediumUp}
|
||||
classNames={{
|
||||
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
|
||||
panel: 'w-full',
|
||||
panel: 'w-full relative',
|
||||
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm'
|
||||
}}
|
||||
>
|
||||
<Tab title="OneBot配置" key="onebot">
|
||||
<OneBotConfigCard
|
||||
isSubmitting={isOnebotSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onOneBotSubmit}
|
||||
control={onebotControl}
|
||||
reset={resetOneBot}
|
||||
/>
|
||||
<ConfingPageItem>
|
||||
<OneBotConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title="WebUI配置" key="webui">
|
||||
<WebUIConfigCard
|
||||
isSubmitting={isWebuiSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onWebuiSubmit}
|
||||
control={webuiControl}
|
||||
reset={resetWebUI}
|
||||
/>
|
||||
<ConfingPageItem>
|
||||
<WebUIConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
|
||||
<Tab title="修改密码" key="token">
|
||||
<ConfingPageItem>
|
||||
<ChangePasswordCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
@@ -1,68 +1,110 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import SwitchCard from '@/components/switch_card'
|
||||
|
||||
export interface OneBotConfigCardProps {
|
||||
control: Control<IConfig['onebot']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
const OneBotConfigCard = () => {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
const reset = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
|
||||
const onSubmit = handleOnebotSubmit((data) => {
|
||||
try {
|
||||
saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async (shotTip = true) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await refreshConfig()
|
||||
if (shotTip) toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh(false)
|
||||
}, [])
|
||||
|
||||
if (loading) return <PageLoading loading={true} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -1,69 +1,99 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import ImageInput from '@/components/input/image_input'
|
||||
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export interface WebUIConfigCardProps {
|
||||
control: Control<IConfig['webui']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
const WebUIConfigCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [listId, customIcons, b64img])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => (
|
||||
<ImageInput {...field} label={item.label} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -27,41 +27,36 @@ export default function HttpDebug() {
|
||||
return (
|
||||
<>
|
||||
<title>HTTP调试 - NapCat WebUI</title>
|
||||
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={setSelectedApi}
|
||||
openSideBar={openSideBar}
|
||||
/>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 h-full overflow-x-hidden relative"
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={setSelectedApi}
|
||||
openSideBar={openSideBar}
|
||||
/>
|
||||
<div ref={contentRef} className="flex-1 h-full overflow-x-hidden">
|
||||
<motion.div
|
||||
className="absolute top-16 z-30 md:!ml-4"
|
||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
>
|
||||
<motion.div
|
||||
className="sticky top-0 z-20 md:!ml-4"
|
||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
radius="md"
|
||||
variant="shadow"
|
||||
size="sm"
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
radius="md"
|
||||
variant="shadow"
|
||||
size="sm"
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled
|
||||
size={24}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
openSideBar ? '' : 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
</div>
|
||||
<TbSquareRoundedChevronLeftFilled
|
||||
size={24}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
openSideBar ? '' : 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import key from '@/const/key'
|
||||
|
||||
import OneBotMessageList from '@/components/onebot/message_list'
|
||||
import OneBotSendModal from '@/components/onebot/send_modal'
|
||||
import WSStatus from '@/components/onebot/ws_status'
|
||||
@@ -12,9 +14,11 @@ import WSStatus from '@/components/onebot/ws_status'
|
||||
import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
||||
|
||||
export default function WSDebug() {
|
||||
const url = new URL(window.location.origin).href
|
||||
const defaultWsUrl = url.replace('http', 'ws').replace(':6099', ':3000')
|
||||
const [socketConfig, setSocketConfig] = useState({
|
||||
const url = new URL(window.location.origin)
|
||||
url.port = '3000'
|
||||
url.protocol = 'ws:'
|
||||
const defaultWsUrl = url.href
|
||||
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
||||
url: defaultWsUrl,
|
||||
token: ''
|
||||
})
|
||||
@@ -77,7 +81,6 @@ export default function WSDebug() {
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
<ChatInputModal />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
@@ -294,6 +294,13 @@ export default function NetworkPage() {
|
||||
true
|
||||
)
|
||||
}
|
||||
if (httpSseServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpSseServers',
|
||||
item as OneBotConfig['network']['httpSseServers'][0],
|
||||
true
|
||||
)
|
||||
}
|
||||
if (httpClients.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpClients',
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
|
||||
import DefaultLayout from '@/layouts/default'
|
||||
|
||||
@@ -12,19 +13,30 @@ import LogsPage from './dashboard/logs'
|
||||
import NetworkPage from './dashboard/network'
|
||||
|
||||
export default function IndexPage() {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Routes>
|
||||
<Route element={<DashboardIndexPage />} path="/" />
|
||||
<Route element={<NetworkPage />} path="/network" />
|
||||
<Route element={<ConfigPage />} path="/config" />
|
||||
<Route element={<LogsPage />} path="/logs" />
|
||||
<Route element={<DebugPage />} path="/debug">
|
||||
<Route path="ws" element={<WSDebug />} />
|
||||
<Route path="http" element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route element={<AboutPage />} path="/about" />
|
||||
</Routes>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route element={<DashboardIndexPage />} path="/" />
|
||||
<Route element={<NetworkPage />} path="/network" />
|
||||
<Route element={<ConfigPage />} path="/config" />
|
||||
<Route element={<LogsPage />} path="/logs" />
|
||||
<Route element={<DebugPage />} path="/debug">
|
||||
<Route path="ws" element={<WSDebug />} />
|
||||
<Route path="http" element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route element={<AboutPage />} path="/about" />
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
/* HarmonyOS Sans SC */
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -16,56 +16,56 @@
|
||||
/* Ubuntu */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/pingfang/Ubuntu-Medium.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/fonts/pingfang/Ubuntu-MediumItalic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -73,21 +73,21 @@
|
||||
/* LibreBaskerville */
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -95,17 +95,17 @@
|
||||
/* NotoSerifSC */
|
||||
@font-face {
|
||||
font-family: 'Noto Serif SC';
|
||||
src: url('/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
||||
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Outfit */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
||||
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* FiraCode */
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('/fonts/FiraCode-VariablFont_wght.ttf') format('truetype');
|
||||
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
@@ -44,6 +44,7 @@ body {
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
outline: none !important;
|
||||
border-radius: 5px !important;
|
||||
@@ -75,6 +76,10 @@ body {
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.context-view.monaco-menu-container * {
|
||||
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
||||
}
|
||||
|
||||
.ql-hidden {
|
||||
@apply hidden;
|
||||
}
|
||||
|
@@ -6,17 +6,17 @@ import type {
|
||||
Music163URLResponse
|
||||
} from '@/types/music'
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
/**
|
||||
* 获取网易云音乐歌单
|
||||
* @param id 歌单id
|
||||
* @returns 歌单信息
|
||||
*/
|
||||
export const get163MusicList = async (id: string) => {
|
||||
const res = await request.get<Music163ListResponse>(
|
||||
`https://wavesgame.top/playlist/track/all?id=${id}`
|
||||
)
|
||||
let res = await WebUIManager.proxy<Music163ListResponse>('https://wavesgame.top/playlist/track/all?id=' + id);
|
||||
// const res = await request.get<Music163ListResponse>(
|
||||
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
||||
// )
|
||||
if (res?.data?.code !== 200) {
|
||||
throw new Error('获取歌曲列表失败')
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const getSongsURL = async (ids: number[]) => {
|
||||
}, [] as number[][])
|
||||
const res = await Promise.all(
|
||||
_ids.map(async (id) => {
|
||||
const res = await request.get<Music163URLResponse>(
|
||||
const res = await WebUIManager.proxy<Music163URLResponse>(
|
||||
`https://wavesgame.top/song/url?id=${id.join(',')}`
|
||||
)
|
||||
if (res?.data?.code !== 200) {
|
||||
|
36
napcat.webui/src/utils/version.ts
Normal file
36
napcat.webui/src/utils/version.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 版本号转为数字
|
||||
* @param version 版本号
|
||||
* @returns 版本号数字
|
||||
*/
|
||||
export const versionToNumber = (version: string): number => {
|
||||
const finalVersionString = version.replace(/^v/, '')
|
||||
|
||||
const versionArray = finalVersionString.split('.')
|
||||
const versionNumber =
|
||||
parseInt(versionArray[2]) +
|
||||
parseInt(versionArray[1]) * 100 +
|
||||
parseInt(versionArray[0]) * 10000
|
||||
|
||||
return versionNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
* @param version1 版本号1
|
||||
* @param version2 版本号2
|
||||
* @returns 比较结果
|
||||
* 0: 相等
|
||||
* 1: version1 > version2
|
||||
* -1: version1 < version2
|
||||
*/
|
||||
export const compareVersion = (version1: string, version2: string): number => {
|
||||
const versionNumber1 = versionToNumber(version1)
|
||||
const versionNumber2 = versionToNumber(version2)
|
||||
|
||||
if (versionNumber1 === versionNumber2) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return versionNumber1 > versionNumber2 ? 1 : -1
|
||||
}
|
14
package.json
14
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.4.4",
|
||||
"version": "4.4.16",
|
||||
"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",
|
||||
@@ -17,6 +17,7 @@
|
||||
"dev:depend": "npm i && cd napcat.webui && npm i"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"esbuild": "0.24.0",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@eslint/compat": "^1.2.2",
|
||||
@@ -29,7 +30,6 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@sinclair/typebox": "^0.34.9",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/node": "^22.0.1",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.5.12",
|
||||
@@ -54,11 +54,19 @@
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
||||
"@ffmpeg.wasm/main": "^0.13.1",
|
||||
"express": "^5.0.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"piscina": "^4.7.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"overrides": {
|
||||
"strtok3": {
|
||||
"dependencies": {
|
||||
"peek-readable": "5.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,14 +2,12 @@ import Piscina from 'piscina';
|
||||
import fsPromise from 'fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import { EncodeArgs } from "@/common/audio-worker";
|
||||
import { FFmpegService } from "@/common/ffmpeg";
|
||||
|
||||
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||
const EXIT_CODES = [0, 255];
|
||||
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
|
||||
|
||||
async function getWorkerPath() {
|
||||
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
||||
@@ -26,30 +24,6 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
async function convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const cp = spawn(FFMPEG_PATH, ['-y', '-i', filePath, '-ar', '24000', '-ac', '1', '-f', 's16le', pcmPath]);
|
||||
cp.on('error', (err: Error) => {
|
||||
logger.log('FFmpeg处理转换出错: ', err.message);
|
||||
reject(err);
|
||||
});
|
||||
cp.on('exit', async (code, signal) => {
|
||||
if (code == null || EXIT_CODES.includes(code)) {
|
||||
try {
|
||||
const data = await fsPromise.readFile(pcmPath);
|
||||
await fsPromise.unlink(pcmPath);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
logger.log(`FFmpeg exit: code=${code ?? 'unknown'} sig=${signal ?? 'unknown'}`);
|
||||
reject(new Error('FFmpeg处理转换失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWavFile(
|
||||
file: Buffer,
|
||||
filePath: string,
|
||||
@@ -58,7 +32,7 @@ async function handleWavFile(
|
||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||
return { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
||||
return { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
||||
}
|
||||
return { input: file, sampleRate: fmt.sampleRate };
|
||||
}
|
||||
@@ -72,7 +46,7 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
const { input, sampleRate } = isWav(file)
|
||||
? (await handleWavFile(file, filePath, pcmPath, logger))
|
||||
: { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
||||
: { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
||||
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
|
||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { NapCatCore } from '@/core';
|
||||
import json5 from 'json5';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
@@ -46,7 +47,7 @@ export abstract class ConfigBase<T> {
|
||||
fs.writeFileSync(configPath, '{}');
|
||||
}
|
||||
try {
|
||||
this.configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
this.configData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||
return this.configData;
|
||||
} catch (e: any) {
|
||||
|
@@ -25,7 +25,6 @@ export class Fallback<T> {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
151
src/common/ffmpeg-worker.ts
Normal file
151
src/common/ffmpeg-worker.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { FFmpeg } from '@ffmpeg.wasm/main';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync, statSync, writeFileSync } from 'fs';
|
||||
import type { LogWrapper } from './log';
|
||||
import type { VideoInfo } from './video';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import imageSize from 'image-size';
|
||||
class FFmpegService {
|
||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
const videoFileName = `${randomUUID()}.mp4`;
|
||||
const outputFileName = `${randomUUID()}.jpg`;
|
||||
try {
|
||||
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
||||
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
||||
if (code !== 0) {
|
||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||
}
|
||||
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
|
||||
writeFileSync(thumbnailPath, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(outputFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking output file:', unlinkError);
|
||||
}
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(videoFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking video file:', unlinkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
const inputFileName = `${randomUUID()}.pcm`;
|
||||
const outputFileName = `${randomUUID()}.${format}`;
|
||||
try {
|
||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile));
|
||||
const params = format === 'amr'
|
||||
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
||||
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
||||
let code = await ffmpegInstance.run(...params);
|
||||
if (code !== 0) {
|
||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||
}
|
||||
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
||||
writeFileSync(outputFile, outputData);
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(outputFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking output file:', unlinkError);
|
||||
}
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(inputFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking input file:', unlinkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
const inputFileName = `${randomUUID()}.input`;
|
||||
const outputFileName = `${randomUUID()}.pcm`;
|
||||
try {
|
||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
||||
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
||||
let code = await ffmpegInstance.run(...params);
|
||||
if (code !== 0) {
|
||||
throw new Error('FFmpeg process exited with code ' + code);
|
||||
}
|
||||
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
||||
writeFileSync(pcmPath, outputData);
|
||||
return Buffer.from(outputData);
|
||||
} catch (error: any) {
|
||||
throw new Error('FFmpeg处理转换出错: ' + error.message);
|
||||
} finally {
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(outputFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking output file:', unlinkError);
|
||||
}
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(inputFileName);
|
||||
} catch (unlinkError) {
|
||||
console.error('Error unlinking output file:', unlinkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
||||
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
||||
const inputFileName = `${randomUUID()}.${fileType}`;
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
||||
ffmpegInstance.setLogging(true);
|
||||
let duration = 60;
|
||||
ffmpegInstance.setLogger((level, ...msg) => {
|
||||
const message = msg.join(' ');
|
||||
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
||||
if (durationMatch) {
|
||||
const hours = parseInt(durationMatch[1], 10);
|
||||
const minutes = parseInt(durationMatch[2], 10);
|
||||
const seconds = parseFloat(durationMatch[3]);
|
||||
duration = hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
});
|
||||
await ffmpegInstance.run('-i', inputFileName);
|
||||
let image = imageSize(thumbnailPath);
|
||||
ffmpegInstance.fs.unlink(inputFileName);
|
||||
const fileSize = statSync(videoPath).size;
|
||||
return {
|
||||
width: image.width ?? 100,
|
||||
height: image.height ?? 100,
|
||||
time: duration,
|
||||
format: fileType,
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
}
|
||||
}
|
||||
}
|
||||
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||
|
||||
interface FFmpegTask {
|
||||
method: FFmpegMethod;
|
||||
args: any[];
|
||||
}
|
||||
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
||||
switch (method) {
|
||||
case 'extractThumbnail':
|
||||
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||
case 'convertFile':
|
||||
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||
case 'convert':
|
||||
return await FFmpegService.convert(...args as [string, string]);
|
||||
case 'getVideoInfo':
|
||||
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
}
|
50
src/common/ffmpeg.ts
Normal file
50
src/common/ffmpeg.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import Piscina from "piscina";
|
||||
import { VideoInfo } from "./video";
|
||||
import type { LogWrapper } from "./log";
|
||||
|
||||
type EncodeArgs = {
|
||||
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||
args: any[];
|
||||
};
|
||||
|
||||
type EncodeResult = any;
|
||||
|
||||
async function getWorkerPath() {
|
||||
return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href;
|
||||
}
|
||||
|
||||
export class FFmpegService {
|
||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||
filename: await getWorkerPath(),
|
||||
});
|
||||
await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
|
||||
await piscina.destroy();
|
||||
}
|
||||
|
||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||
filename: await getWorkerPath(),
|
||||
});
|
||||
await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] });
|
||||
await piscina.destroy();
|
||||
}
|
||||
|
||||
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||
filename: await getWorkerPath(),
|
||||
});
|
||||
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath] });
|
||||
await piscina.destroy();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||
filename: await getWorkerPath(),
|
||||
});
|
||||
const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
|
||||
await piscina.destroy();
|
||||
return result;
|
||||
}
|
||||
}
|
@@ -181,28 +181,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
|
||||
const filePath = path.join(dir, filename);
|
||||
|
||||
switch (UriType) {
|
||||
case FileUriType.Local: {
|
||||
const fileExt = path.extname(HandledUri);
|
||||
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
|
||||
const tempFilePath = path.join(dir, filename + fileExt);
|
||||
fs.copyFileSync(HandledUri, tempFilePath);
|
||||
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
|
||||
}
|
||||
case FileUriType.Local: {
|
||||
const fileExt = path.extname(HandledUri);
|
||||
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
|
||||
const tempFilePath = path.join(dir, filename + fileExt);
|
||||
fs.copyFileSync(HandledUri, tempFilePath);
|
||||
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
case FileUriType.Base64: {
|
||||
const base64 = HandledUri.replace(/^base64:\/\//, '');
|
||||
const base64Buffer = Buffer.from(base64, 'base64');
|
||||
fs.writeFileSync(filePath, base64Buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
case FileUriType.Base64: {
|
||||
const base64 = HandledUri.replace(/^base64:\/\//, '');
|
||||
const base64Buffer = Buffer.from(base64, 'base64');
|
||||
fs.writeFileSync(filePath, base64Buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
|
||||
default:
|
||||
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
|
||||
}
|
||||
}
|
||||
|
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.4.4';
|
||||
export const napCatVersion = '4.4.16';
|
||||
|
File diff suppressed because one or more lines are too long
@@ -22,11 +22,11 @@ import { ISizeCalculationResult } from 'image-size/dist/types/interface';
|
||||
import { RkeyManager } from '@/core/helper/rkey';
|
||||
import { calculateFileMD5 } from '@/common/file';
|
||||
import pathLib from 'node:path';
|
||||
import { defaultVideoThumbB64, getVideoInfo } from '@/common/video';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { defaultVideoThumbB64 } from '@/common/video';
|
||||
import { encodeSilk } from '@/common/audio';
|
||||
import { SendMessageContext } from '@/onebot/api';
|
||||
import { getFileTypeForSendType } from '../helper/msg';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
|
||||
export class NTQQFileApi {
|
||||
context: InstanceContext;
|
||||
@@ -40,7 +40,7 @@ export class NTQQFileApi {
|
||||
this.rkeyManager = new RkeyManager([
|
||||
'https://rkey.napneko.icu/rkeys'
|
||||
],
|
||||
this.context.logger
|
||||
this.context.logger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,12 +149,6 @@ export class NTQQFileApi {
|
||||
size: 0,
|
||||
filePath,
|
||||
};
|
||||
try {
|
||||
videoInfo = await getVideoInfo(filePath, this.context.logger);
|
||||
} catch (e) {
|
||||
this.context.logger.logError('获取视频信息失败,将使用默认值', e);
|
||||
}
|
||||
|
||||
let fileExt = 'mp4';
|
||||
try {
|
||||
const tempExt = (await fileTypeFromFile(filePath))?.ext;
|
||||
@@ -162,53 +156,29 @@ export class NTQQFileApi {
|
||||
} catch (e) {
|
||||
this.context.logger.logError('获取文件类型失败', e);
|
||||
}
|
||||
const newFilePath = filePath + '.' + fileExt;
|
||||
const newFilePath = `${filePath}.${fileExt}`;
|
||||
fs.copyFileSync(filePath, newFilePath);
|
||||
context.deleteAfterSentFiles.push(newFilePath);
|
||||
filePath = newFilePath;
|
||||
|
||||
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
|
||||
if (fileSize === 0) {
|
||||
throw new Error('文件异常,大小为0');
|
||||
}
|
||||
videoInfo.size = fileSize;
|
||||
let thumb = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
|
||||
thumb = pathLib.dirname(thumb);
|
||||
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
|
||||
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
|
||||
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
|
||||
try {
|
||||
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
}
|
||||
|
||||
const thumbPath = new Map();
|
||||
const _thumbPath = await new Promise<string | undefined>((resolve, reject) => {
|
||||
const thumbFileName = `${md5}_0.png`;
|
||||
const thumbPath = pathLib.join(thumb, thumbFileName);
|
||||
ffmpeg(filePath)
|
||||
.on('error', (err) => {
|
||||
try {
|
||||
this.context.logger.logDebug('获取视频封面失败,使用默认封面', err);
|
||||
if (diyThumbPath) {
|
||||
fsPromises.copyFile(diyThumbPath, thumbPath).then(() => {
|
||||
resolve(thumbPath);
|
||||
}).catch(reject);
|
||||
} else {
|
||||
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
resolve(thumbPath);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('获取视频封面失败,使用默认封面失败', error);
|
||||
}
|
||||
})
|
||||
.screenshots({
|
||||
timestamps: [0],
|
||||
filename: thumbFileName,
|
||||
folder: thumb,
|
||||
size: videoInfo.width + 'x' + videoInfo.height,
|
||||
})
|
||||
.on('end', () => {
|
||||
resolve(thumbPath);
|
||||
});
|
||||
});
|
||||
const thumbSize = _thumbPath ? (await fsPromises.stat(_thumbPath)).size : 0;
|
||||
thumbPath.set(0, _thumbPath);
|
||||
const thumbMd5 = _thumbPath ? await calculateFileMD5(_thumbPath) : '';
|
||||
const thumbSize = (await fsPromises.stat(thumbPath)).size;
|
||||
const thumbMd5 = await calculateFileMD5(thumbPath);
|
||||
context.deleteAfterSentFiles.push(path);
|
||||
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith('.' + fileExt.toLocaleLowerCase()) ? (fileName || _fileName) : (fileName || _fileName) + '.' + fileExt;
|
||||
|
||||
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`;
|
||||
return {
|
||||
elementType: ElementType.VIDEO,
|
||||
elementId: '',
|
||||
@@ -218,15 +188,14 @@ export class NTQQFileApi {
|
||||
videoMd5: md5,
|
||||
thumbMd5,
|
||||
fileTime: videoInfo.time,
|
||||
thumbPath: thumbPath,
|
||||
thumbPath: new Map([[0, thumbPath]]),
|
||||
thumbSize,
|
||||
thumbWidth: videoInfo.width,
|
||||
thumbHeight: videoInfo.height,
|
||||
fileSize: '' + fileSize,
|
||||
fileSize: fileSize.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createValidSendPttElement(pttPath: string): Promise<SendPttElement> {
|
||||
|
||||
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
|
||||
@@ -305,18 +274,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++;
|
||||
}
|
||||
|
@@ -67,7 +67,7 @@ export class NTQQGroupApi {
|
||||
1,
|
||||
1000
|
||||
).then((data) => {
|
||||
resolve(data[1])
|
||||
resolve(data[1]);
|
||||
}).catch(reject);
|
||||
|
||||
onCancel(() => {
|
||||
@@ -78,9 +78,9 @@ export class NTQQGroupApi {
|
||||
const task = new CancelableTask(executor);
|
||||
this.context.session.getGroupService().getGroupShutUpMemberList(groupCode).then(e => {
|
||||
if (e.result !== 0) {
|
||||
task.cancel()
|
||||
task.cancel();
|
||||
}
|
||||
})
|
||||
});
|
||||
return await task.catch(() => []);
|
||||
}
|
||||
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import { decode } from 'silk-wasm';
|
||||
const FFMPEG_PATH = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
|
||||
const out_format = ['mp3' , 'amr' , 'wma' , 'm4a' , 'spx' , 'ogg' , 'wav' , 'flac'];
|
||||
|
||||
@@ -30,7 +29,7 @@ export default class GetRecord extends GetFileBase {
|
||||
await fs.access(outputFile);
|
||||
} catch (error) {
|
||||
await this.decodeFile(inputFile, pcmFile);
|
||||
await this.convertFile(pcmFile, outputFile, payload.out_format);
|
||||
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
|
||||
}
|
||||
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
|
||||
res.file = outputFile;
|
||||
@@ -54,23 +53,4 @@ export default class GetRecord extends GetFileBase {
|
||||
throw error; // 重新抛出错误以便调用者可以处理
|
||||
}
|
||||
}
|
||||
|
||||
private convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const params = format === 'amr' ? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, '-ar', '8000', '-b:a', '12.2k', outputFile] : ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, outputFile];
|
||||
const ffmpeg = spawn(FFMPEG_PATH, params);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -905,16 +905,16 @@ export class OneBotMsgApi {
|
||||
const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => {
|
||||
const sizePromises = elements.map(async element => {
|
||||
switch (element.elementType) {
|
||||
case ElementType.PTT:
|
||||
return (await fsPromise.stat(element.pttElement.filePath)).size;
|
||||
case ElementType.FILE:
|
||||
return (await fsPromise.stat(element.fileElement.filePath)).size;
|
||||
case ElementType.VIDEO:
|
||||
return (await fsPromise.stat(element.videoElement.filePath)).size;
|
||||
case ElementType.PIC:
|
||||
return (await fsPromise.stat(element.picElement.sourcePath)).size;
|
||||
default:
|
||||
return 0;
|
||||
case ElementType.PTT:
|
||||
return (await fsPromise.stat(element.pttElement.filePath)).size;
|
||||
case ElementType.FILE:
|
||||
return (await fsPromise.stat(element.fileElement.filePath)).size;
|
||||
case ElementType.VIDEO:
|
||||
return (await fsPromise.stat(element.videoElement.filePath)).size;
|
||||
case ElementType.PIC:
|
||||
return (await fsPromise.stat(element.picElement.sourcePath)).size;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
const sizes = await Promise.all(sizePromises);
|
||||
@@ -1007,14 +1007,14 @@ export class OneBotMsgApi {
|
||||
}
|
||||
groupChangDecreseType2String(type: number): GroupDecreaseSubType {
|
||||
switch (type) {
|
||||
case 130:
|
||||
return 'leave';
|
||||
case 131:
|
||||
return 'kick';
|
||||
case 3:
|
||||
return 'kick_me';
|
||||
default:
|
||||
return 'kick';
|
||||
case 130:
|
||||
return 'leave';
|
||||
case 131:
|
||||
return 'kick';
|
||||
case 3:
|
||||
return 'kick_me';
|
||||
default:
|
||||
return 'kick';
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -98,7 +98,7 @@ export type NetworkConfigKey = keyof OneBotConfig['network'];
|
||||
|
||||
|
||||
export function loadConfig(config: Partial<OneBotConfig>): OneBotConfig {
|
||||
const ajv = new Ajv({ useDefaults: true });
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(OneBotConfigSchema);
|
||||
const valid = validate(config);
|
||||
if (!valid) {
|
||||
|
@@ -7,6 +7,7 @@ import { RequestUtil } from '@/common/request';
|
||||
import { HttpClientConfig } from '@/onebot/config/config';
|
||||
import { ActionMap } from '@/onebot/action';
|
||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig> {
|
||||
constructor(
|
||||
@@ -34,7 +35,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
|
||||
}
|
||||
|
||||
const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers);
|
||||
const resJson: QuickAction = data ? JSON.parse(data) : {};
|
||||
const resJson: QuickAction = data ? json5.parse(data) : {};
|
||||
|
||||
await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson);
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import cors from 'cors';
|
||||
import { HttpServerConfig } from '@/onebot/config/config';
|
||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
@@ -52,12 +53,20 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
this.app.use((req, res, next) => {
|
||||
// 兼容处理没有带content-type的请求
|
||||
req.headers['content-type'] = 'application/json';
|
||||
const originalJson = express.json({ limit: '5000mb' });
|
||||
originalJson(req, res, (err) => {
|
||||
if (err) {
|
||||
let rawData = '';
|
||||
req.on('data', (chunk) => {
|
||||
rawData += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
req.body = json5.parse(rawData || '{}');
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(400).send('Invalid JSON');
|
||||
}
|
||||
next();
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
return res.status(400).send('Invalid JSON');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +100,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
}
|
||||
if (req.path === '' || req.path === '/') {
|
||||
const hello = OB11Response.ok({});
|
||||
hello.message = 'NapCat4 Ss Running';
|
||||
hello.message = 'NapCat4 Is Running';
|
||||
return res.json(hello);
|
||||
}
|
||||
const actionName = req.path.split('/')[1];
|
||||
|
@@ -9,6 +9,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
|
||||
import { WebsocketClientConfig } from '@/onebot/config/config';
|
||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
|
||||
private connection: WebSocket | null = null;
|
||||
@@ -129,7 +130,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
let echo = undefined;
|
||||
|
||||
try {
|
||||
receiveData = JSON.parse(message.toString());
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
||||
} catch (e) {
|
||||
|
@@ -12,6 +12,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
|
||||
import { WebsocketServerConfig } from '@/onebot/config/config';
|
||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||
import json5 from 'json5';
|
||||
|
||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
wsServer: WebSocketServer;
|
||||
@@ -162,7 +163,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
|
||||
let echo = undefined;
|
||||
try {
|
||||
receiveData = JSON.parse(message.toString());
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
||||
} catch (e) {
|
||||
|
@@ -29,6 +29,7 @@ import { InitWebUi } from '@/webui';
|
||||
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
|
||||
import { napCatVersion } from '@/common/version';
|
||||
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions(logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
|
@@ -1,3 +1,3 @@
|
||||
import { NCoreInitShell } from "./base";
|
||||
|
||||
NCoreInitShell();
|
||||
NCoreInitShell();
|
@@ -62,3 +62,19 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, 'Authorization Faild');
|
||||
}
|
||||
};
|
||||
|
||||
// 修改密码(token)
|
||||
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||
const { oldToken, newToken } = req.body;
|
||||
|
||||
if (isEmpty(oldToken) || isEmpty(newToken)) {
|
||||
return sendError(res, 'oldToken or newToken is empty');
|
||||
}
|
||||
|
||||
try {
|
||||
await WebUiConfig.UpdateToken(oldToken, newToken);
|
||||
return sendSuccess(res, 'Token updated successfully');
|
||||
} catch (e: any) {
|
||||
return sendError(res, `Failed to update token: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
@@ -53,6 +53,6 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiDataRuntime.setOB11Config(JSON.parse(req.body.config));
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error');
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
|
14
src/webui/src/api/Proxy.ts
Normal file
14
src/webui/src/api/Proxy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { RequestUtil } from "@/common/request";
|
||||
import { sendError, sendSuccess } from "../utils/response";
|
||||
|
||||
export const GetProxyHandler: RequestHandler = async (req, res) => {
|
||||
let { url } = req.query;
|
||||
if (url && typeof url === "string") {
|
||||
url = decodeURIComponent(url);
|
||||
const responseText = await RequestUtil.HttpGetText(url);
|
||||
res.send(sendSuccess(res, responseText));
|
||||
} else {
|
||||
res.send(sendError(res, 'url参数不合法'));
|
||||
}
|
||||
};
|
@@ -68,7 +68,15 @@ export class WebUiConfigWrapper {
|
||||
WebUiConfigData: WebUiConfigType | undefined = undefined;
|
||||
|
||||
private applyDefaults<T>(obj: Partial<T>, defaults: T): T {
|
||||
return { ...defaults, ...obj };
|
||||
const result = { ...defaults } as T;
|
||||
for (const key in obj) {
|
||||
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
result[key] = this.applyDefaults(obj[key], defaults[key]);
|
||||
} else if (obj[key] !== undefined) {
|
||||
result[key] = obj[key] as T[Extract<keyof T, string>];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async GetWebUIConfig(): Promise<WebUiConfigType> {
|
||||
@@ -99,10 +107,8 @@ export class WebUiConfigWrapper {
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(configPath, 'utf-8');
|
||||
// 更新配置字段后新增字段可能会缺失,同步一下
|
||||
const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial<WebUiConfigType>, defaultconfig);
|
||||
|
||||
// 配置已经被操作过了,还是回写一下吧,不然新配置不会出现在配置文件里
|
||||
if (
|
||||
await fs
|
||||
.access(configPath, constants.W_OK)
|
||||
@@ -113,9 +119,7 @@ export class WebUiConfigWrapper {
|
||||
} else {
|
||||
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
|
||||
}
|
||||
// 不希望回写的配置放后面
|
||||
|
||||
// 查询主机地址是否可用
|
||||
const [host_err, host] = await tryUseHost(parsedConfig.host)
|
||||
.then((data) => [null, data])
|
||||
.catch((err) => [err, null]);
|
||||
@@ -124,7 +128,6 @@ export class WebUiConfigWrapper {
|
||||
parsedConfig.port = 0; // 设置为0,禁用WebUI
|
||||
} else {
|
||||
parsedConfig.host = host;
|
||||
// 修正端口占用情况
|
||||
const [port_err, port] = await tryUsePort(parsedConfig.port, parsedConfig.host)
|
||||
.then((data) => [null, data])
|
||||
.catch((err) => [err, null]);
|
||||
@@ -143,6 +146,32 @@ export class WebUiConfigWrapper {
|
||||
return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了
|
||||
}
|
||||
|
||||
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
|
||||
const currentConfig = await this.GetWebUIConfig();
|
||||
const updatedConfig = this.applyDefaults(newConfig, currentConfig);
|
||||
|
||||
if (
|
||||
await fs
|
||||
.access(configPath, constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 4));
|
||||
this.WebUiConfigData = updatedConfig;
|
||||
} else {
|
||||
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
|
||||
}
|
||||
}
|
||||
|
||||
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
|
||||
const currentConfig = await this.GetWebUIConfig();
|
||||
if (currentConfig.token !== oldToken) {
|
||||
throw new Error('旧 token 不匹配');
|
||||
}
|
||||
await this.UpdateWebUIConfig({ token: newToken });
|
||||
}
|
||||
|
||||
// 获取日志文件夹路径
|
||||
public static async GetLogsPath(): Promise<string> {
|
||||
return resolve(webUiPathWrapper.logsPath);
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { PackageInfoHandler, QQVersionHandler } from '../api/BaseInfo';
|
||||
import { StatusRealTimeHandler } from "@webapi/api/Status";
|
||||
import { GetProxyHandler } from '../api/Proxy';
|
||||
|
||||
const router = Router();
|
||||
// router: 获取nc的package.json信息
|
||||
router.get('/QQVersion', QQVersionHandler);
|
||||
router.get('/PackageInfo', PackageInfoHandler);
|
||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||
|
||||
router.get('/proxy', GetProxyHandler);
|
||||
export { router as BaseRouter };
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { checkHandler, LoginHandler, LogoutHandler } from '@webapi/api/Auth';
|
||||
import { checkHandler, LoginHandler, LogoutHandler, UpdateTokenHandler } from '@webapi/api/Auth';
|
||||
|
||||
const router = Router();
|
||||
// router:登录
|
||||
@@ -9,5 +9,7 @@ router.post('/login', LoginHandler);
|
||||
router.post('/check', checkHandler);
|
||||
// router:注销
|
||||
router.post('/logout', LogoutHandler);
|
||||
// router:更新token
|
||||
router.post('/update_token', UpdateTokenHandler);
|
||||
|
||||
export { router as AuthRouter };
|
||||
|
@@ -4,7 +4,7 @@ import { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
//依赖排除
|
||||
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'fluent-ffmpeg', 'piscina'];
|
||||
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'piscina', '@ffmpeg.wasm/core-mt', "@ffmpeg.wasm/main"];
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
let startScripts: string[] | undefined = undefined;
|
||||
@@ -79,7 +79,6 @@ const UniversalBaseConfig = () =>
|
||||
alias: {
|
||||
'@/core': resolve(__dirname, './src/core'),
|
||||
'@': resolve(__dirname, './src'),
|
||||
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
|
||||
'@webapi': resolve(__dirname, './src/webui/src'),
|
||||
},
|
||||
},
|
||||
@@ -91,6 +90,7 @@ const UniversalBaseConfig = () =>
|
||||
entry: {
|
||||
napcat: 'src/universal/napcat.ts',
|
||||
'audio-worker': 'src/common/audio-worker.ts',
|
||||
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
@@ -109,7 +109,6 @@ const ShellBaseConfig = () =>
|
||||
alias: {
|
||||
'@/core': resolve(__dirname, './src/core'),
|
||||
'@': resolve(__dirname, './src'),
|
||||
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
|
||||
'@webapi': resolve(__dirname, './src/webui/src'),
|
||||
},
|
||||
},
|
||||
@@ -121,6 +120,7 @@ const ShellBaseConfig = () =>
|
||||
entry: {
|
||||
napcat: 'src/shell/napcat.ts',
|
||||
'audio-worker': 'src/common/audio-worker.ts',
|
||||
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
@@ -138,7 +138,6 @@ const FrameworkBaseConfig = () =>
|
||||
alias: {
|
||||
'@/core': resolve(__dirname, './src/core'),
|
||||
'@': resolve(__dirname, './src'),
|
||||
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
|
||||
'@webapi': resolve(__dirname, './src/webui/src'),
|
||||
},
|
||||
},
|
||||
@@ -150,6 +149,7 @@ const FrameworkBaseConfig = () =>
|
||||
entry: {
|
||||
napcat: 'src/framework/napcat.ts',
|
||||
'audio-worker': 'src/common/audio-worker.ts',
|
||||
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
|
Reference in New Issue
Block a user