mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b08a29897f | ||
![]() |
b59c1d9122 | ||
![]() |
adb9cea701 | ||
![]() |
5e148d2e82 | ||
![]() |
a0d780558e | ||
![]() |
ad56065a4e | ||
![]() |
f5dee80b6e | ||
![]() |
9cc75881b8 | ||
![]() |
593fb13b61 | ||
![]() |
fca90592d6 | ||
![]() |
d6848e2855 | ||
![]() |
7539a4129f | ||
![]() |
5402574266 | ||
![]() |
853175aa1a | ||
![]() |
feb84809ec | ||
![]() |
a812c568e4 | ||
![]() |
11db25e355 | ||
![]() |
ecd2fba629 | ||
![]() |
a6763cf5a1 | ||
![]() |
c9e91a9b94 | ||
![]() |
43fb62c5bd | ||
![]() |
cb8727d487 | ||
![]() |
a94e03e2fd | ||
![]() |
425c3c6432 | ||
![]() |
89b9610016 | ||
![]() |
62fe88f868 | ||
![]() |
11a7f5fade | ||
![]() |
fbde997f7c | ||
![]() |
26734a35ef | ||
![]() |
715c4ac534 | ||
![]() |
bd4b0885a1 | ||
![]() |
e3c7af3d91 | ||
![]() |
a7ee21bfd8 | ||
![]() |
d0f51d92ac | ||
![]() |
e6dc148ea2 | ||
![]() |
514ab6637f | ||
![]() |
377794abe8 | ||
![]() |
0f3251f35b | ||
![]() |
8002dc5bc5 | ||
![]() |
c75a13dcf4 | ||
![]() |
91d153bb9d |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -5,5 +5,6 @@
|
|||||||
".env.universal": ".env.*",
|
".env.universal": ".env.*",
|
||||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||||
}
|
},
|
||||||
|
"css.customData": [".vscode/tailwindcss.json"],
|
||||||
}
|
}
|
55
.vscode/tailwindcss.json
vendored
Normal file
55
.vscode/tailwindcss.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"version": 1.1,
|
||||||
|
"atDirectives": [
|
||||||
|
{
|
||||||
|
"name": "@tailwind",
|
||||||
|
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@apply",
|
||||||
|
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@responsive",
|
||||||
|
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@screen",
|
||||||
|
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@variants",
|
||||||
|
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.4.20",
|
"version": "4.5.9",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
@@ -32,6 +32,7 @@
|
|||||||
"@heroui/pagination": "^2.2.9",
|
"@heroui/pagination": "^2.2.9",
|
||||||
"@heroui/popover": "2.3.10",
|
"@heroui/popover": "2.3.10",
|
||||||
"@heroui/select": "2.4.10",
|
"@heroui/select": "2.4.10",
|
||||||
|
"@heroui/skeleton": "^2.2.6",
|
||||||
"@heroui/slider": "2.4.8",
|
"@heroui/slider": "2.4.8",
|
||||||
"@heroui/snippet": "2.2.11",
|
"@heroui/snippet": "2.2.11",
|
||||||
"@heroui/spinner": "2.2.7",
|
"@heroui/spinner": "2.2.7",
|
||||||
@@ -46,9 +47,9 @@
|
|||||||
"@react-aria/visually-hidden": "^3.8.19",
|
"@react-aria/visually-hidden": "^3.8.19",
|
||||||
"@reduxjs/toolkit": "^2.5.1",
|
"@reduxjs/toolkit": "^2.5.1",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-photo-view": "^1.2.7",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.0",
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "^7.1.4",
|
"react-router-dom": "^7.1.4",
|
||||||
|
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,16 @@ import store from '@/store'
|
|||||||
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
||||||
const IndexPage = lazy(() => import('@/pages/index'))
|
const IndexPage = lazy(() => import('@/pages/index'))
|
||||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
||||||
|
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
|
||||||
|
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
|
||||||
|
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
|
||||||
|
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
|
||||||
|
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
|
||||||
|
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
|
||||||
|
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
|
||||||
|
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
|
||||||
|
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
|
||||||
|
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
|
|||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<IndexPage />} path="/*" />
|
<Route path="/" element={<IndexPage />}>
|
||||||
<Route element={<QQLoginPage />} path="/qq_login" />
|
<Route index element={<DashboardIndexPage />} />
|
||||||
<Route element={<WebLoginPage />} path="/web_login" />
|
<Route path="network" element={<NetworkPage />} />
|
||||||
|
<Route path="config" element={<ConfigPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
<Route path="debug" element={<DebugPage />}>
|
||||||
|
<Route path="ws" element={<WSDebug />} />
|
||||||
|
<Route path="http" element={<HttpDebug />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="file_manager" element={<FileManagerPage />} />
|
||||||
|
<Route path="terminal" element={<TerminalPage />} />
|
||||||
|
<Route path="about" element={<AboutPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/qq_login" element={<QQLoginPage />} />
|
||||||
|
<Route path="/web_login" element={<WebLoginPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -231,7 +231,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
|
|||||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||||
)}
|
)}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||||
>
|
>
|
||||||
|
@@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
startContent={<IoAddCircleOutline className="text-2xl" />}
|
startContent={<IoAddCircleOutline className="text-2xl" />}
|
||||||
>
|
>
|
||||||
新建
|
新建
|
||||||
|
@@ -27,7 +27,7 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
|||||||
取消更改
|
取消更改
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
onPress={() => onSubmit()}
|
onPress={() => onSubmit()}
|
||||||
>
|
>
|
||||||
|
@@ -110,7 +110,7 @@ const AudioInsert = () => {
|
|||||||
<Tooltip content="发送音频">
|
<Tooltip content="发送音频">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<IoMic className="text-xl" />
|
<IoMic className="text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -120,7 +120,7 @@ const AudioInsert = () => {
|
|||||||
<Tooltip content="上传音频">
|
<Tooltip content="上传音频">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -137,7 +137,7 @@ const AudioInsert = () => {
|
|||||||
<PopoverTrigger tooltip="输入音频地址">
|
<PopoverTrigger tooltip="输入音频地址">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -154,7 +154,7 @@ const AudioInsert = () => {
|
|||||||
placeholder="请输入音频地址"
|
placeholder="请输入音频地址"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -177,7 +177,7 @@ const AudioInsert = () => {
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -190,7 +190,7 @@ const AudioInsert = () => {
|
|||||||
<PopoverContent className="flex-col gap-2 p-4">
|
<PopoverContent className="flex-col gap-2 p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
color={isRecording ? 'danger' : 'danger'}
|
color={isRecording ? 'primary' : 'primary'}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={isRecording ? stopRecording : startRecording}
|
onPress={isRecording ? stopRecording : startRecording}
|
||||||
>
|
>
|
||||||
@@ -198,7 +198,7 @@ const AudioInsert = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
{showPreview && audioPreview && (
|
{showPreview && audioPreview && (
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={handleShowPreview}
|
onPress={handleShowPreview}
|
||||||
>
|
>
|
||||||
@@ -212,7 +212,7 @@ const AudioInsert = () => {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'w-4 h-4 rounded-full',
|
'w-4 h-4 rounded-full',
|
||||||
isRecording
|
isRecording
|
||||||
? 'animate-pulse bg-danger-400'
|
? 'animate-pulse bg-primary-400'
|
||||||
: 'bg-success-400'
|
: 'bg-success-400'
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
|
@@ -10,7 +10,7 @@ const DiceInsert = () => {
|
|||||||
return (
|
return (
|
||||||
<Tooltip content="发送骰子">
|
<Tooltip content="发送骰子">
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -55,7 +55,7 @@ const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
|
|||||||
<Tooltip content="插入表情">
|
<Tooltip content="插入表情">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<MdEmojiEmotions className="text-xl" />
|
<MdEmojiEmotions className="text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -65,7 +65,7 @@ const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
|
|||||||
{visibleEmojis.map((emoji) => (
|
{visibleEmojis.map((emoji) => (
|
||||||
<Button
|
<Button
|
||||||
key={emoji.id}
|
key={emoji.id}
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -35,7 +35,7 @@ const FileInsert = () => {
|
|||||||
<Tooltip content="发送文件">
|
<Tooltip content="发送文件">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<FaFolder className="text-lg" />
|
<FaFolder className="text-lg" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -45,7 +45,7 @@ const FileInsert = () => {
|
|||||||
<Tooltip content="上传文件">
|
<Tooltip content="上传文件">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -62,7 +62,7 @@ const FileInsert = () => {
|
|||||||
<PopoverTrigger tooltip="输入文件地址">
|
<PopoverTrigger tooltip="输入文件地址">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -79,7 +79,7 @@ const FileInsert = () => {
|
|||||||
placeholder="请输入文件地址"
|
placeholder="请输入文件地址"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -23,7 +23,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
|||||||
<Tooltip content="插入图片">
|
<Tooltip content="插入图片">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<MdImage className="text-xl" />
|
<MdImage className="text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -33,7 +33,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
|||||||
<Tooltip content="上传图片">
|
<Tooltip content="上传图片">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -50,7 +50,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
|||||||
<PopoverTrigger tooltip="输入图片地址">
|
<PopoverTrigger tooltip="输入图片地址">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -67,7 +67,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
|||||||
placeholder="请输入图片地址"
|
placeholder="请输入图片地址"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -80,7 +80,7 @@ const MusicInsert = () => {
|
|||||||
<Tooltip content="发送音乐">
|
<Tooltip content="发送音乐">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<IoMusicalNotes className="text-xl" />
|
<IoMusicalNotes className="text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -132,7 +132,7 @@ const MusicInsert = () => {
|
|||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -236,7 +236,7 @@ const MusicInsert = () => {
|
|||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@@ -19,7 +19,7 @@ const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
|||||||
<Tooltip content="回复消息">
|
<Tooltip content="回复消息">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<BsChatQuoteFill className="text-lg" />
|
<BsChatQuoteFill className="text-lg" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -38,7 +38,7 @@ const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
|
@@ -10,7 +10,7 @@ const RPSInsert = () => {
|
|||||||
return (
|
return (
|
||||||
<Tooltip content="发送猜拳">
|
<Tooltip content="发送猜拳">
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -35,7 +35,7 @@ const VideoInsert = () => {
|
|||||||
<Tooltip content="发送视频">
|
<Tooltip content="发送视频">
|
||||||
<div className="max-w-fit">
|
<div className="max-w-fit">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||||
<IoVideocam className="text-xl" />
|
<IoVideocam className="text-xl" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -45,7 +45,7 @@ const VideoInsert = () => {
|
|||||||
<Tooltip content="上传视频">
|
<Tooltip content="上传视频">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -62,7 +62,7 @@ const VideoInsert = () => {
|
|||||||
<PopoverTrigger tooltip="输入视频地址">
|
<PopoverTrigger tooltip="输入视频地址">
|
||||||
<Button
|
<Button
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
color="danger"
|
color="primary"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
@@ -79,7 +79,7 @@ const VideoInsert = () => {
|
|||||||
placeholder="请输入视频地址"
|
placeholder="请输入视频地址"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -190,7 +190,7 @@ const ChatInput = () => {
|
|||||||
<DiceInsert />
|
<DiceInsert />
|
||||||
<RPSInsert />
|
<RPSInsert />
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const messages = getChatMessage()
|
const messages = getChatMessage()
|
||||||
showStructuredMessage(messages)
|
showStructuredMessage(messages)
|
||||||
|
@@ -15,7 +15,7 @@ export default function ChatInputModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
|
||||||
构造聊天消息
|
构造聊天消息
|
||||||
</Button>
|
</Button>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -36,7 +36,7 @@ export default function ChatInputModal() {
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" onPress={onClose} variant="flat">
|
<Button color="primary" onPress={onClose} variant="flat">
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -78,7 +78,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
|
|||||||
{debug ? '关闭调试' : '开启调试'}
|
{debug ? '关闭调试' : '开启调试'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
startContent={<MdDeleteForever />}
|
startContent={<MdDeleteForever />}
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
>
|
>
|
||||||
|
@@ -19,7 +19,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||||
size === 'md'
|
size === 'md'
|
||||||
? 'col-span-8 md:col-span-2 bg-danger-50 shadow-danger-100'
|
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
|
||||||
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||||
)}
|
)}
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
@@ -27,7 +27,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
|||||||
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'font-outfit flex-1',
|
'flex-1',
|
||||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||||
title({
|
title({
|
||||||
color: size === 'md' ? 'pink' : 'yellow',
|
color: size === 'md' ? 'pink' : 'yellow',
|
||||||
|
@@ -33,7 +33,7 @@ export default function CreateFileModal({
|
|||||||
<ModalHeader>新建</ModalHeader>
|
<ModalHeader>新建</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<ButtonGroup color="danger">
|
<ButtonGroup color="primary">
|
||||||
<Button
|
<Button
|
||||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||||
onPress={() => onTypeChange('file')}
|
onPress={() => onTypeChange('file')}
|
||||||
@@ -51,10 +51,10 @@ export default function CreateFileModal({
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="danger" onPress={onCreate}>
|
<Button color="primary" onPress={onCreate}>
|
||||||
创建
|
创建
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -81,10 +81,10 @@ export default function FileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="danger" onPress={onSave}>
|
<Button color="primary" onPress={onSave}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import FileManager from '@/controllers/file_manager'
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
@@ -18,11 +19,10 @@ interface FilePreviewModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
export const videoExts = ['.mp4', '.webm']
|
||||||
const videoExts = ['.mp4', '.webm']
|
export const audioExts = ['.mp3', '.wav']
|
||||||
const audioExts = ['.mp3', '.wav']
|
|
||||||
|
|
||||||
const supportedPreviewExts = [...imageExts, ...videoExts, ...audioExts]
|
export const supportedPreviewExts = [...videoExts, ...audioExts]
|
||||||
|
|
||||||
export default function FilePreviewModal({
|
export default function FilePreviewModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -31,19 +31,26 @@ export default function FilePreviewModal({
|
|||||||
}: FilePreviewModalProps) {
|
}: FilePreviewModalProps) {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
const { data, loading, error, run } = useRequest(
|
const { data, loading, error, run } = useRequest(
|
||||||
async (path: string) => FileManager.downloadToURL(path),
|
async () => FileManager.downloadToURL(filePath),
|
||||||
{
|
{
|
||||||
refreshDeps: [filePath],
|
refreshDeps: [filePath],
|
||||||
|
manual: true,
|
||||||
refreshDepsAction: () => {
|
refreshDepsAction: () => {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
run(filePath)
|
run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}, [filePath])
|
||||||
|
|
||||||
let contentElement = null
|
let contentElement = null
|
||||||
if (!supportedPreviewExts.includes(ext)) {
|
if (!supportedPreviewExts.includes(ext)) {
|
||||||
contentElement = <div>暂不支持预览此文件类型</div>
|
contentElement = <div>暂不支持预览此文件类型</div>
|
||||||
@@ -55,27 +62,27 @@ export default function FilePreviewModal({
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (imageExts.includes(ext)) {
|
|
||||||
contentElement = (
|
|
||||||
<img src={data} alt="预览" className="max-w-full max-h-96" />
|
|
||||||
)
|
|
||||||
} else if (videoExts.includes(ext)) {
|
} else if (videoExts.includes(ext)) {
|
||||||
contentElement = (
|
contentElement = <video src={data} controls className="max-w-full" />
|
||||||
<video src={data} controls className="max-w-full max-h-96" />
|
|
||||||
)
|
|
||||||
} else if (audioExts.includes(ext)) {
|
} else if (audioExts.includes(ext)) {
|
||||||
contentElement = <audio src={data} controls className="w-full" />
|
contentElement = <audio src={data} controls className="w-full" />
|
||||||
|
} else {
|
||||||
|
contentElement = (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
|
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>文件预览</ModalHeader>
|
<ModalHeader>文件预览</ModalHeader>
|
||||||
<ModalBody className="flex justify-center items-center">
|
<ModalBody className="flex justify-center items-center">
|
||||||
{contentElement}
|
{contentElement}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -12,15 +12,19 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from '@heroui/table'
|
} from '@heroui/table'
|
||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { BiRename } from 'react-icons/bi'
|
import { BiRename } from 'react-icons/bi'
|
||||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
||||||
|
import { PhotoSlider } from 'react-photo-view'
|
||||||
|
|
||||||
import FileIcon from '@/components/file_icon'
|
import FileIcon from '@/components/file_icon'
|
||||||
|
|
||||||
import type { FileInfo } from '@/controllers/file_manager'
|
import type { FileInfo } from '@/controllers/file_manager'
|
||||||
|
|
||||||
interface FileTableProps {
|
import { supportedPreviewExts } from './file_preview_modal'
|
||||||
|
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
|
||||||
|
|
||||||
|
export interface FileTableProps {
|
||||||
files: FileInfo[]
|
files: FileInfo[]
|
||||||
currentPath: string
|
currentPath: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -58,147 +62,184 @@ export default function FileTable({
|
|||||||
onDownload
|
onDownload
|
||||||
}: FileTableProps) {
|
}: FileTableProps) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const pages = Math.ceil(files.length / PAGE_SIZE)
|
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
|
||||||
const start = (page - 1) * PAGE_SIZE
|
const start = (page - 1) * PAGE_SIZE
|
||||||
const end = start + PAGE_SIZE
|
const end = start + PAGE_SIZE
|
||||||
const displayFiles = files.slice(start, end)
|
const displayFiles = files.slice(start, end)
|
||||||
|
const [showImage, setShowImage] = useState(false)
|
||||||
|
const [previewIndex, setPreviewIndex] = useState(0)
|
||||||
|
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||||
|
|
||||||
|
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||||
|
setPreviewImages((prev) => {
|
||||||
|
const exists = prev.some((p) => p.key === image.key)
|
||||||
|
if (exists) return prev
|
||||||
|
return [...prev, image]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviewImages([])
|
||||||
|
setPreviewIndex(0)
|
||||||
|
setShowImage(false)
|
||||||
|
}, [currentPath])
|
||||||
|
|
||||||
|
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||||
|
const index = images.findIndex((image) => image.key === name)
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewIndex(index)
|
||||||
|
setShowImage(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<>
|
||||||
aria-label="文件列表"
|
<PhotoSlider
|
||||||
sortDescriptor={sortDescriptor}
|
images={previewImages}
|
||||||
onSortChange={onSortChange}
|
visible={showImage}
|
||||||
onSelectionChange={onSelectionChange}
|
onClose={() => setShowImage(false)}
|
||||||
defaultSelectedKeys={[]}
|
index={previewIndex}
|
||||||
selectedKeys={selectedFiles}
|
onIndexChange={setPreviewIndex}
|
||||||
selectionMode="multiple"
|
/>
|
||||||
bottomContent={
|
<Table
|
||||||
<div className="flex w-full justify-center">
|
aria-label="文件列表"
|
||||||
<Pagination
|
sortDescriptor={sortDescriptor}
|
||||||
isCompact
|
onSortChange={onSortChange}
|
||||||
showControls
|
onSelectionChange={onSelectionChange}
|
||||||
showShadow
|
defaultSelectedKeys={[]}
|
||||||
color="danger"
|
selectedKeys={selectedFiles}
|
||||||
page={page}
|
selectionMode="multiple"
|
||||||
total={pages}
|
bottomContent={
|
||||||
onChange={(page) => setPage(page)}
|
<div className="flex w-full justify-center">
|
||||||
/>
|
<Pagination
|
||||||
</div>
|
isCompact
|
||||||
}
|
showControls
|
||||||
>
|
showShadow
|
||||||
<TableHeader>
|
color="primary"
|
||||||
<TableColumn key="name" allowsSorting>
|
page={page}
|
||||||
名称
|
total={pages}
|
||||||
</TableColumn>
|
onChange={(page) => setPage(page)}
|
||||||
<TableColumn key="type" allowsSorting>
|
/>
|
||||||
类型
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="size" allowsSorting>
|
|
||||||
大小
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="mtime" allowsSorting>
|
|
||||||
修改时间
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="actions">操作</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody
|
|
||||||
isLoading={loading}
|
|
||||||
loadingContent={
|
|
||||||
<div className="flex justify-center items-center h-full">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={displayFiles}
|
|
||||||
>
|
>
|
||||||
{(file: FileInfo) => {
|
<TableHeader>
|
||||||
const filePath = path.join(currentPath, file.name)
|
<TableColumn key="name" allowsSorting>
|
||||||
// 判断预览类型
|
名称
|
||||||
const ext = path.extname(file.name).toLowerCase()
|
</TableColumn>
|
||||||
const previewable = [
|
<TableColumn key="type" allowsSorting>
|
||||||
'.png',
|
类型
|
||||||
'.jpg',
|
</TableColumn>
|
||||||
'.jpeg',
|
<TableColumn key="size" allowsSorting>
|
||||||
'.gif',
|
大小
|
||||||
'.bmp',
|
</TableColumn>
|
||||||
'.mp4',
|
<TableColumn key="mtime" allowsSorting>
|
||||||
'.webm',
|
修改时间
|
||||||
'.mp3',
|
</TableColumn>
|
||||||
'.wav'
|
<TableColumn key="actions">操作</TableColumn>
|
||||||
].includes(ext)
|
</TableHeader>
|
||||||
return (
|
<TableBody
|
||||||
<TableRow key={file.name}>
|
isLoading={loading}
|
||||||
<TableCell>
|
loadingContent={
|
||||||
<Button
|
<div className="flex justify-center items-center h-full">
|
||||||
variant="light"
|
<Spinner />
|
||||||
onPress={() =>
|
</div>
|
||||||
file.isDirectory
|
}
|
||||||
? onDirectoryClick(file.name)
|
>
|
||||||
: previewable
|
{displayFiles.map((file: FileInfo) => {
|
||||||
? onPreview(filePath)
|
const filePath = path.join(currentPath, file.name)
|
||||||
: onEdit(filePath)
|
const ext = path.extname(file.name).toLowerCase()
|
||||||
}
|
const previewable = supportedPreviewExts.includes(ext)
|
||||||
className="text-left justify-start"
|
const images = previewImages
|
||||||
startContent={
|
return (
|
||||||
<FileIcon name={file.name} isDirectory={file.isDirectory} />
|
<TableRow key={file.name}>
|
||||||
}
|
<TableCell>
|
||||||
>
|
{imageExts.includes(ext) ? (
|
||||||
{file.name}
|
<ImageNameButton
|
||||||
</Button>
|
name={file.name}
|
||||||
</TableCell>
|
filePath={filePath}
|
||||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
onPreview={() => onPreviewImage(file.name, images)}
|
||||||
<TableCell>
|
onAddPreview={addPreviewImage}
|
||||||
{isNaN(file.size) || file.isDirectory
|
/>
|
||||||
? '-'
|
) : (
|
||||||
: `${file.size} 字节`}
|
<Button
|
||||||
</TableCell>
|
variant="light"
|
||||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
onPress={() =>
|
||||||
<TableCell>
|
file.isDirectory
|
||||||
<ButtonGroup size="sm">
|
? onDirectoryClick(file.name)
|
||||||
<Button
|
: previewable
|
||||||
isIconOnly
|
? onPreview(filePath)
|
||||||
color="danger"
|
: onEdit(filePath)
|
||||||
variant="flat"
|
}
|
||||||
onPress={() => onRenameRequest(file.name)}
|
className="text-left justify-start"
|
||||||
>
|
startContent={
|
||||||
<BiRename />
|
<FileIcon
|
||||||
</Button>
|
name={file.name}
|
||||||
<Button
|
isDirectory={file.isDirectory}
|
||||||
isIconOnly
|
/>
|
||||||
color="danger"
|
}
|
||||||
variant="flat"
|
>
|
||||||
onPress={() => onMoveRequest(file.name)}
|
{file.name}
|
||||||
>
|
</Button>
|
||||||
<FiMove />
|
)}
|
||||||
</Button>
|
</TableCell>
|
||||||
<Button
|
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||||
isIconOnly
|
<TableCell>
|
||||||
color="danger"
|
{isNaN(file.size) || file.isDirectory
|
||||||
variant="flat"
|
? '-'
|
||||||
onPress={() => onCopyPath(file.name)}
|
: `${file.size} 字节`}
|
||||||
>
|
</TableCell>
|
||||||
<FiCopy />
|
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||||
</Button>
|
<TableCell>
|
||||||
<Button
|
<ButtonGroup size="sm">
|
||||||
isIconOnly
|
<Button
|
||||||
color="danger"
|
isIconOnly
|
||||||
variant="flat"
|
color="primary"
|
||||||
onPress={() => onDownload(filePath)}
|
variant="flat"
|
||||||
>
|
onPress={() => onRenameRequest(file.name)}
|
||||||
<FiDownload />
|
>
|
||||||
</Button>
|
<BiRename />
|
||||||
<Button
|
</Button>
|
||||||
isIconOnly
|
<Button
|
||||||
color="danger"
|
isIconOnly
|
||||||
variant="flat"
|
color="primary"
|
||||||
onPress={() => onDelete(filePath)}
|
variant="flat"
|
||||||
>
|
onPress={() => onMoveRequest(file.name)}
|
||||||
<FiTrash2 />
|
>
|
||||||
</Button>
|
<FiMove />
|
||||||
</ButtonGroup>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
isIconOnly
|
||||||
)
|
color="primary"
|
||||||
}}
|
variant="flat"
|
||||||
</TableBody>
|
onPress={() => onCopyPath(file.name)}
|
||||||
</Table>
|
>
|
||||||
|
<FiCopy />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDownload(filePath)}
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDelete(filePath)}
|
||||||
|
>
|
||||||
|
<FiTrash2 />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,88 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Image } from '@heroui/image'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import { useRequest } from 'ahooks'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
import FileIcon from '../file_icon'
|
||||||
|
|
||||||
|
export interface PreviewImage {
|
||||||
|
key: string
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
export interface ImageNameButtonProps {
|
||||||
|
name: string
|
||||||
|
filePath: string
|
||||||
|
onPreview: () => void
|
||||||
|
onAddPreview: (image: PreviewImage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageNameButton({
|
||||||
|
name,
|
||||||
|
filePath,
|
||||||
|
onPreview,
|
||||||
|
onAddPreview
|
||||||
|
}: ImageNameButtonProps) {
|
||||||
|
const { data, loading, error, run } = useRequest(
|
||||||
|
async () => FileManager.downloadToURL(filePath),
|
||||||
|
{
|
||||||
|
refreshDeps: [filePath],
|
||||||
|
manual: true,
|
||||||
|
refreshDepsAction: () => {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (!filePath || !imageExts.includes(ext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
onAddPreview({
|
||||||
|
key: name,
|
||||||
|
src: data,
|
||||||
|
alt: name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [data, name, onAddPreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
className="text-left justify-start"
|
||||||
|
onPress={onPreview}
|
||||||
|
startContent={
|
||||||
|
error ? (
|
||||||
|
<FileIcon name={name} isDirectory={false} />
|
||||||
|
) : loading || !data ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={data}
|
||||||
|
alt={name}
|
||||||
|
className="w-8 h-8 flex-shrink-0"
|
||||||
|
classNames={{
|
||||||
|
wrapper: 'w-8 h-8 flex-shrink-0'
|
||||||
|
}}
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@@ -86,13 +86,13 @@ function DirectoryTree({
|
|||||||
onPress={handleClick}
|
onPress={handleClick}
|
||||||
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
|
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant={variant}
|
variant={variant}
|
||||||
startContent={
|
startContent={
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded-md',
|
'rounded-md',
|
||||||
isSeleted ? 'bg-danger-600' : 'bg-danger-50'
|
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{expanded ? <IoRemove /> : <IoAdd />}
|
{expanded ? <IoRemove /> : <IoAdd />}
|
||||||
@@ -105,7 +105,7 @@ function DirectoryTree({
|
|||||||
<div>
|
<div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex py-1 px-8">
|
<div className="flex py-1 px-8">
|
||||||
<Spinner size="sm" color="danger" />
|
<Spinner size="sm" color="primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dirs.map((dirName) => {
|
dirs.map((dirName) => {
|
||||||
@@ -155,10 +155,10 @@ export default function MoveModal({
|
|||||||
<p className="text-sm text-default-500">移动项:{selectionInfo}</p>
|
<p className="text-sm text-default-500">移动项:{selectionInfo}</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="danger" onPress={onMove}>
|
<Button color="primary" onPress={onMove}>
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -31,10 +31,10 @@ export default function RenameModal({
|
|||||||
<Input label="新名称" value={newFileName} onChange={onNameChange} />
|
<Input label="新名称" value={newFileName} onChange={onNameChange} />
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="danger" onPress={onRename}>
|
<Button color="primary" onPress={onRename}>
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
@@ -33,10 +33,10 @@ export default function Hitokoto() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{loading && <PageLoading />}
|
{loading && <PageLoading />}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
<div className="text-primary-400">一言加载失败:{error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="font-noto-serif">{data?.hitokoto}</div>
|
<div>{data?.hitokoto}</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||||
{data?.from_who}
|
{data?.from_who}
|
||||||
@@ -52,7 +52,7 @@ export default function Hitokoto() {
|
|||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
<IoRefresh />
|
<IoRefresh />
|
||||||
|
@@ -34,7 +34,7 @@ export default function HoverTiltedCard({
|
|||||||
rotateAmplitude = 14,
|
rotateAmplitude = 14,
|
||||||
showTooltip = false,
|
showTooltip = false,
|
||||||
overlayContent = (
|
overlayContent = (
|
||||||
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80">
|
||||||
NapCat
|
NapCat
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
@@ -43,7 +43,7 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
|||||||
onChange('')
|
onChange('')
|
||||||
if (inputRef.current) inputRef.current.value = ''
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
}}
|
}}
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
@@ -16,13 +16,13 @@ const logLevelColor: {
|
|||||||
| 'secondary'
|
| 'secondary'
|
||||||
| 'success'
|
| 'success'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
| 'danger'
|
| 'primary'
|
||||||
} = {
|
} = {
|
||||||
[LogLevel.DEBUG]: 'default',
|
[LogLevel.DEBUG]: 'default',
|
||||||
[LogLevel.INFO]: 'primary',
|
[LogLevel.INFO]: 'primary',
|
||||||
[LogLevel.WARN]: 'warning',
|
[LogLevel.WARN]: 'warning',
|
||||||
[LogLevel.ERROR]: 'danger',
|
[LogLevel.ERROR]: 'primary',
|
||||||
[LogLevel.FATAL]: 'danger'
|
[LogLevel.FATAL]: 'primary'
|
||||||
}
|
}
|
||||||
const LogLevelSelect = (props: LogLevelSelectProps) => {
|
const LogLevelSelect = (props: LogLevelSelectProps) => {
|
||||||
const { selectedKeys, onSelectionChange } = props
|
const { selectedKeys, onSelectionChange } = props
|
||||||
|
@@ -65,7 +65,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
{showCancel && (
|
{showCancel && (
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onCancel?.()
|
onCancel?.()
|
||||||
@@ -76,7 +76,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onConfirm?.()
|
onConfirm?.()
|
||||||
nativeClose()
|
nativeClose()
|
||||||
|
@@ -28,7 +28,7 @@ import type {
|
|||||||
|
|
||||||
function displayData(data: number, loading: boolean, error?: Error) {
|
function displayData(data: number, loading: boolean, error?: Error) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return <MdError className="text-danger-400" />
|
return <MdError className="text-primary-400" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -175,7 +175,7 @@ export default function NapCatRepoInfo() {
|
|||||||
className="group h-auto py-3"
|
className="group h-auto py-3"
|
||||||
endContent={
|
endContent={
|
||||||
releaseError ? (
|
releaseError ? (
|
||||||
<MdError className="text-danger-400" />
|
<MdError className="text-primary-400" />
|
||||||
) : releaseLoading ? (
|
) : releaseLoading ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
@@ -229,7 +229,7 @@ export default function NapCatRepoInfo() {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
startContent={
|
startContent={
|
||||||
<IconWrapper className="bg-danger/10 text-danger dark:text-danger-500">
|
<IconWrapper className="bg-primary/10 text-primary dark:text-primary-500">
|
||||||
<BookIcon />
|
<BookIcon />
|
||||||
</IconWrapper>
|
</IconWrapper>
|
||||||
}
|
}
|
||||||
|
@@ -150,7 +150,7 @@ const GenericForm = <T extends keyof NetworkConfigType>({
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
isDisabled={formState.isSubmitting}
|
isDisabled={formState.isSubmitting}
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
|
@@ -20,7 +20,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
|||||||
enable: false,
|
enable: false,
|
||||||
name: '',
|
name: '',
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3001,
|
||||||
reportSelfMessage: false,
|
reportSelfMessage: false,
|
||||||
enableForcePushEvent: true,
|
enableForcePushEvent: true,
|
||||||
messagePostFormat: 'array',
|
messagePostFormat: 'array',
|
||||||
|
@@ -91,7 +91,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="p-4 pt-14 rounded-lg shadow-md">
|
<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">
|
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400">
|
||||||
<PiCatDuotone />
|
<PiCatDuotone />
|
||||||
{data.description}
|
{data.description}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -125,7 +125,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onPress={sendRequest}
|
onPress={sendRequest}
|
||||||
color="danger"
|
color="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="full"
|
radius="full"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
shadow="sm"
|
shadow="sm"
|
||||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||||
>
|
>
|
||||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||||
<span className="mr-2">请求体</span>
|
<span className="mr-2">请求体</span>
|
||||||
<Button
|
<Button
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<PageLoading loading={isFetching} />
|
<PageLoading loading={isFetching} />
|
||||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||||
<span className="mr-2">响应</span>
|
<span className="mr-2">响应</span>
|
||||||
<Button
|
<Button
|
||||||
color="warning"
|
color="warning"
|
||||||
|
@@ -27,7 +27,7 @@ const SchemaType = ({
|
|||||||
name = '固定值'
|
name = '固定值'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' =
|
let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' =
|
||||||
'primary'
|
'primary'
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'enum':
|
case 'enum':
|
||||||
@@ -37,7 +37,7 @@ const SchemaType = ({
|
|||||||
chipColor = 'secondary'
|
chipColor = 'secondary'
|
||||||
break
|
break
|
||||||
case 'array':
|
case 'array':
|
||||||
chipColor = 'danger'
|
chipColor = 'primary'
|
||||||
break
|
break
|
||||||
case 'object':
|
case 'object':
|
||||||
chipColor = 'success'
|
chipColor = 'success'
|
||||||
|
@@ -33,11 +33,11 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
|
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
|
||||||
<Input
|
<Input
|
||||||
className="sticky top-0 z-10 text-danger-600"
|
className="sticky top-0 z-10 text-primary-600"
|
||||||
classNames={{
|
classNames={{
|
||||||
inputWrapper:
|
inputWrapper:
|
||||||
'bg-opacity-30 bg-danger-50 backdrop-blur-sm border border-danger-300 mb-2',
|
'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
|
||||||
input: 'bg-transparent !text-danger-400 !placeholder-danger-400'
|
input: 'bg-transparent !text-primary-400 !placeholder-primary-400'
|
||||||
}}
|
}}
|
||||||
radius="full"
|
radius="full"
|
||||||
placeholder="搜索 API"
|
placeholder="搜索 API"
|
||||||
@@ -51,7 +51,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
key={apiName}
|
key={apiName}
|
||||||
shadow="none"
|
shadow="none"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full border border-danger-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-danger-400',
|
'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
|
||||||
{
|
{
|
||||||
hidden: !(
|
hidden: !(
|
||||||
apiName.includes(searchValue) ||
|
apiName.includes(searchValue) ||
|
||||||
@@ -59,7 +59,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'!bg-opacity-40 border border-danger-400 bg-danger-50 text-danger-600':
|
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
|
||||||
apiName === selectedApi
|
apiName === selectedApi
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@@ -67,10 +67,10 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||||
>
|
>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
<h2 className="font-bold">{api.description}</h2>
|
||||||
<div
|
<div
|
||||||
className={clsx('text-sm text-danger-200', {
|
className={clsx('text-sm text-primary-200', {
|
||||||
'!text-danger-400': apiName === selectedApi
|
'!text-primary-400': apiName === selectedApi
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{apiName}
|
{apiName}
|
||||||
|
@@ -109,7 +109,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
|
@@ -30,7 +30,7 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
radius="full"
|
radius="full"
|
||||||
className="text-medium"
|
className="text-medium"
|
||||||
|
@@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
|
||||||
构造请求
|
构造请求
|
||||||
</Button>
|
</Button>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -75,11 +75,11 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
|||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<ChatInputModal />
|
<ChatInputModal />
|
||||||
|
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="primary" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
onPress={() => handleSendMessage(onClose)}
|
onPress={() => handleSendMessage(onClose)}
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
|
@@ -10,7 +10,7 @@ function StatusTag({
|
|||||||
color
|
color
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
color: 'success' | 'danger' | 'warning'
|
color: 'success' | 'primary' | 'warning'
|
||||||
}) {
|
}) {
|
||||||
const textClassName = `text-${color} text-sm`
|
const textClassName = `text-${color} text-sm`
|
||||||
const bgClassName = `bg-${color}`
|
const bgClassName = `bg-${color}`
|
||||||
@@ -27,7 +27,7 @@ export default function WSStatus({ state }: WSStatusProps) {
|
|||||||
return <StatusTag title="已连接" color="success" />
|
return <StatusTag title="已连接" color="success" />
|
||||||
}
|
}
|
||||||
if (state === ReadyState.CLOSED) {
|
if (state === ReadyState.CLOSED) {
|
||||||
return <StatusTag title="已关闭" color="danger" />
|
return <StatusTag title="已关闭" color="primary" />
|
||||||
}
|
}
|
||||||
if (state === ReadyState.CONNECTING) {
|
if (state === ReadyState.CONNECTING) {
|
||||||
return <StatusTag title="连接中" color="warning" />
|
return <StatusTag title="连接中" color="warning" />
|
||||||
|
@@ -16,23 +16,21 @@ export interface QQInfoCardProps {
|
|||||||
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="relative bg-danger-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-danger-300 dark:shadow-danger-50"
|
className="relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50"
|
||||||
shadow="none"
|
shadow="none"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<PageLoading loading={loading} />
|
<PageLoading loading={loading} />
|
||||||
{error ? (
|
{error ? (
|
||||||
<CardBody className="items-center gap-1 justify-center">
|
<CardBody className="items-center gap-1 justify-center">
|
||||||
<div className="font-outfit flex-1 text-content1-foreground">
|
<div className="flex-1 text-content1-foreground">Error</div>
|
||||||
Error
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||||
{error.message}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
) : (
|
) : (
|
||||||
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
|
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
|
||||||
<div className="absolute right-0 bottom-0 text-5xl text-danger-400">
|
<div className="absolute right-0 bottom-0 text-5xl text-primary-400">
|
||||||
<BsTencentQq />
|
<BsTencentQq />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0 z-10">
|
<div className="relative flex-shrink-0 z-10">
|
||||||
@@ -45,16 +43,14 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-danger-100 z-10',
|
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
|
||||||
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
||||||
)}
|
)}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col justify-center">
|
<div className="flex-col justify-center">
|
||||||
<div className="font-outfit text-lg truncate">{data?.nick}</div>
|
<div className="text-lg truncate">{data?.nick}</div>
|
||||||
<div className="font-ubuntu text-danger-500 text-sm">
|
<div className="text-primary-500 text-sm">{data?.uin}</div>
|
||||||
{data?.uin}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
)}
|
)}
|
||||||
|
@@ -11,7 +11,7 @@ const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
|||||||
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
|
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
|
||||||
{!qrcode && (
|
{!qrcode && (
|
||||||
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
|
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
|
||||||
<Spinner color="danger" />
|
<Spinner color="primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<QRCodeSVG size={180} value={qrcode} />
|
<QRCodeSVG size={180} value={qrcode} />
|
||||||
|
265
napcat.webui/src/components/rotating_text.tsx
Normal file
265
napcat.webui/src/components/rotating_text.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
HTMLMotionProps,
|
||||||
|
TargetAndTransition,
|
||||||
|
Transition,
|
||||||
|
motion
|
||||||
|
} from 'motion/react'
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotatingTextRef {
|
||||||
|
next: () => void
|
||||||
|
previous: () => void
|
||||||
|
jumpTo: (index: number) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotatingTextProps
|
||||||
|
extends Omit<
|
||||||
|
HTMLMotionProps<'span'>,
|
||||||
|
'children' | 'transition' | 'initial' | 'animate' | 'exit'
|
||||||
|
> {
|
||||||
|
texts: string[]
|
||||||
|
transition?: Transition
|
||||||
|
initial?: TargetAndTransition
|
||||||
|
animate?: TargetAndTransition
|
||||||
|
exit?: TargetAndTransition
|
||||||
|
animatePresenceMode?: 'sync' | 'wait'
|
||||||
|
animatePresenceInitial?: boolean
|
||||||
|
rotationInterval?: number
|
||||||
|
staggerDuration?: number
|
||||||
|
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
|
||||||
|
loop?: boolean
|
||||||
|
auto?: boolean
|
||||||
|
splitBy?: string
|
||||||
|
onNext?: (index: number) => void
|
||||||
|
mainClassName?: string
|
||||||
|
splitLevelClassName?: string
|
||||||
|
elementLevelClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
texts,
|
||||||
|
transition = { type: 'spring', damping: 25, stiffness: 300 },
|
||||||
|
initial = { y: '100%', opacity: 0 },
|
||||||
|
animate = { y: 0, opacity: 1 },
|
||||||
|
exit = { y: '-120%', opacity: 0 },
|
||||||
|
animatePresenceMode = 'wait',
|
||||||
|
animatePresenceInitial = false,
|
||||||
|
rotationInterval = 2000,
|
||||||
|
staggerDuration = 0,
|
||||||
|
staggerFrom = 'first',
|
||||||
|
loop = true,
|
||||||
|
auto = true,
|
||||||
|
splitBy = 'characters',
|
||||||
|
onNext,
|
||||||
|
mainClassName,
|
||||||
|
splitLevelClassName,
|
||||||
|
elementLevelClassName,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
|
||||||
|
|
||||||
|
const splitIntoCharacters = (text: string): string[] => {
|
||||||
|
return Array.from(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const currentText: string = texts[currentTextIndex]
|
||||||
|
if (splitBy === 'characters') {
|
||||||
|
const words = currentText.split(' ')
|
||||||
|
return words.map((word, i) => ({
|
||||||
|
characters: splitIntoCharacters(word),
|
||||||
|
needsSpace: i !== words.length - 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (splitBy === 'words') {
|
||||||
|
return currentText.split(' ').map((word, i, arr) => ({
|
||||||
|
characters: [word],
|
||||||
|
needsSpace: i !== arr.length - 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (splitBy === 'lines') {
|
||||||
|
return currentText.split('\n').map((line, i, arr) => ({
|
||||||
|
characters: [line],
|
||||||
|
needsSpace: i !== arr.length - 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentText.split(splitBy).map((part, i, arr) => ({
|
||||||
|
characters: [part],
|
||||||
|
needsSpace: i !== arr.length - 1
|
||||||
|
}))
|
||||||
|
}, [texts, currentTextIndex, splitBy])
|
||||||
|
|
||||||
|
const getStaggerDelay = useCallback(
|
||||||
|
(index: number, totalChars: number): number => {
|
||||||
|
const total = totalChars
|
||||||
|
if (staggerFrom === 'first') return index * staggerDuration
|
||||||
|
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
|
||||||
|
if (staggerFrom === 'center') {
|
||||||
|
const center = Math.floor(total / 2)
|
||||||
|
return Math.abs(center - index) * staggerDuration
|
||||||
|
}
|
||||||
|
if (staggerFrom === 'random') {
|
||||||
|
const randomIndex = Math.floor(Math.random() * total)
|
||||||
|
return Math.abs(randomIndex - index) * staggerDuration
|
||||||
|
}
|
||||||
|
return Math.abs((staggerFrom as number) - index) * staggerDuration
|
||||||
|
},
|
||||||
|
[staggerFrom, staggerDuration]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleIndexChange = useCallback(
|
||||||
|
(newIndex: number) => {
|
||||||
|
setCurrentTextIndex(newIndex)
|
||||||
|
if (onNext) onNext(newIndex)
|
||||||
|
},
|
||||||
|
[onNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = useCallback(() => {
|
||||||
|
const nextIndex =
|
||||||
|
currentTextIndex === texts.length - 1
|
||||||
|
? loop
|
||||||
|
? 0
|
||||||
|
: currentTextIndex
|
||||||
|
: currentTextIndex + 1
|
||||||
|
if (nextIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(nextIndex)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||||
|
|
||||||
|
const previous = useCallback(() => {
|
||||||
|
const prevIndex =
|
||||||
|
currentTextIndex === 0
|
||||||
|
? loop
|
||||||
|
? texts.length - 1
|
||||||
|
: currentTextIndex
|
||||||
|
: currentTextIndex - 1
|
||||||
|
if (prevIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(prevIndex)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, texts.length, loop, handleIndexChange])
|
||||||
|
|
||||||
|
const jumpTo = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
|
||||||
|
if (validIndex !== currentTextIndex) {
|
||||||
|
handleIndexChange(validIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[texts.length, currentTextIndex, handleIndexChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (currentTextIndex !== 0) {
|
||||||
|
handleIndexChange(0)
|
||||||
|
}
|
||||||
|
}, [currentTextIndex, handleIndexChange])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
jumpTo,
|
||||||
|
reset
|
||||||
|
}),
|
||||||
|
[next, previous, jumpTo, reset]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auto) return
|
||||||
|
const intervalId = setInterval(next, rotationInterval)
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}, [next, rotationInterval, auto])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap whitespace-pre-wrap relative',
|
||||||
|
mainClassName
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
layout
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{texts[currentTextIndex]}</span>
|
||||||
|
<AnimatePresence
|
||||||
|
mode={animatePresenceMode}
|
||||||
|
initial={animatePresenceInitial}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key={currentTextIndex}
|
||||||
|
className={cn(
|
||||||
|
splitBy === 'lines'
|
||||||
|
? 'flex flex-col w-full'
|
||||||
|
: 'flex flex-wrap whitespace-pre-wrap relative'
|
||||||
|
)}
|
||||||
|
layout
|
||||||
|
aria-hidden="true"
|
||||||
|
initial={initial as HTMLMotionProps<'div'>['initial']}
|
||||||
|
animate={animate as HTMLMotionProps<'div'>['animate']}
|
||||||
|
exit={exit as HTMLMotionProps<'div'>['exit']}
|
||||||
|
>
|
||||||
|
{elements.map((wordObj, wordIndex, array) => {
|
||||||
|
const previousCharsCount = array
|
||||||
|
.slice(0, wordIndex)
|
||||||
|
.reduce((sum, word) => sum + word.characters.length, 0)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={wordIndex}
|
||||||
|
className={cn('inline-flex', splitLevelClassName)}
|
||||||
|
>
|
||||||
|
{wordObj.characters.map((char, charIndex) => (
|
||||||
|
<motion.span
|
||||||
|
key={charIndex}
|
||||||
|
initial={initial as HTMLMotionProps<'span'>['initial']}
|
||||||
|
animate={animate as HTMLMotionProps<'span'>['animate']}
|
||||||
|
exit={exit as HTMLMotionProps<'span'>['exit']}
|
||||||
|
transition={{
|
||||||
|
...transition,
|
||||||
|
delay: getStaggerDelay(
|
||||||
|
previousCharsCount + charIndex,
|
||||||
|
array.reduce(
|
||||||
|
(sum, word) => sum + word.characters.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={cn('inline-block', elementLevelClassName)}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
{wordObj.needsSpace && (
|
||||||
|
<span className="whitespace-pre"> </span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RotatingText.displayName = 'RotatingText'
|
||||||
|
export default RotatingText
|
@@ -47,11 +47,11 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
||||||
<div className="flex justify-center items-center mt-2 gap-2">
|
<div className="flex justify-center items-center my-2 gap-2">
|
||||||
<Image radius="none" height={40} src={logo} className="mb-2" />
|
<Image radius="none" height={40} src={logo} className="mb-2" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center hm-medium',
|
'flex items-center font-bold',
|
||||||
'!text-2xl shiny-text'
|
'!text-2xl shiny-text'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -63,7 +63,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
<div className="mt-auto mb-10 md:mb-0">
|
<div className="mt-auto mb-10 md:mb-0">
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
color="danger"
|
color="primary"
|
||||||
radius="full"
|
radius="full"
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={toggleTheme}
|
onPress={toggleTheme}
|
||||||
@@ -75,7 +75,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full mb-2"
|
className="w-full mb-2"
|
||||||
color="danger"
|
color="primary"
|
||||||
radius="full"
|
radius="full"
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={onRevokeAuth}
|
onPress={onRevokeAuth}
|
||||||
|
@@ -55,7 +55,7 @@ const renderItems = (items: MenuItem[], children = false) => {
|
|||||||
isActive && 'bg-opacity-60',
|
isActive && 'bg-opacity-60',
|
||||||
b64img && 'backdrop-blur-md text-white'
|
b64img && 'backdrop-blur-md text-white'
|
||||||
)}
|
)}
|
||||||
color="danger"
|
color="primary"
|
||||||
endContent={
|
endContent={
|
||||||
canOpen ? (
|
canOpen ? (
|
||||||
// div实现箭头V效果
|
// div实现箭头V效果
|
||||||
@@ -63,7 +63,9 @@ const renderItems = (items: MenuItem[], children = false) => {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'ml-auto relative w-3 h-3 transition-transform',
|
'ml-auto relative w-3 h-3 transition-transform',
|
||||||
open && 'transform rotate-180',
|
open && 'transform rotate-180',
|
||||||
isActive ? 'text-danger-500' : 'text-red-300 dark:text-white',
|
isActive
|
||||||
|
? 'text-primary-500'
|
||||||
|
: 'text-red-300 dark:text-white',
|
||||||
'before:rounded-full',
|
'before:rounded-full',
|
||||||
'before:content-[""]',
|
'before:content-[""]',
|
||||||
'before:block',
|
'before:block',
|
||||||
@@ -95,7 +97,7 @@ const renderItems = (items: MenuItem[], children = false) => {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-danger-500 animate-spinner-ease-spin'
|
? 'bg-primary-500 animate-spinner-ease-spin'
|
||||||
: 'bg-red-300 dark:bg-white'
|
: 'bg-red-300 dark:bg-white'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@@ -4,6 +4,8 @@ import { Chip } from '@heroui/chip'
|
|||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { Tooltip } from '@heroui/tooltip'
|
import { Tooltip } from '@heroui/tooltip'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { BsStars } from 'react-icons/bs'
|
||||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||||
import { RiMacFill } from 'react-icons/ri'
|
import { RiMacFill } from 'react-icons/ri'
|
||||||
@@ -16,7 +18,6 @@ import { compareVersion } from '@/utils/version'
|
|||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
import { GithubRelease } from '@/types/github'
|
import { GithubRelease } from '@/types/github'
|
||||||
|
|
||||||
import packageJson from '../../package.json'
|
|
||||||
import TailwindMarkdown from './tailwind_markdown'
|
import TailwindMarkdown from './tailwind_markdown'
|
||||||
|
|
||||||
export interface SystemInfoItemProps {
|
export interface SystemInfoItemProps {
|
||||||
@@ -33,10 +34,10 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
|||||||
endContent
|
endContent
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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">
|
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-50 dark:shadow-primary-100 rounded text-primary-400">
|
||||||
{icon}
|
{icon}
|
||||||
<div className="w-24">{title}</div>
|
<div className="w-24">{title}</div>
|
||||||
<div className="text-danger-200">{value}</div>
|
<div className="text-primary-200">{value}</div>
|
||||||
<div className="ml-auto">{endContent}</div>
|
<div className="ml-auto">{endContent}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -61,7 +62,7 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="shadow"
|
variant="shadow"
|
||||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -98,12 +99,48 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AISummaryComponent = () => {
|
||||||
|
const {
|
||||||
|
data: aiSummaryData,
|
||||||
|
loading: aiSummaryLoading,
|
||||||
|
error: aiSummaryError,
|
||||||
|
run: runAiSummary
|
||||||
|
} = useRequest(
|
||||||
|
(version) =>
|
||||||
|
request.get<ServerResponse<string | null>>(
|
||||||
|
`https://release.nc.152710.xyz/?version=${version}`,
|
||||||
|
{
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{
|
||||||
|
manual: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runAiSummary(currentVersion)
|
||||||
|
}, [currentVersion, runAiSummary])
|
||||||
|
|
||||||
|
if (aiSummaryLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-1">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (aiSummaryError) {
|
||||||
|
return <div className="text-center text-primary-500">AI 摘要获取失败</div>
|
||||||
|
}
|
||||||
|
return <span className="text-default-700">{aiSummaryData?.data.data}</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content="有新版本可用">
|
<Tooltip content="有新版本可用">
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius="full"
|
radius="full"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="shadow"
|
variant="shadow"
|
||||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -121,6 +158,13 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
<span>最新版本</span>
|
<span>最新版本</span>
|
||||||
<Chip color="primary">{latestVersion}</Chip>
|
<Chip color="primary">{latestVersion}</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-2 rounded-md bg-content2 text-sm">
|
||||||
|
<div className="text-primary-400 font-bold flex items-center gap-1 mb-1">
|
||||||
|
<BsStars />
|
||||||
|
<span>AI总结</span>
|
||||||
|
</div>
|
||||||
|
{<AISummaryComponent />}
|
||||||
|
</div>
|
||||||
<div className="text-sm space-y-2 !mt-4">
|
<div className="text-sm space-y-2 !mt-4">
|
||||||
{middleVersions.map((versionInfo) => (
|
{middleVersions.map((versionInfo) => (
|
||||||
<div
|
<div
|
||||||
@@ -190,19 +234,14 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
error: qqVersionError
|
error: qqVersionError
|
||||||
} = useRequest(WebUIManager.getQQVersion)
|
} = useRequest(WebUIManager.getQQVersion)
|
||||||
return (
|
return (
|
||||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 overflow-visible flex-1">
|
<Card className="bg-opacity-60 shadow-sm shadow-primary-50 dark:shadow-primary-100 overflow-visible flex-1">
|
||||||
<CardHeader className="pb-0 items-center gap-1 text-danger-500 font-extrabold">
|
<CardHeader className="pb-0 items-center gap-1 text-primary-500 font-extrabold">
|
||||||
<FaCircleInfo className="text-lg" />
|
<FaCircleInfo className="text-lg" />
|
||||||
<span>系统信息</span>
|
<span>系统信息</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="flex-1">
|
<CardBody className="flex-1">
|
||||||
<div className="flex flex-col justify-between h-full">
|
<div className="flex flex-col justify-between h-full">
|
||||||
<NapCatVersion />
|
<NapCatVersion />
|
||||||
<SystemInfoItem
|
|
||||||
title="WebUI 版本"
|
|
||||||
icon={<IoLogoChrome className="text-xl" />}
|
|
||||||
value={packageJson.version}
|
|
||||||
/>
|
|
||||||
<SystemInfoItem
|
<SystemInfoItem
|
||||||
title="QQ 版本"
|
title="QQ 版本"
|
||||||
icon={<FaQq className="text-lg" />}
|
icon={<FaQq className="text-lg" />}
|
||||||
@@ -216,6 +255,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SystemInfoItem
|
||||||
|
title="WebUI 版本"
|
||||||
|
icon={<IoLogoChrome className="text-xl" />}
|
||||||
|
value="Next"
|
||||||
|
/>
|
||||||
<SystemInfoItem
|
<SystemInfoItem
|
||||||
title="系统版本"
|
title="系统版本"
|
||||||
icon={<RiMacFill className="text-xl" />}
|
icon={<RiMacFill className="text-xl" />}
|
||||||
|
@@ -55,7 +55,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 col-span-1 lg:col-span-2 relative overflow-hidden">
|
<Card className="bg-opacity-60 shadow-sm shadow-primary-50 dark:shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden">
|
||||||
<div className="absolute h-full right-0 top-0">
|
<div className="absolute h-full right-0 top-0">
|
||||||
<Image
|
<Image
|
||||||
src={bkg}
|
src={bkg}
|
||||||
@@ -69,7 +69,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
|
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
|
||||||
<div className="flex-1 w-full md:max-w-96">
|
<div className="flex-1 w-full md:max-w-96">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400">
|
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400">
|
||||||
<GiCpu className="text-xl" />
|
<GiCpu className="text-xl" />
|
||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -88,7 +88,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
|||||||
unit="%"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400 mt-2">
|
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2">
|
||||||
<BiSolidMemoryCard className="text-xl" />
|
<BiSolidMemoryCard className="text-xl" />
|
||||||
<span>内存</span>
|
<span>内存</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
@@ -62,7 +62,7 @@ export const Tab = forwardRef<HTMLDivElement, TabProps>(
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
|
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-danger text-danger'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent hover:border-default',
|
: 'border-transparent hover:border-default',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
@@ -10,23 +10,24 @@ interface TerminalInstanceProps {
|
|||||||
|
|
||||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||||
const termRef = useRef<XTermRef>(null)
|
const termRef = useRef<XTermRef>(null)
|
||||||
|
const connected = useRef(false)
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.data) {
|
||||||
|
termRef.current?.write(parsed.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
termRef.current?.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleData = (data: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (parsed.data) {
|
|
||||||
termRef.current?.write(parsed.data)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
termRef.current?.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TerminalManager.connectTerminal(id, handleData)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
TerminalManager.disconnectTerminal(id, handleData)
|
if (connected.current) {
|
||||||
|
TerminalManager.disconnectTerminal(id, handleData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -34,5 +35,22 @@ export function TerminalInstance({ id }: TerminalInstanceProps) {
|
|||||||
TerminalManager.sendInput(id, data)
|
TerminalManager.sendInput(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
|
const handleResize = (cols: number, rows: number) => {
|
||||||
|
if (!connected.current) {
|
||||||
|
connected.current = true
|
||||||
|
console.log('instance', rows, cols)
|
||||||
|
TerminalManager.connectTerminal(id, handleData, { rows, cols })
|
||||||
|
} else {
|
||||||
|
TerminalManager.sendResize(id, cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XTerm
|
||||||
|
ref={termRef}
|
||||||
|
onInput={handleInput}
|
||||||
|
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
|
import { CanvasAddon } from '@xterm/addon-canvas'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
// import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
|||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme'
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
|
||||||
import { gradientText } from '@/utils/terminal'
|
|
||||||
|
|
||||||
export type XTermRef = {
|
export type XTermRef = {
|
||||||
write: (
|
write: (
|
||||||
...args: Parameters<Terminal['write']>
|
...args: Parameters<Terminal['write']>
|
||||||
@@ -20,53 +19,44 @@ export type XTermRef = {
|
|||||||
) => ReturnType<Terminal['writeln']>
|
) => ReturnType<Terminal['writeln']>
|
||||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||||
clear: () => void
|
clear: () => void
|
||||||
|
terminalRef: React.RefObject<Terminal | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XTermProps
|
export interface XTermProps
|
||||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||||
onInput?: (data: string) => void
|
onInput?: (data: string) => void
|
||||||
onKey?: (key: string, event: KeyboardEvent) => void
|
onKey?: (key: string, event: KeyboardEvent) => void
|
||||||
|
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||||
}
|
}
|
||||||
|
|
||||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||||
const domRef = useRef<HTMLDivElement>(null)
|
const domRef = useRef<HTMLDivElement>(null)
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
const { className, onInput, onKey, ...rest } = props
|
const { className, onInput, onKey, onResize, ...rest } = props
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!domRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
fontFamily:
|
||||||
|
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||||
cursorInactiveStyle: 'outline',
|
cursorInactiveStyle: 'outline',
|
||||||
drawBoldTextInBrightColors: false
|
drawBoldTextInBrightColors: false,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2
|
||||||
})
|
})
|
||||||
terminalRef.current = terminal
|
terminalRef.current = terminal
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
terminal.loadAddon(
|
terminal.loadAddon(
|
||||||
new WebLinksAddon((event, uri) => {
|
new WebLinksAddon((event, uri) => {
|
||||||
if (event.ctrlKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
window.open(uri, '_blank')
|
window.open(uri, '_blank')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
terminal.loadAddon(new WebglAddon())
|
terminal.open(domRef.current!)
|
||||||
terminal.open(domRef.current)
|
|
||||||
|
|
||||||
terminal.writeln(
|
|
||||||
gradientText(
|
|
||||||
'Welcome to NapCat WebUI',
|
|
||||||
[255, 0, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
terminal.loadAddon(new CanvasAddon())
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
if (onInput) {
|
if (onInput) {
|
||||||
onInput(data)
|
onInput(data)
|
||||||
@@ -81,6 +71,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
// 获取当前终端尺寸
|
||||||
|
const cols = terminal.cols
|
||||||
|
const rows = terminal.rows
|
||||||
|
if (onResize) {
|
||||||
|
onResize(cols, rows)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 字体加载完成后重新调整终端大小
|
// 字体加载完成后重新调整终端大小
|
||||||
@@ -100,21 +96,49 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.options.theme = {
|
if (theme === 'dark') {
|
||||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
terminalRef.current.options.theme = {
|
||||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
background: '#00000000',
|
||||||
selectionBackground:
|
black: '#ffffff',
|
||||||
theme === 'dark'
|
red: '#cd3131',
|
||||||
? 'rgba(179, 0, 0, 0.3)'
|
green: '#0dbc79',
|
||||||
: 'rgba(255, 167, 167, 0.3)',
|
yellow: '#e5e510',
|
||||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
blue: '#2472c8',
|
||||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
cyan: '#11a8cd',
|
||||||
black: theme === 'dark' ? '#fff' : '#000'
|
white: '#e5e5e5',
|
||||||
|
brightBlack: '#666666',
|
||||||
|
brightRed: '#f14c4c',
|
||||||
|
brightGreen: '#23d18b',
|
||||||
|
brightYellow: '#f5f543',
|
||||||
|
brightBlue: '#3b8eea',
|
||||||
|
brightCyan: '#29b8db',
|
||||||
|
brightWhite: '#e5e5e5',
|
||||||
|
foreground: '#cccccc',
|
||||||
|
selectionBackground: '#3a3d41',
|
||||||
|
cursor: '#ffffff'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terminalRef.current.options.theme = {
|
||||||
|
background: '#ffffff00',
|
||||||
|
black: '#000000',
|
||||||
|
red: '#aa3731',
|
||||||
|
green: '#448c27',
|
||||||
|
yellow: '#cb9000',
|
||||||
|
blue: '#325cc0',
|
||||||
|
cyan: '#0083b2',
|
||||||
|
white: '#7f7f7f',
|
||||||
|
brightBlack: '#777777',
|
||||||
|
brightRed: '#f05050',
|
||||||
|
brightGreen: '#60cb00',
|
||||||
|
brightYellow: '#ffbc5d',
|
||||||
|
brightBlue: '#007acc',
|
||||||
|
brightCyan: '#00aacb',
|
||||||
|
brightWhite: '#b0b0b0',
|
||||||
|
foreground: '#000000',
|
||||||
|
selectionBackground: '#bfdbfe',
|
||||||
|
cursor: '#007acc'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
terminalRef.current.options.fontWeight =
|
|
||||||
theme === 'dark' ? 'normal' : '600'
|
|
||||||
terminalRef.current.options.fontWeightBold =
|
|
||||||
theme === 'dark' ? 'bold' : '900'
|
|
||||||
}
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
@@ -139,7 +163,8 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
terminalRef.current?.clear()
|
terminalRef.current?.clear()
|
||||||
}
|
},
|
||||||
|
terminalRef: terminalRef
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
@@ -51,7 +51,7 @@ export const siteConfig = {
|
|||||||
href: '/config'
|
href: '/config'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'NapCat日志',
|
label: '猫猫日志',
|
||||||
icon: (
|
icon: (
|
||||||
<div className="w-5 h-5">
|
<div className="w-5 h-5">
|
||||||
<LogIcon />
|
<LogIcon />
|
||||||
|
@@ -35,6 +35,7 @@ const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
|||||||
const [musicId, setMusicId] = useState<number>(0)
|
const [musicId, setMusicId] = useState<number>(0)
|
||||||
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop)
|
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop)
|
||||||
const music = musicList.find((music) => music.id === musicId)
|
const music = musicList.find((music) => music.id === musicId)
|
||||||
|
const [token] = useLocalStorage(key.token, '')
|
||||||
const onNext = () => {
|
const onNext = () => {
|
||||||
const nextID = getNextMusic(musicList, musicId, playMode)
|
const nextID = getNextMusic(musicList, musicId, playMode)
|
||||||
setMusicId(nextID)
|
setMusicId(nextID)
|
||||||
@@ -60,8 +61,8 @@ const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
|||||||
setMusicId(res[0].id)
|
setMusicId(res[0].id)
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMusicList(listId)
|
if (listId && token) fetchMusicList(listId)
|
||||||
}, [listId])
|
}, [listId, token])
|
||||||
return (
|
return (
|
||||||
<AudioContext.Provider
|
<AudioContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@@ -41,9 +41,16 @@ class TerminalManager {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
|
connectTerminal(
|
||||||
|
id: string,
|
||||||
|
callback: TerminalCallback,
|
||||||
|
config?: {
|
||||||
|
cols?: number
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
): WebSocket {
|
||||||
let conn = this.connections.get(id)
|
let conn = this.connections.get(id)
|
||||||
|
const { cols = 80, rows = 24 } = config || {}
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.protocol = url.protocol.replace('http', 'ws')
|
url.protocol = url.protocol.replace('http', 'ws')
|
||||||
@@ -74,6 +81,7 @@ class TerminalManager {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (conn) conn.isConnected = true
|
if (conn) conn.isConnected = true
|
||||||
|
this.sendResize(id, cols, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -111,6 +119,13 @@ class TerminalManager {
|
|||||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendResize(id: string, cols: number, rows: number) {
|
||||||
|
const conn = this.connections.get(id)
|
||||||
|
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||||
|
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalManager = new TerminalManager()
|
const terminalManager = new TerminalManager()
|
||||||
|
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
69
napcat.webui/src/hooks/use-preload-images.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// 全局图片缓存
|
||||||
|
const imageCache = new Map<string, HTMLImageElement>()
|
||||||
|
|
||||||
|
export function usePreloadImages(urls: string[]) {
|
||||||
|
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const isMounted = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true
|
||||||
|
|
||||||
|
// 检查是否所有图片都已缓存
|
||||||
|
const allCached = urls.every((url) => imageCache.has(url))
|
||||||
|
if (allCached) {
|
||||||
|
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
const loadedImages: Record<string, boolean> = {}
|
||||||
|
let pendingCount = urls.length
|
||||||
|
|
||||||
|
urls.forEach((url) => {
|
||||||
|
// 如果已经缓存,直接标记为已加载
|
||||||
|
if (imageCache.has(url)) {
|
||||||
|
loadedImages[url] = true
|
||||||
|
pendingCount--
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
if (!isMounted.current) return
|
||||||
|
loadedImages[url] = true
|
||||||
|
imageCache.set(url, img)
|
||||||
|
pendingCount--
|
||||||
|
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
if (!isMounted.current) return
|
||||||
|
loadedImages[url] = false
|
||||||
|
pendingCount--
|
||||||
|
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
setLoadedUrls(loadedImages)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false
|
||||||
|
}
|
||||||
|
}, [urls])
|
||||||
|
|
||||||
|
return { loadedUrls, isLoading }
|
||||||
|
}
|
@@ -79,7 +79,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen relative flex bg-danger-50 dark:bg-black items-stretch"
|
className="h-screen relative flex bg-primary-50 dark:bg-black items-stretch"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${b64img})`,
|
backgroundImage: `url(${b64img})`,
|
||||||
backgroundSize: 'cover'
|
backgroundSize: 'cover'
|
||||||
@@ -98,10 +98,10 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
|
||||||
'dark:bg-background dark:shadow-danger-100',
|
'dark:bg-background dark:shadow-primary-100',
|
||||||
'bg-background !bg-opacity-50',
|
'bg-background !bg-opacity-50',
|
||||||
'shadow-sm shadow-danger-50',
|
'shadow-sm shadow-primary-50',
|
||||||
'z-30 m-2 mb-0 sticky top-2 left-0'
|
'z-30 m-2 mb-0 sticky top-2 left-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import 'react-photo-view/dist/react-photo-view.css'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import App from '@/App.tsx'
|
import App from '@/App.tsx'
|
||||||
|
@@ -1,12 +1,19 @@
|
|||||||
import { Chip } from '@heroui/chip'
|
import { Card, CardBody } from '@heroui/card'
|
||||||
import { Image } from '@heroui/image'
|
import { Image } from '@heroui/image'
|
||||||
|
import { Link } from '@heroui/link'
|
||||||
|
import { Skeleton } from '@heroui/skeleton'
|
||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import clsx from 'clsx'
|
import { useMemo } from 'react'
|
||||||
|
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
|
||||||
|
import { IoDocument } from 'react-icons/io5'
|
||||||
|
|
||||||
import HoverTiltedCard from '@/components/hover_titled_card'
|
import HoverTiltedCard from '@/components/hover_titled_card'
|
||||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||||
import { title } from '@/components/primitives'
|
import RotatingText from '@/components/rotating_text'
|
||||||
|
|
||||||
|
import { usePreloadImages } from '@/hooks/use-preload-images'
|
||||||
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
@@ -14,54 +21,177 @@ import WebUIManager from '@/controllers/webui_manager'
|
|||||||
function VersionInfo() {
|
function VersionInfo() {
|
||||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-5">
|
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||||
<Chip
|
<div className="flex items-center gap-2">
|
||||||
startContent={
|
<div className="text-primary-500 drop-shadow-md">NapCat</div>
|
||||||
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
|
|
||||||
NapCat
|
|
||||||
</Chip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
error.message
|
error.message
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
data?.version
|
<RotatingText
|
||||||
|
texts={['WebUI', data?.version ?? '']}
|
||||||
|
mainClassName="overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md"
|
||||||
|
staggerFrom={'last'}
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '-120%' }}
|
||||||
|
staggerDuration={0.025}
|
||||||
|
splitLevelClassName="overflow-hidden"
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
|
||||||
|
rotationInterval={2000}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Chip>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const imageUrls = useMemo(
|
||||||
|
() => [
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
|
||||||
|
|
||||||
|
const getImageUrl = useMemo(
|
||||||
|
() => (baseUrl: string) => {
|
||||||
|
const theme = isDark ? 'dark' : 'light'
|
||||||
|
const fullUrl = baseUrl.replace(
|
||||||
|
/color_scheme=(?:light|dark)/,
|
||||||
|
`color_scheme=${theme}`
|
||||||
|
)
|
||||||
|
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
|
||||||
|
},
|
||||||
|
[isDark, isLoading, loadedUrls]
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderImage = useMemo(
|
||||||
|
() => (baseUrl: string, alt: string) => {
|
||||||
|
const imageUrl = getImageUrl(baseUrl)
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return <Skeleton className="h-16 rounded-lg" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className="flex-1 pointer-events-none select-none rounded-none"
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[getImageUrl]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>关于 NapCat WebUI</title>
|
<title>关于 NapCat WebUI</title>
|
||||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
|
||||||
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex flex-col md:flex-row items-center mb-6">
|
<div className="flex flex-col md:flex-row items-center">
|
||||||
<HoverTiltedCard imageSrc={logo} />
|
<HoverTiltedCard imageSrc={logo} overlayContent="" />
|
||||||
</div>
|
</div>
|
||||||
<VersionInfo />
|
<div className="flex-1 flex flex-col gap-2 py-2">
|
||||||
<div className="mb-6 flex flex-col items-center gap-4">
|
<VersionInfo />
|
||||||
<p
|
<div className="space-y-1">
|
||||||
className={clsx(
|
<p className="font-bold text-primary-400">NapCat 是什么?</p>
|
||||||
title({
|
<p className="text-default-800">
|
||||||
color: 'cyan',
|
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||||
shadow: true
|
Node模块提供给客户端的接口,实现Bot的功能.
|
||||||
}),
|
</p>
|
||||||
'!text-3xl'
|
<p className="font-bold text-primary-400">魔法版介绍</p>
|
||||||
)}
|
<p className="text-default-800">
|
||||||
>
|
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||||
NapCat Contributors
|
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||||
</p>
|
WebSocket 请求按照规范读取,
|
||||||
<Image
|
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||||
className="w-[600px] max-w-full pointer-events-none select-none"
|
</p>
|
||||||
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
|
</div>
|
||||||
alt="Contributors"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap justify-around">
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://qm.qq.com/q/F9cgs1N3Mc"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||||
|
<BsTencentQq size={16} />
|
||||||
|
</span>
|
||||||
|
<span>官方社群1</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://qm.qq.com/q/hSt0u9PVn"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||||
|
<BsTencentQq size={16} />
|
||||||
|
</span>
|
||||||
|
<span>官方社群2</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://t.me/MelodicMoonlight"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||||
|
<BsTelegram size={16} />
|
||||||
|
</span>
|
||||||
|
<span>Telegram</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
as={Link}
|
||||||
|
shadow="sm"
|
||||||
|
isPressable
|
||||||
|
isExternal
|
||||||
|
href="https://napcat.napneko.icu/"
|
||||||
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||||
|
<IoDocument size={16} />
|
||||||
|
</span>
|
||||||
|
<span>使用文档</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start gap-4">
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
{renderImage(
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||||
|
'Contributors'
|
||||||
|
)}
|
||||||
|
{renderImage(
|
||||||
|
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||||
|
'Activity Trends'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<NapCatRepoInfo />
|
<NapCatRepoInfo />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -41,7 +41,7 @@ export default function HttpDebug() {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color="danger"
|
color="primary"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant="shadow"
|
variant="shadow"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@@ -15,7 +15,7 @@ import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
|||||||
|
|
||||||
export default function WSDebug() {
|
export default function WSDebug() {
|
||||||
const url = new URL(window.location.origin)
|
const url = new URL(window.location.origin)
|
||||||
url.port = '3000'
|
url.port = '3001'
|
||||||
url.protocol = 'ws:'
|
url.protocol = 'ws:'
|
||||||
const defaultWsUrl = url.href
|
const defaultWsUrl = url.href
|
||||||
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
||||||
@@ -64,7 +64,7 @@ export default function WSDebug() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex-shrink-0 flex gap-2 col-span-2 md:col-span-1">
|
<div className="flex-shrink-0 flex gap-2 col-span-2 md:col-span-1">
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
onPress={handleConnect}
|
onPress={handleConnect}
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="full"
|
radius="full"
|
||||||
|
@@ -332,7 +332,7 @@ export default function FileManagerPage() {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
|
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -343,7 +343,7 @@ export default function FileManagerPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -354,7 +354,7 @@ export default function FileManagerPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
size="sm"
|
size="sm"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
@@ -365,7 +365,7 @@ export default function FileManagerPage() {
|
|||||||
<MdRefresh />
|
<MdRefresh />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -379,7 +379,7 @@ export default function FileManagerPage() {
|
|||||||
selectedFiles === 'all') && (
|
selectedFiles === 'all') && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={handleBatchDelete}
|
onPress={handleBatchDelete}
|
||||||
@@ -391,7 +391,7 @@ export default function FileManagerPage() {
|
|||||||
)
|
)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -406,7 +406,7 @@ export default function FileManagerPage() {
|
|||||||
)
|
)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={handleBatchDownload}
|
onPress={handleBatchDownload}
|
||||||
|
@@ -105,7 +105,7 @@ const DashboardIndexPage: React.FC = () => {
|
|||||||
<SystemStatusCard setArchInfo={setArchInfo} />
|
<SystemStatusCard setArchInfo={setArchInfo} />
|
||||||
</div>
|
</div>
|
||||||
<Networks />
|
<Networks />
|
||||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50">
|
<Card className="bg-opacity-60 shadow-sm shadow-primary-50">
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<Hitokoto />
|
<Hitokoto />
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
@@ -100,7 +100,7 @@ export default function TerminalPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-4">
|
<div className="flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -133,7 +133,7 @@ export default function TerminalPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-0 w-4 h-4 flex-shrink-0"
|
className="min-w-0 w-4 h-4 flex-shrink-0"
|
||||||
onPress={() => closeTerminal(tab.id)}
|
onPress={() => closeTerminal(tab.id)}
|
||||||
color={selectedTab === tab.id ? 'danger' : 'default'}
|
color={selectedTab === tab.id ? 'primary' : 'default'}
|
||||||
>
|
>
|
||||||
<IoClose />
|
<IoClose />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -143,7 +143,7 @@ export default function TerminalPage() {
|
|||||||
</TabList>
|
</TabList>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color="danger"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={createNewTerminal}
|
onPress={createNewTerminal}
|
||||||
|
@@ -1,46 +1,35 @@
|
|||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Suspense } from 'react'
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import DefaultLayout from '@/layouts/default'
|
import DefaultLayout from '@/layouts/default'
|
||||||
|
|
||||||
import DashboardIndexPage from './dashboard'
|
|
||||||
import AboutPage from './dashboard/about'
|
|
||||||
import ConfigPage from './dashboard/config'
|
|
||||||
import DebugPage from './dashboard/debug'
|
|
||||||
import HttpDebug from './dashboard/debug/http'
|
|
||||||
import WSDebug from './dashboard/debug/websocket'
|
|
||||||
import FileManagerPage from './dashboard/file_manager'
|
|
||||||
import LogsPage from './dashboard/logs'
|
|
||||||
import NetworkPage from './dashboard/network'
|
|
||||||
import TerminalPage from './dashboard/terminal'
|
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<AnimatePresence mode="wait">
|
<Suspense
|
||||||
<motion.div
|
fallback={
|
||||||
key={location.pathname}
|
<div className="flex justify-center px-10">
|
||||||
initial={{ opacity: 0, y: 50 }}
|
<Spinner />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</div>
|
||||||
exit={{ opacity: 0, y: -50 }}
|
}
|
||||||
transition={{ duration: 0.3 }}
|
>
|
||||||
>
|
<AnimatePresence mode="wait">
|
||||||
<Routes location={location} key={location.pathname}>
|
<motion.div
|
||||||
<Route element={<DashboardIndexPage />} path="/" />
|
key={location.pathname}
|
||||||
<Route element={<NetworkPage />} path="/network" />
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<Route element={<ConfigPage />} path="/config" />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Route element={<LogsPage />} path="/logs" />
|
transition={{
|
||||||
<Route element={<DebugPage />} path="/debug">
|
type: 'tween',
|
||||||
<Route path="ws" element={<WSDebug />} />
|
ease: 'easeInOut'
|
||||||
<Route path="http" element={<HttpDebug />} />
|
}}
|
||||||
</Route>
|
>
|
||||||
<Route element={<FileManagerPage />} path="/file_manager" />
|
<Outlet />
|
||||||
<Route element={<TerminalPage />} path="/terminal" />
|
</motion.div>
|
||||||
<Route element={<AboutPage />} path="/about" />
|
</AnimatePresence>
|
||||||
</Routes>
|
</Suspense>
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,111 +1,13 @@
|
|||||||
/* HarmonyOS Sans SC */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Harmony';
|
font-family: 'Aa偷吃可爱长大的';
|
||||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
src: url('/fonts/AaCute.woff') format('woff');
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Harmony';
|
font-family: 'JetBrains Mono';
|
||||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ubuntu */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Ubuntu';
|
font-family: 'JetBrains Mono';
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LibreBaskerville */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NotoSerifSC */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Serif SC';
|
|
||||||
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outfit */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Outfit';
|
|
||||||
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FiraCode */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fira Code';
|
|
||||||
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
@@ -6,35 +6,18 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
|
'Aa偷吃可爱长大的',
|
||||||
PingFang SC,
|
PingFang SC,
|
||||||
'Harmony',
|
|
||||||
Helvetica Neue,
|
Helvetica Neue,
|
||||||
Microsoft YaHei,
|
Microsoft YaHei,
|
||||||
sans-serif !important;
|
sans-serif !important;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-smooth: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.hm-medium {
|
|
||||||
font-family:
|
|
||||||
PingFang SC,
|
|
||||||
'Harmony',
|
|
||||||
Helvetica Neue,
|
|
||||||
Microsoft YaHei,
|
|
||||||
sans-serif !important;
|
|
||||||
@apply font-bold;
|
|
||||||
}
|
|
||||||
.font-ubuntu {
|
|
||||||
font-family: 'Ubuntu', sans-serif;
|
|
||||||
}
|
|
||||||
.font-outfit {
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
}
|
|
||||||
.font-libre {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
}
|
|
||||||
.font-noto-serif {
|
|
||||||
font-family: 'Noto Serif SC', serif;
|
|
||||||
}
|
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
@@ -105,7 +88,7 @@ body {
|
|||||||
.context-view.monaco-menu-container * {
|
.context-view.monaco-menu-container * {
|
||||||
font-family:
|
font-family:
|
||||||
PingFang SC,
|
PingFang SC,
|
||||||
'Harmony',
|
'Aa偷吃可爱长大的',
|
||||||
Helvetica Neue,
|
Helvetica Neue,
|
||||||
Microsoft YaHei,
|
Microsoft YaHei,
|
||||||
sans-serif !important;
|
sans-serif !important;
|
||||||
@@ -116,4 +99,4 @@ body {
|
|||||||
}
|
}
|
||||||
.ql-editor img {
|
.ql-editor img {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
}
|
}
|
@@ -195,7 +195,7 @@ export interface OneBot11GroupUpload extends NoticeBase {
|
|||||||
name: string
|
name: string
|
||||||
/** 文件大小(字节数) */
|
/** 文件大小(字节数) */
|
||||||
size: number
|
size: number
|
||||||
/** busid(目前不清楚有什么作用) */
|
/** busid 无作用 */
|
||||||
busid: number
|
busid: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,5 +13,74 @@ export default {
|
|||||||
extend: {}
|
extend: {}
|
||||||
},
|
},
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
plugins: [heroui()]
|
plugins: [
|
||||||
|
heroui({
|
||||||
|
themes: {
|
||||||
|
light: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#f31260',
|
||||||
|
foreground: '#fff',
|
||||||
|
50: '#fee7ef',
|
||||||
|
100: '#fdd0df',
|
||||||
|
200: '#faa0bf',
|
||||||
|
300: '#f871a0',
|
||||||
|
400: '#f54180',
|
||||||
|
500: '#f31260',
|
||||||
|
600: '#c20e4d',
|
||||||
|
700: '#920b3a',
|
||||||
|
800: '#610726',
|
||||||
|
900: '#310413'
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
DEFAULT: '#DB3694',
|
||||||
|
foreground: '#fff',
|
||||||
|
50: '#FEEAF6',
|
||||||
|
100: '#FDD7DD',
|
||||||
|
200: '#FBAFC4',
|
||||||
|
300: '#F485AE',
|
||||||
|
400: '#E965A3',
|
||||||
|
500: '#DB3694',
|
||||||
|
600: '#BC278B',
|
||||||
|
700: '#9D1B7F',
|
||||||
|
800: '#7F1170',
|
||||||
|
900: '#690A66'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#f31260',
|
||||||
|
foreground: '#fff',
|
||||||
|
50: '#310413',
|
||||||
|
100: '#610726',
|
||||||
|
200: '#920b3a',
|
||||||
|
300: '#c20e4d',
|
||||||
|
400: '#f31260',
|
||||||
|
500: '#f54180',
|
||||||
|
600: '#f871a0',
|
||||||
|
700: '#faa0bf',
|
||||||
|
800: '#fdd0df',
|
||||||
|
900: '#fee7ef'
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
DEFAULT: '#DB3694',
|
||||||
|
foreground: '#fff',
|
||||||
|
50: '#690A66',
|
||||||
|
100: '#7F1170',
|
||||||
|
200: '#9D1B7F',
|
||||||
|
300: '#BC278B',
|
||||||
|
400: '#DB3694',
|
||||||
|
500: '#E965A3',
|
||||||
|
600: '#F485AE',
|
||||||
|
700: '#FBAFC4',
|
||||||
|
800: '#FDD7DD',
|
||||||
|
900: '#FEEAF6'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.4.20",
|
"version": "4.5.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||||
|
@@ -2,73 +2,73 @@ import path from 'node:path';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import type { NapCatCore } from '@/core';
|
import type { NapCatCore } from '@/core';
|
||||||
import json5 from 'json5';
|
import json5 from 'json5';
|
||||||
|
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||||
|
|
||||||
export abstract class ConfigBase<T> {
|
export abstract class ConfigBase<T> {
|
||||||
name: string;
|
name: string;
|
||||||
core: NapCatCore;
|
core: NapCatCore;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
configData: T = {} as T;
|
configData: T = {} as T;
|
||||||
|
ajv: Ajv;
|
||||||
|
validate: ValidateFunction<T>;
|
||||||
|
|
||||||
protected constructor(name: string, core: NapCatCore, configPath: string, copy_default: boolean = true) {
|
protected constructor(name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.core = core;
|
this.core = core;
|
||||||
this.configPath = configPath;
|
this.configPath = configPath;
|
||||||
|
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||||
|
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||||
fs.mkdirSync(this.configPath, { recursive: true });
|
fs.mkdirSync(this.configPath, { recursive: true });
|
||||||
this.read(copy_default);
|
this.read();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getKeys(): string[] | null {
|
getConfigPath(pathName?: string): string {
|
||||||
// 决定 key 在json配置文件中的顺序
|
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||||
return null;
|
return path.join(this.configPath, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfigPath(pathName: string | undefined): string {
|
read(): T {
|
||||||
if (!pathName) {
|
|
||||||
const filename = `${this.name}.json`;
|
|
||||||
const mainPath = this.core.context.pathWrapper.binaryPath;
|
|
||||||
return path.join(mainPath, 'config', filename);
|
|
||||||
} else {
|
|
||||||
const filename = `${this.name}_${pathName}.json`;
|
|
||||||
return path.join(this.configPath, filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
read(copy_default: boolean = true): T {
|
|
||||||
|
|
||||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
if (!fs.existsSync(configPath) && copy_default) {
|
const defaultConfigPath = this.getConfigPath();
|
||||||
try {
|
if (!fs.existsSync(configPath)) {
|
||||||
fs.writeFileSync(configPath, fs.readFileSync(this.getConfigPath(undefined), 'utf-8'));
|
if (fs.existsSync(defaultConfigPath)) {
|
||||||
this.core.context.logger.log('[Core] [Config] 配置文件创建成功!\n');
|
this.configData = this.loadConfig(defaultConfigPath);
|
||||||
} catch (e: unknown) {
|
|
||||||
this.core.context.logger.logError('[Core] [Config] 创建配置文件时发生错误:', (e as Error).message);
|
|
||||||
}
|
}
|
||||||
} else if (!fs.existsSync(configPath) && !copy_default) {
|
this.save();
|
||||||
fs.writeFileSync(configPath, '{}');
|
return this.configData;
|
||||||
}
|
}
|
||||||
|
return this.loadConfig(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(configPath: string): T {
|
||||||
try {
|
try {
|
||||||
this.configData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
let newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
this.validate(newConfigData);
|
||||||
|
this.configData = newConfigData;
|
||||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||||
return this.configData;
|
return this.configData;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof SyntaxError) {
|
this.handleError(e, '读取配置文件时发生错误');
|
||||||
this.core.context.logger.logError('[Core] [Config] 配置文件格式错误,请检查配置文件:', e.message);
|
|
||||||
} else {
|
|
||||||
this.core.context.logger.logError('[Core] [Config] 读取配置文件时发生错误:', (e as Error).message);
|
|
||||||
}
|
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
save(newConfigData: T = this.configData): void {
|
||||||
save(newConfigData: T = this.configData) {
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
const selfInfo = this.core.selfInfo;
|
this.validate(newConfigData);
|
||||||
this.configData = newConfigData;
|
this.configData = newConfigData;
|
||||||
const configPath = this.getConfigPath(selfInfo.uin);
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
|
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, (e as Error).message);
|
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private handleError(e: unknown, message: string): void {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
|
||||||
|
} else {
|
||||||
|
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.4.20';
|
export const napCatVersion = '4.5.9';
|
||||||
|
@@ -34,6 +34,8 @@ export class NTQQFileApi {
|
|||||||
core: NapCatCore;
|
core: NapCatCore;
|
||||||
rkeyManager: RkeyManager;
|
rkeyManager: RkeyManager;
|
||||||
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint }> | undefined;
|
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint }> | undefined;
|
||||||
|
private fetchRkeyFailures: number = 0;
|
||||||
|
private readonly MAX_RKEY_FAILURES: number = 8;
|
||||||
|
|
||||||
constructor(context: InstanceContext, core: NapCatCore) {
|
constructor(context: InstanceContext, core: NapCatCore) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -45,6 +47,22 @@ export class NTQQFileApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchRkeyWithRetry() {
|
||||||
|
if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
|
||||||
|
throw new Error('Native.FetchRkey 已被禁用');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
|
||||||
|
this.fetchRkeyFailures = 0; // Reset failures on success
|
||||||
|
return ret;
|
||||||
|
} catch (error) {
|
||||||
|
this.fetchRkeyFailures++;
|
||||||
|
this.context.logger.logError('FetchRkey 失败', (error as Error).message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async copyFile(filePath: string, destPath: string) {
|
async copyFile(filePath: string, destPath: string) {
|
||||||
await this.core.util.copyFile(filePath, destPath);
|
await this.core.util.copyFile(filePath, destPath);
|
||||||
}
|
}
|
||||||
@@ -182,7 +200,6 @@ export class NTQQFileApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.deleteAfterSentFiles.push(thumbPath);
|
context.deleteAfterSentFiles.push(thumbPath);
|
||||||
|
|
||||||
const thumbSize = (await fsPromises.stat(thumbPath)).size;
|
const thumbSize = (await fsPromises.stat(thumbPath)).size;
|
||||||
const thumbMd5 = await calculateFileMD5(thumbPath);
|
const thumbMd5 = await calculateFileMD5(thumbPath);
|
||||||
context.deleteAfterSentFiles.push(thumbPath);
|
context.deleteAfterSentFiles.push(thumbPath);
|
||||||
@@ -421,7 +438,7 @@ export class NTQQFileApi {
|
|||||||
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
||||||
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
||||||
if (rkey_expired_private || rkey_expired_group) {
|
if (rkey_expired_private || rkey_expired_group) {
|
||||||
this.packetRkey = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
|
this.packetRkey = await this.fetchRkeyWithRetry();
|
||||||
}
|
}
|
||||||
if (this.packetRkey && this.packetRkey.length > 0) {
|
if (this.packetRkey && this.packetRkey.length > 0) {
|
||||||
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
|
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
|
||||||
@@ -430,7 +447,7 @@ export class NTQQFileApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.context.logger.logError('获取rkey失败', (error as Error).message);
|
this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rkeyData.online_rkey) {
|
if (!rkeyData.online_rkey) {
|
||||||
@@ -439,11 +456,11 @@ export class NTQQFileApi {
|
|||||||
rkeyData.group_rkey = tempRkeyData.group_rkey;
|
rkeyData.group_rkey = tempRkeyData.group_rkey;
|
||||||
rkeyData.private_rkey = tempRkeyData.private_rkey;
|
rkeyData.private_rkey = tempRkeyData.private_rkey;
|
||||||
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
|
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
|
||||||
} catch (e) {
|
} catch (error: unknown) {
|
||||||
this.context.logger.logDebug('获取rkey失败 Fallback Old Mode', e);
|
this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 进行 fallback.rkey 模式
|
||||||
return rkeyData;
|
return rkeyData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,11 +1,21 @@
|
|||||||
import { ConfigBase } from '@/common/config-base';
|
import { ConfigBase } from '@/common/config-base';
|
||||||
import napCatDefaultConfig from '@/core/external/napcat.json';
|
|
||||||
import { NapCatCore } from '@/core';
|
import { NapCatCore } from '@/core';
|
||||||
|
import { Type, Static } from '@sinclair/typebox';
|
||||||
|
import { AnySchema } from 'ajv';
|
||||||
|
|
||||||
export type NapCatConfig = typeof napCatDefaultConfig;
|
export const NapcatConfigSchema = Type.Object({
|
||||||
|
fileLog: Type.Boolean({ default: false }),
|
||||||
|
consoleLog: Type.Boolean({ default: true }),
|
||||||
|
fileLogLevel: Type.String({ default: 'debug' }),
|
||||||
|
consoleLogLevel: Type.String({ default: 'info' }),
|
||||||
|
packetBackend: Type.String({ default: 'auto' }),
|
||||||
|
packetServer: Type.String({ default: '' })
|
||||||
|
});
|
||||||
|
|
||||||
export class NapCatConfigLoader extends ConfigBase<NapCatConfig> {
|
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||||
constructor(core: NapCatCore, configPath: string) {
|
|
||||||
super('napcat', core, configPath);
|
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||||
|
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||||
|
super('napcat', core, configPath, schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -25,7 +25,7 @@ import fs from 'node:fs';
|
|||||||
import { hostname, systemName, systemVersion } from '@/common/system';
|
import { hostname, systemName, systemVersion } from '@/common/system';
|
||||||
import { NTEventWrapper } from '@/common/event';
|
import { NTEventWrapper } from '@/common/event';
|
||||||
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
|
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
|
||||||
import { NapCatConfigLoader } from '@/core/helper/config';
|
import { NapCatConfigLoader, NapcatConfigSchema } from '@/core/helper/config';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
||||||
import { proxiedListenerOf } from '@/common/proxy-handler';
|
import { proxiedListenerOf } from '@/common/proxy-handler';
|
||||||
@@ -99,7 +99,7 @@ export class NapCatCore {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||||
this.eventWrapper = new NTEventWrapper(context.session);
|
this.eventWrapper = new NTEventWrapper(context.session);
|
||||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath);
|
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath,NapcatConfigSchema);
|
||||||
this.apis = {
|
this.apis = {
|
||||||
FileApi: new NTQQFileApi(this.context, this),
|
FileApi: new NTQQFileApi(this.context, this),
|
||||||
SystemApi: new NTQQSystemApi(this.context, this),
|
SystemApi: new NTQQSystemApi(this.context, this),
|
||||||
|
@@ -1,20 +1,22 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { PacketContext } from '@/core/packet/context/packetContext';
|
import {PacketContext} from '@/core/packet/context/packetContext';
|
||||||
import * as trans from '@/core/packet/transformer';
|
import * as trans from '@/core/packet/transformer';
|
||||||
import { PacketMsg } from '@/core/packet/message/message';
|
import {PacketMsg} from '@/core/packet/message/message';
|
||||||
import {
|
import {
|
||||||
PacketMsgFileElement,
|
PacketMsgFileElement,
|
||||||
PacketMsgPicElement,
|
PacketMsgPicElement,
|
||||||
PacketMsgPttElement,
|
PacketMsgPttElement,
|
||||||
PacketMsgVideoElement
|
PacketMsgVideoElement
|
||||||
} from '@/core/packet/message/element';
|
} from '@/core/packet/message/element';
|
||||||
import { ChatType } from '@/core';
|
import {ChatType, MsgSourceType, NTMsgType, RawMessage} from '@/core';
|
||||||
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
|
import {MiniAppRawData, MiniAppReqParams} from '@/core/packet/entities/miniApp';
|
||||||
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
|
import {AIVoiceChatType} from '@/core/packet/entities/aiChat';
|
||||||
import { NapProtoDecodeStructType, NapProtoEncodeStructType } from '@napneko/nap-proto-core';
|
import {NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg} from '@napneko/nap-proto-core';
|
||||||
import { IndexNode, MsgInfo } from '@/core/packet/transformer/proto';
|
import {IndexNode, LongMsgResult, MsgInfo} from '@/core/packet/transformer/proto';
|
||||||
import { OidbPacket } from '@/core/packet/transformer/base';
|
import {OidbPacket} from '@/core/packet/transformer/base';
|
||||||
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
|
import {ImageOcrResult} from '@/core/packet/entities/ocrResult';
|
||||||
|
import {gunzipSync} from 'zlib';
|
||||||
|
import {PacketMsgConverter} from '@/core/packet/message/converter';
|
||||||
|
|
||||||
export class PacketOperationContext {
|
export class PacketOperationContext {
|
||||||
private readonly context: PacketContext;
|
private readonly context: PacketContext;
|
||||||
@@ -57,10 +59,10 @@ export class PacketOperationContext {
|
|||||||
const res = trans.GetStrangerInfo.parse(resp);
|
const res = trans.GetStrangerInfo.parse(resp);
|
||||||
const extBigInt = BigInt(res.data.status.value);
|
const extBigInt = BigInt(res.data.status.value);
|
||||||
if (extBigInt <= 10n) {
|
if (extBigInt <= 10n) {
|
||||||
return { status: Number(extBigInt) * 10, ext_status: 0 };
|
return {status: Number(extBigInt) * 10, ext_status: 0};
|
||||||
}
|
}
|
||||||
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
|
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
|
||||||
return { status: 10, ext_status: status };
|
return {status: 10, ext_status: status};
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -77,13 +79,13 @@ export class PacketOperationContext {
|
|||||||
const reqList = msg.flatMap(m =>
|
const reqList = msg.flatMap(m =>
|
||||||
m.msg.map(e => {
|
m.msg.map(e => {
|
||||||
if (e instanceof PacketMsgPicElement) {
|
if (e instanceof PacketMsgPicElement) {
|
||||||
return this.context.highway.uploadImage({ chatType, peerUid }, e);
|
return this.context.highway.uploadImage({chatType, peerUid}, e);
|
||||||
} else if (e instanceof PacketMsgVideoElement) {
|
} else if (e instanceof PacketMsgVideoElement) {
|
||||||
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
|
return this.context.highway.uploadVideo({chatType, peerUid}, e);
|
||||||
} else if (e instanceof PacketMsgPttElement) {
|
} else if (e instanceof PacketMsgPttElement) {
|
||||||
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
|
return this.context.highway.uploadPtt({chatType, peerUid}, e);
|
||||||
} else if (e instanceof PacketMsgFileElement) {
|
} else if (e instanceof PacketMsgFileElement) {
|
||||||
return this.context.highway.uploadFile({ chatType, peerUid }, e);
|
return this.context.highway.uploadFile({chatType, peerUid}, e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
@@ -116,6 +118,13 @@ export class PacketOperationContext {
|
|||||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||||
|
const req = trans.DownloadGroupImage.build(groupUin, node);
|
||||||
|
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||||
|
const res = trans.DownloadImage.parse(resp);
|
||||||
|
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
async ImageOCR(imgUrl: string) {
|
async ImageOCR(imgUrl: string) {
|
||||||
const req = trans.ImageOCR.build(imgUrl);
|
const req = trans.ImageOCR.build(imgUrl);
|
||||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||||
@@ -195,4 +204,74 @@ export class PacketOperationContext {
|
|||||||
return res.msgInfo;
|
return res.msgInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async FetchForwardMsg(res_id: string): Promise<RawMessage[]> {
|
||||||
|
const req = trans.DownloadForwardMsg.build(this.context.napcore.basicInfo.uid, res_id);
|
||||||
|
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||||
|
const res = trans.DownloadForwardMsg.parse(resp);
|
||||||
|
const inflate = gunzipSync(res.result.payload);
|
||||||
|
const result = new NapProtoMsg(LongMsgResult).decode(inflate);
|
||||||
|
|
||||||
|
const main = result.action.find((r) => r.actionCommand === 'MultiMsg');
|
||||||
|
if (!main?.actionData.msgBody) {
|
||||||
|
throw new Error('msgBody is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesPromises = main.actionData.msgBody.map(async (msg) => {
|
||||||
|
if (!msg?.body?.richText?.elems) {
|
||||||
|
throw new Error('msg.body.richText.elems is empty');
|
||||||
|
}
|
||||||
|
const rawChains = new PacketMsgConverter().packetMsgToRaw(msg?.body?.richText?.elems);
|
||||||
|
const elements = await Promise.all(
|
||||||
|
rawChains.map(async ([element, rawElem]) => {
|
||||||
|
if (element.picElement && rawElem?.commonElem?.pbElem) {
|
||||||
|
const extra = new NapProtoMsg(MsgInfo).decode(rawElem.commonElem.pbElem);
|
||||||
|
const index = extra?.msgInfoBody[0]?.index;
|
||||||
|
if (msg?.responseHead.grp !== undefined) {
|
||||||
|
const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
|
||||||
|
element.picElement = {
|
||||||
|
...element.picElement,
|
||||||
|
originImageUrl: await this.GetGroupImageUrl(groupUin, index!)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
element.picElement = {
|
||||||
|
...element.picElement,
|
||||||
|
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
chatType: ChatType.KCHATTYPEGROUP,
|
||||||
|
elements: elements,
|
||||||
|
guildId: '',
|
||||||
|
isOnlineMsg: false,
|
||||||
|
msgId: '7467703692092974645', // TODO: no necessary
|
||||||
|
msgRandom: '0',
|
||||||
|
msgSeq: String(msg.contentHead.sequence ?? 0),
|
||||||
|
msgTime: String(msg.contentHead.timeStamp ?? 0),
|
||||||
|
msgType: NTMsgType.KMSGTYPEMIX,
|
||||||
|
parentMsgIdList: [],
|
||||||
|
parentMsgPeer: {
|
||||||
|
chatType: ChatType.KCHATTYPEGROUP,
|
||||||
|
peerUid: String(msg?.responseHead.grp?.groupUin ?? 0),
|
||||||
|
},
|
||||||
|
peerName: '',
|
||||||
|
peerUid: '1094950020',
|
||||||
|
peerUin: '1094950020',
|
||||||
|
recallTime: '0',
|
||||||
|
records: [],
|
||||||
|
sendNickName: msg?.responseHead.grp?.memberName ?? '',
|
||||||
|
sendRemarkName: msg?.responseHead.grp?.memberName ?? '',
|
||||||
|
senderUid: '',
|
||||||
|
senderUin: '1094950020',
|
||||||
|
sourceType: MsgSourceType.K_DOWN_SOURCETYPE_UNKNOWN,
|
||||||
|
subMsgType: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return await Promise.all(messagesPromises);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Peer,
|
|
||||||
ChatType,
|
ChatType,
|
||||||
ElementType,
|
ElementType,
|
||||||
MessageElement,
|
MessageElement,
|
||||||
|
Peer,
|
||||||
RawMessage,
|
RawMessage,
|
||||||
SendArkElement,
|
SendArkElement,
|
||||||
SendFaceElement,
|
SendFaceElement,
|
||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
PacketMsgVideoElement,
|
PacketMsgVideoElement,
|
||||||
PacketMultiMsgElement
|
PacketMultiMsgElement
|
||||||
} from '@/core/packet/message/element';
|
} from '@/core/packet/message/element';
|
||||||
import { PacketMsg, PacketSendMsgElement } from '@/core/packet/message/message';
|
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
|
||||||
|
import {NapProtoDecodeStructType} from '@napneko/nap-proto-core';
|
||||||
|
import {Elem} from '@/core/packet/transformer/proto';
|
||||||
|
|
||||||
const SupportedElementTypes = [
|
const SupportedElementTypes = [
|
||||||
ElementType.TEXT,
|
ElementType.TEXT,
|
||||||
@@ -154,4 +156,16 @@ export class PacketMsgConverter {
|
|||||||
}).filter((e) => e !== null)
|
}).filter((e) => e !== null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packetMsgToRaw(msg: NapProtoDecodeStructType<typeof Elem>[]): [MessageElement, NapProtoDecodeStructType<typeof Elem> | null][] {
|
||||||
|
const converters = [PacketMsgTextElement.parseElement,
|
||||||
|
PacketMsgAtElement.parseElement, PacketMsgReplyElement.parseElement, PacketMsgPicElement.parseElement];
|
||||||
|
return msg.map((element) => {
|
||||||
|
for (const converter of converters) {
|
||||||
|
const result = converter(element);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter((e) => e !== null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,20 +1,22 @@
|
|||||||
import * as zlib from 'node:zlib';
|
import * as zlib from 'node:zlib';
|
||||||
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
|
import {NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg} from '@napneko/nap-proto-core';
|
||||||
import {
|
import {
|
||||||
CustomFace,
|
CustomFace,
|
||||||
Elem,
|
Elem,
|
||||||
|
FileExtra,
|
||||||
|
GroupFileExtra,
|
||||||
MarkdownData,
|
MarkdownData,
|
||||||
MentionExtra,
|
MentionExtra,
|
||||||
|
MsgInfo,
|
||||||
NotOnlineImage,
|
NotOnlineImage,
|
||||||
|
OidbSvcTrpcTcp0XE37_800Response,
|
||||||
QBigFaceExtra,
|
QBigFaceExtra,
|
||||||
QSmallFaceExtra,
|
QSmallFaceExtra,
|
||||||
MsgInfo,
|
|
||||||
OidbSvcTrpcTcp0XE37_800Response,
|
|
||||||
FileExtra,
|
|
||||||
GroupFileExtra
|
|
||||||
} from '@/core/packet/transformer/proto';
|
} from '@/core/packet/transformer/proto';
|
||||||
import {
|
import {
|
||||||
|
ElementType,
|
||||||
FaceType,
|
FaceType,
|
||||||
|
MessageElement,
|
||||||
NTMsgAtType,
|
NTMsgAtType,
|
||||||
PicType,
|
PicType,
|
||||||
SendArkElement,
|
SendArkElement,
|
||||||
@@ -29,8 +31,11 @@ import {
|
|||||||
SendTextElement,
|
SendTextElement,
|
||||||
SendVideoElement
|
SendVideoElement
|
||||||
} from '@/core';
|
} from '@/core';
|
||||||
import { ForwardMsgBuilder } from '@/common/forward-msg-builder';
|
import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
|
||||||
import { PacketMsg, PacketSendMsgElement } from '@/core/packet/message/message';
|
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
|
||||||
|
|
||||||
|
export type ParseElementFnR = [MessageElement, NapProtoDecodeStructType<typeof Elem> | null] | undefined;
|
||||||
|
type ParseElementFn = (elem: NapProtoDecodeStructType<typeof Elem>) => ParseElementFnR;
|
||||||
|
|
||||||
// raw <-> packet
|
// raw <-> packet
|
||||||
// TODO: SendStructLongMsgElement
|
// TODO: SendStructLongMsgElement
|
||||||
@@ -51,6 +56,8 @@ export abstract class IPacketMsgElement<T extends PacketSendMsgElement> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static parseElement: ParseElementFn;
|
||||||
|
|
||||||
toPreview(): string {
|
toPreview(): string {
|
||||||
return '[暂不支持该消息类型喵~]';
|
return '[暂不支持该消息类型喵~]';
|
||||||
}
|
}
|
||||||
@@ -72,11 +79,30 @@ export class PacketMsgTextElement extends IPacketMsgElement<SendTextElement> {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
|
||||||
|
if (elem.text?.str && (elem.text?.attr6Buf === undefined || elem.text?.attr6Buf?.length === 0)) {
|
||||||
|
return [{
|
||||||
|
textElement: {
|
||||||
|
content: elem.text?.str,
|
||||||
|
atType: NTMsgAtType.ATTYPEUNKNOWN,
|
||||||
|
atUid: '',
|
||||||
|
atTinyId: '',
|
||||||
|
atNtUid: '',
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
override toPreview(): string {
|
override toPreview(): string {
|
||||||
return this.text;
|
return this.text;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class PacketMsgAtElement extends PacketMsgTextElement {
|
export class PacketMsgAtElement extends PacketMsgTextElement {
|
||||||
targetUid: string;
|
targetUid: string;
|
||||||
atAll: boolean;
|
atAll: boolean;
|
||||||
@@ -101,6 +127,22 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
|
|||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
|
||||||
|
if (elem.text?.str && (elem.text?.attr6Buf?.length ?? 100) >= 11) {
|
||||||
|
return [{
|
||||||
|
textElement: {
|
||||||
|
content: elem.text?.str,
|
||||||
|
atType: NTMsgAtType.ATTYPEONE,
|
||||||
|
atUid: String(Buffer.from(elem.text!.attr6Buf!).readUInt32BE(7)), // FIXME: hack
|
||||||
|
atTinyId: '',
|
||||||
|
atNtUid: '',
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
|
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
|
||||||
@@ -137,21 +179,28 @@ export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
|
|||||||
pbReserve: {
|
pbReserve: {
|
||||||
messageId: this.messageId,
|
messageId: this.messageId,
|
||||||
},
|
},
|
||||||
toUin: BigInt(0),
|
toUin: BigInt(this.targetUin),
|
||||||
|
type: 1,
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
text: this.isGroupReply ? {
|
|
||||||
str: 'nya~',
|
|
||||||
pbReserve: new NapProtoMsg(MentionExtra).encode({
|
|
||||||
type: this.targetUin === 0 ? 1 : 2,
|
|
||||||
uin: 0,
|
|
||||||
field5: 0,
|
|
||||||
uid: String(this.targetUid),
|
|
||||||
}),
|
|
||||||
} : undefined,
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
|
||||||
|
if (elem.srcMsg && elem.srcMsg.pbReserve) {
|
||||||
|
const reserve = elem.srcMsg.pbReserve;
|
||||||
|
return [{
|
||||||
|
replyElement: {
|
||||||
|
replayMsgSeq: String(reserve.friendSeq ?? elem.srcMsg?.origSeqs?.[0] ?? 0),
|
||||||
|
replayMsgId: String(reserve.messageId ?? 0),
|
||||||
|
senderUin: String(elem?.srcMsg ?? 0)
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
override toPreview(): string {
|
override toPreview(): string {
|
||||||
return '[回复消息]';
|
return '[回复消息]';
|
||||||
}
|
}
|
||||||
@@ -207,6 +256,46 @@ export class PacketMsgFaceElement extends IPacketMsgElement<SendFaceElement> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
|
||||||
|
if (elem.face?.index) {
|
||||||
|
return [{
|
||||||
|
faceElement: {
|
||||||
|
faceIndex: elem.face.index,
|
||||||
|
faceType: FaceType.Normal
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
if (elem?.commonElem?.serviceType === 37 && elem?.commonElem?.pbElem) {
|
||||||
|
const qface = new NapProtoMsg(QBigFaceExtra).decode(elem?.commonElem?.pbElem);
|
||||||
|
if (qface?.faceId) {
|
||||||
|
return [{
|
||||||
|
faceElement: {
|
||||||
|
faceIndex: qface.faceId,
|
||||||
|
faceType: FaceType.Normal
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (elem?.commonElem?.serviceType === 33 && elem?.commonElem?.pbElem) {
|
||||||
|
const qface = new NapProtoMsg(QSmallFaceExtra).decode(elem?.commonElem?.pbElem);
|
||||||
|
if (qface?.faceId) {
|
||||||
|
return [{
|
||||||
|
faceElement: {
|
||||||
|
faceIndex: qface.faceId,
|
||||||
|
faceType: FaceType.Normal
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
override toPreview(): string {
|
override toPreview(): string {
|
||||||
return '[表情]';
|
return '[表情]';
|
||||||
}
|
}
|
||||||
@@ -295,6 +384,60 @@ export class PacketMsgPicElement extends IPacketMsgElement<SendPicElement> {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
|
||||||
|
if (elem?.commonElem?.serviceType === 48 || [10, 20].includes(elem?.commonElem?.businessType ?? 0)) {
|
||||||
|
const extra = new NapProtoMsg(MsgInfo).decode(elem.commonElem!.pbElem!);
|
||||||
|
const msgInfoBody = extra.msgInfoBody[0];
|
||||||
|
const index = msgInfoBody?.index;
|
||||||
|
return [{
|
||||||
|
picElement: {
|
||||||
|
fileSize: index?.info.fileSize ?? 0,
|
||||||
|
picWidth: index?.info?.width ?? 0,
|
||||||
|
picHeight: index?.info?.height ?? 0,
|
||||||
|
fileName: index?.info?.fileHash ?? '',
|
||||||
|
sourcePath: '',
|
||||||
|
original: false,
|
||||||
|
picType: PicType.NEWPIC_APNG,
|
||||||
|
fileUuid: '',
|
||||||
|
fileSubId: '',
|
||||||
|
thumbFileSize: 0,
|
||||||
|
summary: '[图片]',
|
||||||
|
thumbPath: new Map(),
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
}, elem];
|
||||||
|
}
|
||||||
|
if (elem?.notOnlineImage) {
|
||||||
|
const img = elem?.notOnlineImage; // url in originImageUrl
|
||||||
|
const preImg: MessageElement = {
|
||||||
|
picElement: {
|
||||||
|
fileSize: img.fileLen ?? 0,
|
||||||
|
picWidth: img.picWidth ?? 0,
|
||||||
|
picHeight: img.picHeight ?? 0,
|
||||||
|
fileName: Buffer.from(img.picMd5!).toString('hex') ?? '',
|
||||||
|
sourcePath: '',
|
||||||
|
original: false,
|
||||||
|
picType: PicType.NEWPIC_APNG,
|
||||||
|
fileUuid: '',
|
||||||
|
fileSubId: '',
|
||||||
|
thumbFileSize: 0,
|
||||||
|
summary: '[图片]',
|
||||||
|
thumbPath: new Map(),
|
||||||
|
},
|
||||||
|
elementType: ElementType.UNKNOWN,
|
||||||
|
elementId: '',
|
||||||
|
};
|
||||||
|
if (img.origUrl?.includes('&fileid=')) {
|
||||||
|
preImg.picElement!.originImageUrl = `https://multimedia.nt.qq.com.cn${img.origUrl}`;
|
||||||
|
} else {
|
||||||
|
preImg.picElement!.originImageUrl = `https://gchat.qpic.cn${img.origUrl}`;
|
||||||
|
}
|
||||||
|
return [preImg, elem];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
override toPreview(): string {
|
override toPreview(): string {
|
||||||
return this.summary;
|
return this.summary;
|
||||||
}
|
}
|
||||||
|
50
src/core/packet/transformer/highway/DownloadGroupImage.ts
Normal file
50
src/core/packet/transformer/highway/DownloadGroupImage.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as proto from '@/core/packet/transformer/proto';
|
||||||
|
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
|
||||||
|
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
|
||||||
|
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
|
||||||
|
import { IndexNode } from '@/core/packet/transformer/proto';
|
||||||
|
|
||||||
|
class DownloadGroupImage extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
build(group_uin: number, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
|
||||||
|
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
|
||||||
|
reqHead: {
|
||||||
|
common: {
|
||||||
|
requestId: 1,
|
||||||
|
command: 200
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
requestType: 2,
|
||||||
|
businessType: 1,
|
||||||
|
sceneType: 2,
|
||||||
|
group: {
|
||||||
|
groupUin: group_uin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
agentType: 2,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
node: node,
|
||||||
|
download: {
|
||||||
|
video: {
|
||||||
|
busiType: 0,
|
||||||
|
sceneType: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return OidbBase.build(0x11C4, 200, body, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(data: Buffer) {
|
||||||
|
const oidbBody = OidbBase.parse(data).body;
|
||||||
|
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new DownloadGroupImage();
|
@@ -14,7 +14,7 @@ class DownloadImage extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
|
|||||||
reqHead: {
|
reqHead: {
|
||||||
common: {
|
common: {
|
||||||
requestId: 1,
|
requestId: 1,
|
||||||
command: 100
|
command: 200
|
||||||
},
|
},
|
||||||
scene: {
|
scene: {
|
||||||
requestType: 2,
|
requestType: 2,
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user