mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
85 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
b32f9fa397 | ||
![]() |
80593730ae | ||
![]() |
090d54a78d | ||
![]() |
b7d1fb181c | ||
![]() |
6e56693ca7 | ||
![]() |
7403db9b20 | ||
![]() |
9d167cd883 | ||
![]() |
197eec40ad | ||
![]() |
07819a6618 | ||
![]() |
b72156866d | ||
![]() |
59a7d12a8c | ||
![]() |
179351b23a | ||
![]() |
790809e8e5 | ||
![]() |
1414a8a8c9 | ||
![]() |
9ab41734a5 | ||
![]() |
03cace2ea1 | ||
![]() |
c7371ab869 | ||
![]() |
b32d4b618c | ||
![]() |
3a27f37686 | ||
![]() |
fe2d21979d | ||
![]() |
48b1f3d4f0 | ||
![]() |
93ed589ac7 | ||
![]() |
96de9e2c16 | ||
![]() |
b25f9d3bec | ||
![]() |
15854c605b | ||
![]() |
ac193cc94a | ||
![]() |
d626f872e6 | ||
![]() |
3eb66fa34a | ||
![]() |
0fdd0175b7 | ||
![]() |
dec9b477e0 | ||
![]() |
a0a4b0dd1d | ||
![]() |
8dc6da56a7 | ||
![]() |
b4e07aacfe | ||
![]() |
19b47f0f42 | ||
![]() |
f9ef3d63c7 | ||
![]() |
2b574d33b5 | ||
![]() |
6039e9bb46 | ||
![]() |
adfd4b043f | ||
![]() |
719189be55 | ||
![]() |
ef9907f4b6 | ||
![]() |
16b7447df1 | ||
![]() |
4157746478 | ||
![]() |
5120786708 | ||
![]() |
0176fa75ef | ||
![]() |
e6968f2d80 | ||
![]() |
c0dd8a53e8 | ||
![]() |
3cb3135235 | ||
![]() |
28182cac64 | ||
![]() |
73b80d2482 | ||
![]() |
f22eb22409 | ||
![]() |
4a95b17a47 | ||
![]() |
f4a71159fd | ||
![]() |
c0431e3dc2 | ||
![]() |
7f87cee282 | ||
![]() |
c24c704439 | ||
![]() |
232e5d55b8 | ||
![]() |
da24ae7e1c | ||
![]() |
8fc13f8a8f |
@@ -12,7 +12,7 @@ insert_final_newline = true
|
|||||||
# Set default charset
|
# Set default charset
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
|
|
||||||
# 2 space indentation
|
# 4 space indentation
|
||||||
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
@@ -21,4 +21,4 @@ indent_size = 4
|
|||||||
charset = latin1
|
charset = latin1
|
||||||
|
|
||||||
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
|
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
|
||||||
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Develop
|
# Develop
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
out/
|
out/
|
||||||
dist/
|
dist/
|
||||||
/src/core.lib/common/
|
/src/core.lib/common/
|
||||||
@@ -13,4 +14,4 @@ devconfig/*
|
|||||||
# Build
|
# Build
|
||||||
*.db
|
*.db
|
||||||
checkVersion.sh
|
checkVersion.sh
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@@ -64,4 +64,4 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
|
|||||||
|
|
||||||
## 开源附加
|
## 开源附加
|
||||||
|
|
||||||
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
|
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高IM易用性,实现类似Hook推送,此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||||
|
@@ -1,70 +1,32 @@
|
|||||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
import eslint from '@eslint/js';
|
||||||
import _import from "eslint-plugin-import";
|
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
import { fixupPluginRules } from "@eslint/compat";
|
import tsEslintParser from '@typescript-eslint/parser';
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import tsParser from "@typescript-eslint/parser";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const customTsFlatConfig = [
|
||||||
const dirname = path.dirname(filename);
|
{
|
||||||
const compat = new FlatCompat({
|
name: 'typescript-eslint/base',
|
||||||
baseDirectory: dirname,
|
languageOptions: {
|
||||||
recommendedConfig: js.configs.recommended,
|
parser: tsEslintParser,
|
||||||
allConfig: js.configs.all
|
sourceType: 'module',
|
||||||
});
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
export default [{
|
...globals.node,
|
||||||
ignores: ["src/core/proto/"],
|
NodeJS: 'readonly', // 添加 NodeJS 全局变量
|
||||||
}, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), {
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": typescriptEslint,
|
|
||||||
import: fixupPluginRules(_import),
|
|
||||||
},
|
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
|
|
||||||
settings: {
|
|
||||||
"import/parsers": {
|
|
||||||
"@typescript-eslint/parser": [".ts"],
|
|
||||||
},
|
|
||||||
|
|
||||||
"import/resolver": {
|
|
||||||
typescript: {
|
|
||||||
alwaysTryTypes: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
rules: {
|
...tsEslintPlugin.configs.recommended.rules,
|
||||||
indent: ["error", 4],
|
'quotes': ['error', 'single'], // 使用单引号
|
||||||
semi: ["error", "always"],
|
'semi': ['error', 'always'], // 强制使用分号
|
||||||
"no-unused-vars": "off",
|
'indent': ['error', 4], // 使用 4 空格缩进
|
||||||
"no-async-promise-executor": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
|
||||||
"object-curly-spacing": ["error", "always"],
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
files: ["**/.eslintrc.{js,cjs}"],
|
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
},
|
},
|
||||||
ecmaVersion: 5,
|
plugins: {
|
||||||
sourceType: "commonjs",
|
'@typescript-eslint': tsEslintPlugin,
|
||||||
|
},
|
||||||
|
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
|
||||||
},
|
},
|
||||||
}];
|
];
|
||||||
|
|
||||||
|
export default [eslint.configs.recommended, ...customTsFlatConfig];
|
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.4.12",
|
"version": "4.5.5",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
@@ -4,12 +4,15 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host=0.0.0.0",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@heroui/avatar": "2.2.7",
|
"@heroui/avatar": "2.2.7",
|
||||||
"@heroui/breadcrumbs": "2.2.7",
|
"@heroui/breadcrumbs": "2.2.7",
|
||||||
"@heroui/button": "2.2.10",
|
"@heroui/button": "2.2.10",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"@heroui/listbox": "2.3.10",
|
"@heroui/listbox": "2.3.10",
|
||||||
"@heroui/modal": "2.2.8",
|
"@heroui/modal": "2.2.8",
|
||||||
"@heroui/navbar": "2.2.9",
|
"@heroui/navbar": "2.2.9",
|
||||||
|
"@heroui/pagination": "^2.2.9",
|
||||||
"@heroui/popover": "2.3.10",
|
"@heroui/popover": "2.3.10",
|
||||||
"@heroui/select": "2.4.10",
|
"@heroui/select": "2.4.10",
|
||||||
"@heroui/slider": "2.4.8",
|
"@heroui/slider": "2.4.8",
|
||||||
@@ -33,73 +37,78 @@
|
|||||||
"@heroui/spinner": "2.2.7",
|
"@heroui/spinner": "2.2.7",
|
||||||
"@heroui/switch": "2.2.9",
|
"@heroui/switch": "2.2.9",
|
||||||
"@heroui/system": "2.4.7",
|
"@heroui/system": "2.4.7",
|
||||||
|
"@heroui/table": "^2.2.9",
|
||||||
"@heroui/tabs": "2.2.8",
|
"@heroui/tabs": "2.2.8",
|
||||||
"@heroui/theme": "2.4.6",
|
"@heroui/theme": "2.4.6",
|
||||||
"@heroui/tooltip": "2.2.8",
|
"@heroui/tooltip": "2.2.8",
|
||||||
"@monaco-editor/loader": "^1.4.0",
|
"@monaco-editor/loader": "^1.4.0",
|
||||||
"@monaco-editor/react": "4.7.0-rc.0",
|
"@monaco-editor/react": "4.7.0-rc.0",
|
||||||
"@react-aria/visually-hidden": "3.8.18",
|
"@react-aria/visually-hidden": "^3.8.19",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@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",
|
||||||
"clsx": "2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"event-source-polyfill": "^1.0.31",
|
"event-source-polyfill": "^1.0.31",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^12.0.6",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"motion": "^11.15.0",
|
"motion": "^12.0.6",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
"qface": "^1.4.1",
|
"qface": "^1.4.1",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react": "19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-dropzone": "^14.3.5",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-photo-view": "^1.2.7",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.0",
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "7.1.0",
|
"react-router-dom": "^7.1.4",
|
||||||
"react-use-websocket": "^4.11.1",
|
"react-use-websocket": "^4.11.1",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-variants": "0.3.0",
|
"tailwind-variants": "^0.3.0",
|
||||||
"tailwindcss": "3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@react-types/shared": "^3.26.0",
|
"@react-types/shared": "^3.26.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||||
"@types/event-source-polyfill": "^1.0.5",
|
"@types/event-source-polyfill": "^1.0.5",
|
||||||
"@types/fabric": "^5.3.9",
|
"@types/fabric": "^5.3.9",
|
||||||
"@types/node": "22.10.2",
|
"@types/node": "^22.12.0",
|
||||||
"@types/react": "19.0.2",
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@types/react-dom": "19.0.2",
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
"@typescript-eslint/parser": "8.18.1",
|
"@typescript-eslint/parser": "^8.22.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-node": "^11.1.0",
|
||||||
"eslint-plugin-prettier": "5.2.1",
|
"eslint-plugin-prettier": "5.2.3",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-unused-imports": "4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"postcss": "8.4.49",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "5.7.2",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vite-plugin-static-copy": "^2.2.0",
|
"vite-plugin-static-copy": "^2.2.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,7 @@ import { IoMdRefresh } from 'react-icons/io'
|
|||||||
export interface SaveButtonsProps {
|
export interface SaveButtonsProps {
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
refresh: () => void
|
refresh?: () => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,21 +27,23 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
|||||||
取消更改
|
取消更改
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="danger"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
onPress={() => onSubmit()}
|
onPress={() => onSubmit()}
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{refresh && (
|
||||||
isIconOnly
|
<Button
|
||||||
color="secondary"
|
isIconOnly
|
||||||
radius="full"
|
color="secondary"
|
||||||
variant="flat"
|
radius="full"
|
||||||
onPress={() => refresh()}
|
variant="flat"
|
||||||
>
|
onPress={() => refresh()}
|
||||||
<IoMdRefresh size={24} />
|
>
|
||||||
</Button>
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@@ -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',
|
||||||
|
166
napcat.webui/src/components/file_icon.tsx
Normal file
166
napcat.webui/src/components/file_icon.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
FaFile,
|
||||||
|
FaFileAudio,
|
||||||
|
FaFileCode,
|
||||||
|
FaFileCsv,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileImage,
|
||||||
|
FaFileLines,
|
||||||
|
FaFilePdf,
|
||||||
|
FaFilePowerpoint,
|
||||||
|
FaFileVideo,
|
||||||
|
FaFileWord,
|
||||||
|
FaFileZipper,
|
||||||
|
FaFolderClosed
|
||||||
|
} from 'react-icons/fa6'
|
||||||
|
|
||||||
|
export interface FileIconProps {
|
||||||
|
name?: string
|
||||||
|
isDirectory?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileIcon = (props: FileIconProps) => {
|
||||||
|
const { name, isDirectory = false } = props
|
||||||
|
if (isDirectory) {
|
||||||
|
return <FaFolderClosed className="text-yellow-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = name?.split('.').pop() || ''
|
||||||
|
if (ext) {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'gif':
|
||||||
|
case 'svg':
|
||||||
|
case 'bmp':
|
||||||
|
case 'ico':
|
||||||
|
case 'webp':
|
||||||
|
case 'tiff':
|
||||||
|
case 'tif':
|
||||||
|
case 'heic':
|
||||||
|
case 'heif':
|
||||||
|
case 'avif':
|
||||||
|
case 'apng':
|
||||||
|
case 'flif':
|
||||||
|
case 'ai':
|
||||||
|
case 'psd':
|
||||||
|
case 'xcf':
|
||||||
|
case 'sketch':
|
||||||
|
case 'fig':
|
||||||
|
case 'xd':
|
||||||
|
case 'svgz':
|
||||||
|
return <FaFileImage className="text-green-500" />
|
||||||
|
case 'pdf':
|
||||||
|
return <FaFilePdf className="text-red-500" />
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return <FaFileWord className="text-blue-500" />
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
return <FaFileExcel className="text-green-500" />
|
||||||
|
case 'csv':
|
||||||
|
return <FaFileCsv className="text-green-500" />
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return <FaFilePowerpoint className="text-red-500" />
|
||||||
|
case 'zip':
|
||||||
|
case 'rar':
|
||||||
|
case '7z':
|
||||||
|
case 'tar':
|
||||||
|
case 'gz':
|
||||||
|
case 'bz2':
|
||||||
|
case 'xz':
|
||||||
|
case 'lz':
|
||||||
|
case 'lzma':
|
||||||
|
case 'zst':
|
||||||
|
case 'zstd':
|
||||||
|
case 'z':
|
||||||
|
case 'taz':
|
||||||
|
case 'tz':
|
||||||
|
case 'tzo':
|
||||||
|
return <FaFileZipper className="text-green-500" />
|
||||||
|
case 'txt':
|
||||||
|
return <FaFileLines className="text-gray-500" />
|
||||||
|
case 'mp3':
|
||||||
|
case 'wav':
|
||||||
|
case 'flac':
|
||||||
|
return <FaFileAudio className="text-green-500" />
|
||||||
|
case 'mp4':
|
||||||
|
case 'avi':
|
||||||
|
case 'mov':
|
||||||
|
case 'wmv':
|
||||||
|
return <FaFileVideo className="text-red-500" />
|
||||||
|
case 'html':
|
||||||
|
case 'css':
|
||||||
|
case 'js':
|
||||||
|
case 'ts':
|
||||||
|
case 'jsx':
|
||||||
|
case 'tsx':
|
||||||
|
case 'json':
|
||||||
|
case 'xml':
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
case 'md':
|
||||||
|
case 'sh':
|
||||||
|
case 'py':
|
||||||
|
case 'java':
|
||||||
|
case 'c':
|
||||||
|
case 'cpp':
|
||||||
|
case 'cs':
|
||||||
|
case 'go':
|
||||||
|
case 'php':
|
||||||
|
case 'rb':
|
||||||
|
case 'pl':
|
||||||
|
case 'swift':
|
||||||
|
case 'kt':
|
||||||
|
case 'rs':
|
||||||
|
case 'sql':
|
||||||
|
case 'r':
|
||||||
|
case 'scala':
|
||||||
|
case 'groovy':
|
||||||
|
case 'dart':
|
||||||
|
case 'lua':
|
||||||
|
case 'perl':
|
||||||
|
case 'h':
|
||||||
|
case 'm':
|
||||||
|
case 'mm':
|
||||||
|
case 'makefile':
|
||||||
|
case 'cmake':
|
||||||
|
case 'dockerfile':
|
||||||
|
case 'gradle':
|
||||||
|
case 'properties':
|
||||||
|
case 'ini':
|
||||||
|
case 'conf':
|
||||||
|
case 'env':
|
||||||
|
case 'bat':
|
||||||
|
case 'cmd':
|
||||||
|
case 'ps1':
|
||||||
|
case 'psm1':
|
||||||
|
case 'psd1':
|
||||||
|
case 'ps1xml':
|
||||||
|
case 'psc1':
|
||||||
|
case 'pssc':
|
||||||
|
case 'nuspec':
|
||||||
|
case 'resx':
|
||||||
|
case 'resw':
|
||||||
|
case 'csproj':
|
||||||
|
case 'vbproj':
|
||||||
|
case 'vcxproj':
|
||||||
|
case 'fsproj':
|
||||||
|
case 'sln':
|
||||||
|
case 'suo':
|
||||||
|
case 'user':
|
||||||
|
case 'userosscache':
|
||||||
|
case 'sln.docstates':
|
||||||
|
case 'dll':
|
||||||
|
return <FaFileCode className="text-blue-500" />
|
||||||
|
default:
|
||||||
|
return <FaFile className="text-gray-500" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FaFile className="text-gray-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileIcon
|
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button, ButtonGroup } from '@heroui/button'
|
||||||
|
import { Input } from '@heroui/input'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader
|
||||||
|
} from '@heroui/modal'
|
||||||
|
|
||||||
|
interface CreateFileModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
fileType: 'file' | 'directory'
|
||||||
|
newFileName: string
|
||||||
|
onTypeChange: (type: 'file' | 'directory') => void
|
||||||
|
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onClose: () => void
|
||||||
|
onCreate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateFileModal({
|
||||||
|
isOpen,
|
||||||
|
fileType,
|
||||||
|
newFileName,
|
||||||
|
onTypeChange,
|
||||||
|
onNameChange,
|
||||||
|
onClose,
|
||||||
|
onCreate
|
||||||
|
}: CreateFileModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>新建</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ButtonGroup color="danger">
|
||||||
|
<Button
|
||||||
|
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||||
|
onPress={() => onTypeChange('file')}
|
||||||
|
>
|
||||||
|
文件
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={fileType === 'directory' ? 'solid' : 'flat'}
|
||||||
|
onPress={() => onTypeChange('directory')}
|
||||||
|
>
|
||||||
|
目录
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Input label="名称" value={newFileName} onChange={onNameChange} />
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" onPress={onCreate}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
94
napcat.webui/src/components/file_manage/file_edit_modal.tsx
Normal file
94
napcat.webui/src/components/file_manage/file_edit_modal.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Code } from '@heroui/code'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader
|
||||||
|
} from '@heroui/modal'
|
||||||
|
|
||||||
|
import CodeEditor from '@/components/code_editor'
|
||||||
|
|
||||||
|
interface FileEditModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
file: { path: string; content: string } | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onContentChange: (newContent?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileEditModal({
|
||||||
|
isOpen,
|
||||||
|
file,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onContentChange
|
||||||
|
}: FileEditModalProps) {
|
||||||
|
// 根据文件后缀返回对应语言
|
||||||
|
const getLanguage = (filePath: string) => {
|
||||||
|
if (filePath.endsWith('.js')) return 'javascript'
|
||||||
|
if (filePath.endsWith('.ts')) return 'typescript'
|
||||||
|
if (filePath.endsWith('.tsx')) return 'tsx'
|
||||||
|
if (filePath.endsWith('.jsx')) return 'jsx'
|
||||||
|
if (filePath.endsWith('.vue')) return 'vue'
|
||||||
|
if (filePath.endsWith('.svelte')) return 'svelte'
|
||||||
|
if (filePath.endsWith('.json')) return 'json'
|
||||||
|
if (filePath.endsWith('.html')) return 'html'
|
||||||
|
if (filePath.endsWith('.css')) return 'css'
|
||||||
|
if (filePath.endsWith('.scss')) return 'scss'
|
||||||
|
if (filePath.endsWith('.less')) return 'less'
|
||||||
|
if (filePath.endsWith('.md')) return 'markdown'
|
||||||
|
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
|
||||||
|
if (filePath.endsWith('.xml')) return 'xml'
|
||||||
|
if (filePath.endsWith('.sql')) return 'sql'
|
||||||
|
if (filePath.endsWith('.sh')) return 'shell'
|
||||||
|
if (filePath.endsWith('.bat')) return 'bat'
|
||||||
|
if (filePath.endsWith('.php')) return 'php'
|
||||||
|
if (filePath.endsWith('.java')) return 'java'
|
||||||
|
if (filePath.endsWith('.c')) return 'c'
|
||||||
|
if (filePath.endsWith('.cpp')) return 'cpp'
|
||||||
|
if (filePath.endsWith('.h')) return 'h'
|
||||||
|
if (filePath.endsWith('.hpp')) return 'hpp'
|
||||||
|
if (filePath.endsWith('.go')) return 'go'
|
||||||
|
if (filePath.endsWith('.py')) return 'python'
|
||||||
|
if (filePath.endsWith('.rb')) return 'ruby'
|
||||||
|
if (filePath.endsWith('.cs')) return 'csharp'
|
||||||
|
if (filePath.endsWith('.swift')) return 'swift'
|
||||||
|
if (filePath.endsWith('.vb')) return 'vb'
|
||||||
|
if (filePath.endsWith('.lua')) return 'lua'
|
||||||
|
if (filePath.endsWith('.pl')) return 'perl'
|
||||||
|
if (filePath.endsWith('.r')) return 'r'
|
||||||
|
return 'plaintext'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal size="full" isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
|
||||||
|
<span>编辑文件</span>
|
||||||
|
<Code className="text-xs">{file?.path}</Code>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody className="p-0">
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeEditor
|
||||||
|
height="100%"
|
||||||
|
value={file?.content || ''}
|
||||||
|
onChange={onContentChange}
|
||||||
|
options={{ wordWrap: 'on' }}
|
||||||
|
language={file?.path ? getLanguage(file.path) : 'plaintext'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" onPress={onSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,92 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader
|
||||||
|
} from '@heroui/modal'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import { useRequest } from 'ahooks'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
interface FilePreviewModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
filePath: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoExts = ['.mp4', '.webm']
|
||||||
|
export const audioExts = ['.mp3', '.wav']
|
||||||
|
|
||||||
|
export const supportedPreviewExts = [...videoExts, ...audioExts]
|
||||||
|
|
||||||
|
export default function FilePreviewModal({
|
||||||
|
isOpen,
|
||||||
|
filePath,
|
||||||
|
onClose
|
||||||
|
}: FilePreviewModalProps) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
const { data, loading, error, run } = useRequest(
|
||||||
|
async () => FileManager.downloadToURL(filePath),
|
||||||
|
{
|
||||||
|
refreshDeps: [filePath],
|
||||||
|
manual: true,
|
||||||
|
refreshDepsAction: () => {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath) {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}, [filePath])
|
||||||
|
|
||||||
|
let contentElement = null
|
||||||
|
if (!supportedPreviewExts.includes(ext)) {
|
||||||
|
contentElement = <div>暂不支持预览此文件类型</div>
|
||||||
|
} else if (error) {
|
||||||
|
contentElement = <div>读取文件失败</div>
|
||||||
|
} else if (loading || !data) {
|
||||||
|
contentElement = (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (videoExts.includes(ext)) {
|
||||||
|
contentElement = <video src={data} controls className="max-w-full" />
|
||||||
|
} else if (audioExts.includes(ext)) {
|
||||||
|
contentElement = <audio src={data} controls className="w-full" />
|
||||||
|
} else {
|
||||||
|
contentElement = (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>文件预览</ModalHeader>
|
||||||
|
<ModalBody className="flex justify-center items-center">
|
||||||
|
{contentElement}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
245
napcat.webui/src/components/file_manage/file_table.tsx
Normal file
245
napcat.webui/src/components/file_manage/file_table.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { Button, ButtonGroup } from '@heroui/button'
|
||||||
|
import { Pagination } from '@heroui/pagination'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import {
|
||||||
|
type Selection,
|
||||||
|
type SortDescriptor,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@heroui/table'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { BiRename } from 'react-icons/bi'
|
||||||
|
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
||||||
|
import { PhotoSlider } from 'react-photo-view'
|
||||||
|
|
||||||
|
import FileIcon from '@/components/file_icon'
|
||||||
|
|
||||||
|
import type { FileInfo } from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
import { supportedPreviewExts } from './file_preview_modal'
|
||||||
|
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
|
||||||
|
|
||||||
|
export interface FileTableProps {
|
||||||
|
files: FileInfo[]
|
||||||
|
currentPath: string
|
||||||
|
loading: boolean
|
||||||
|
sortDescriptor: SortDescriptor
|
||||||
|
onSortChange: (descriptor: SortDescriptor) => void
|
||||||
|
selectedFiles: Selection
|
||||||
|
onSelectionChange: (selected: Selection) => void
|
||||||
|
onDirectoryClick: (dirPath: string) => void
|
||||||
|
onEdit: (filePath: string) => void
|
||||||
|
onPreview: (filePath: string) => void
|
||||||
|
onRenameRequest: (name: string) => void
|
||||||
|
onMoveRequest: (name: string) => void
|
||||||
|
onCopyPath: (fileName: string) => void
|
||||||
|
onDelete: (filePath: string) => void
|
||||||
|
onDownload: (filePath: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export default function FileTable({
|
||||||
|
files,
|
||||||
|
currentPath,
|
||||||
|
loading,
|
||||||
|
sortDescriptor,
|
||||||
|
onSortChange,
|
||||||
|
selectedFiles,
|
||||||
|
onSelectionChange,
|
||||||
|
onDirectoryClick,
|
||||||
|
onEdit,
|
||||||
|
onPreview,
|
||||||
|
onRenameRequest,
|
||||||
|
onMoveRequest,
|
||||||
|
onCopyPath,
|
||||||
|
onDelete,
|
||||||
|
onDownload
|
||||||
|
}: FileTableProps) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
|
||||||
|
const start = (page - 1) * PAGE_SIZE
|
||||||
|
const end = start + PAGE_SIZE
|
||||||
|
const displayFiles = files.slice(start, end)
|
||||||
|
const [showImage, setShowImage] = useState(false)
|
||||||
|
const [previewIndex, setPreviewIndex] = useState(0)
|
||||||
|
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||||
|
|
||||||
|
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||||
|
setPreviewImages((prev) => {
|
||||||
|
const exists = prev.some((p) => p.key === image.key)
|
||||||
|
if (exists) return prev
|
||||||
|
return [...prev, image]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviewImages([])
|
||||||
|
setPreviewIndex(0)
|
||||||
|
setShowImage(false)
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||||
|
const index = images.findIndex((image) => image.key === name)
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewIndex(index)
|
||||||
|
setShowImage(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PhotoSlider
|
||||||
|
images={previewImages}
|
||||||
|
visible={showImage}
|
||||||
|
onClose={() => setShowImage(false)}
|
||||||
|
index={previewIndex}
|
||||||
|
onIndexChange={setPreviewIndex}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
aria-label="文件列表"
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
defaultSelectedKeys={[]}
|
||||||
|
selectedKeys={selectedFiles}
|
||||||
|
selectionMode="multiple"
|
||||||
|
bottomContent={
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="danger"
|
||||||
|
page={page}
|
||||||
|
total={pages}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn key="name" allowsSorting>
|
||||||
|
名称
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn key="type" allowsSorting>
|
||||||
|
类型
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn key="size" allowsSorting>
|
||||||
|
大小
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn key="mtime" allowsSorting>
|
||||||
|
修改时间
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn key="actions">操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody
|
||||||
|
isLoading={loading}
|
||||||
|
loadingContent={
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayFiles.map((file: FileInfo) => {
|
||||||
|
const filePath = path.join(currentPath, file.name)
|
||||||
|
const ext = path.extname(file.name).toLowerCase()
|
||||||
|
const previewable = supportedPreviewExts.includes(ext)
|
||||||
|
const images = previewImages
|
||||||
|
return (
|
||||||
|
<TableRow key={file.name}>
|
||||||
|
<TableCell>
|
||||||
|
{imageExts.includes(ext) ? (
|
||||||
|
<ImageNameButton
|
||||||
|
name={file.name}
|
||||||
|
filePath={filePath}
|
||||||
|
onPreview={() => onPreviewImage(file.name, images)}
|
||||||
|
onAddPreview={addPreviewImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onPress={() =>
|
||||||
|
file.isDirectory
|
||||||
|
? onDirectoryClick(file.name)
|
||||||
|
: previewable
|
||||||
|
? onPreview(filePath)
|
||||||
|
: onEdit(filePath)
|
||||||
|
}
|
||||||
|
className="text-left justify-start"
|
||||||
|
startContent={
|
||||||
|
<FileIcon
|
||||||
|
name={file.name}
|
||||||
|
isDirectory={file.isDirectory}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isNaN(file.size) || file.isDirectory
|
||||||
|
? '-'
|
||||||
|
: `${file.size} 字节`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onRenameRequest(file.name)}
|
||||||
|
>
|
||||||
|
<BiRename />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onMoveRequest(file.name)}
|
||||||
|
>
|
||||||
|
<FiMove />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onCopyPath(file.name)}
|
||||||
|
>
|
||||||
|
<FiCopy />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDownload(filePath)}
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDelete(filePath)}
|
||||||
|
>
|
||||||
|
<FiTrash2 />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,85 @@
|
|||||||
|
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"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
168
napcat.webui/src/components/file_manage/move_modal.tsx
Normal file
168
napcat.webui/src/components/file_manage/move_modal.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader
|
||||||
|
} from '@heroui/modal'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { IoAdd, IoRemove } from 'react-icons/io5'
|
||||||
|
|
||||||
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
interface MoveModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
moveTargetPath: string
|
||||||
|
selectionInfo: string
|
||||||
|
onClose: () => void
|
||||||
|
onMove: () => void
|
||||||
|
onSelect: (dir: string) => void // 新增回调
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 DirectoryTree 改为递归组件
|
||||||
|
// 新增 selectedPath 属性,用于标识当前选中的目录
|
||||||
|
function DirectoryTree({
|
||||||
|
basePath,
|
||||||
|
onSelect,
|
||||||
|
selectedPath
|
||||||
|
}: {
|
||||||
|
basePath: string
|
||||||
|
onSelect: (dir: string) => void
|
||||||
|
selectedPath?: string
|
||||||
|
}) {
|
||||||
|
const [dirs, setDirs] = useState<string[]>([])
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
// 新增loading状态
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchDirectories = async () => {
|
||||||
|
try {
|
||||||
|
// 直接使用 basePath 调用接口,移除 process.platform 判断
|
||||||
|
const list = await FileManager.listDirectories(basePath)
|
||||||
|
setDirs(list.map((item) => item.name))
|
||||||
|
} catch (error) {
|
||||||
|
// ...error handling...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (!expanded) {
|
||||||
|
setExpanded(true)
|
||||||
|
setLoading(true)
|
||||||
|
await fetchDirectories()
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
setExpanded(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onSelect(basePath)
|
||||||
|
handleToggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算显示的名称
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (basePath === '/') return '/'
|
||||||
|
if (/^[A-Z]:$/i.test(basePath)) return basePath
|
||||||
|
return path.basename(basePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Button 的 variant 逻辑
|
||||||
|
const isSeleted = selectedPath === basePath
|
||||||
|
const variant = isSeleted
|
||||||
|
? 'solid'
|
||||||
|
: selectedPath && path.dirname(selectedPath) === basePath
|
||||||
|
? 'flat'
|
||||||
|
: 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-4">
|
||||||
|
<Button
|
||||||
|
onPress={handleClick}
|
||||||
|
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
|
||||||
|
size="sm"
|
||||||
|
color="danger"
|
||||||
|
variant={variant}
|
||||||
|
startContent={
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-md',
|
||||||
|
isSeleted ? 'bg-danger-600' : 'bg-danger-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{expanded ? <IoRemove /> : <IoAdd />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getDisplayName()}
|
||||||
|
</Button>
|
||||||
|
{expanded && (
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex py-1 px-8">
|
||||||
|
<Spinner size="sm" color="danger" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dirs.map((dirName) => {
|
||||||
|
const childPath =
|
||||||
|
basePath === '/' && /^[A-Z]:$/i.test(dirName)
|
||||||
|
? dirName
|
||||||
|
: path.join(basePath, dirName)
|
||||||
|
return (
|
||||||
|
<DirectoryTree
|
||||||
|
key={childPath}
|
||||||
|
basePath={childPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MoveModal({
|
||||||
|
isOpen,
|
||||||
|
moveTargetPath,
|
||||||
|
selectionInfo,
|
||||||
|
onClose,
|
||||||
|
onMove,
|
||||||
|
onSelect
|
||||||
|
}: MoveModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>选择目标目录</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
|
||||||
|
<DirectoryTree
|
||||||
|
basePath="/"
|
||||||
|
onSelect={onSelect}
|
||||||
|
selectedPath={moveTargetPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-default-500 mt-2">
|
||||||
|
当前选择:{moveTargetPath || '未选择'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-default-500">移动项:{selectionInfo}</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" onPress={onMove}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
44
napcat.webui/src/components/file_manage/rename_modal.tsx
Normal file
44
napcat.webui/src/components/file_manage/rename_modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Input } from '@heroui/input'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader
|
||||||
|
} from '@heroui/modal'
|
||||||
|
|
||||||
|
interface RenameModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
newFileName: string
|
||||||
|
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onClose: () => void
|
||||||
|
onRename: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RenameModal({
|
||||||
|
isOpen,
|
||||||
|
newFileName,
|
||||||
|
onNameChange,
|
||||||
|
onClose,
|
||||||
|
onRename
|
||||||
|
}: RenameModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>重命名</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Input label="新名称" value={newFileName} onChange={onNameChange} />
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" onPress={onRename}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@@ -36,7 +36,7 @@ export default function Hitokoto() {
|
|||||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="font-noto-serif">{data?.hitokoto}</div>
|
<div>{data?.hitokoto}</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||||
{data?.from_who}
|
{data?.from_who}
|
||||||
|
146
napcat.webui/src/components/hover_titled_card.tsx
Normal file
146
napcat.webui/src/components/hover_titled_card.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { motion, useMotionValue, useSpring } from 'motion/react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const springValues = {
|
||||||
|
damping: 30,
|
||||||
|
stiffness: 100,
|
||||||
|
mass: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverTiltedCardProps {
|
||||||
|
imageSrc: string
|
||||||
|
altText?: string
|
||||||
|
captionText?: string
|
||||||
|
containerHeight?: string
|
||||||
|
containerWidth?: string
|
||||||
|
imageHeight?: string
|
||||||
|
imageWidth?: string
|
||||||
|
scaleOnHover?: number
|
||||||
|
rotateAmplitude?: number
|
||||||
|
showTooltip?: boolean
|
||||||
|
overlayContent?: React.ReactNode
|
||||||
|
displayOverlayContent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverTiltedCard({
|
||||||
|
imageSrc,
|
||||||
|
altText = 'NapCat',
|
||||||
|
captionText = 'NapCat',
|
||||||
|
containerHeight = '200px',
|
||||||
|
containerWidth = '100%',
|
||||||
|
imageHeight = '200px',
|
||||||
|
imageWidth = '200px',
|
||||||
|
scaleOnHover = 1.1,
|
||||||
|
rotateAmplitude = 14,
|
||||||
|
showTooltip = false,
|
||||||
|
overlayContent = (
|
||||||
|
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
||||||
|
NapCat
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
displayOverlayContent = true
|
||||||
|
}: HoverTiltedCardProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const x = useMotionValue(0)
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
const rotateX = useSpring(useMotionValue(0), springValues)
|
||||||
|
const rotateY = useSpring(useMotionValue(0), springValues)
|
||||||
|
const scale = useSpring(1, springValues)
|
||||||
|
const opacity = useSpring(0)
|
||||||
|
const rotateFigcaption = useSpring(0, {
|
||||||
|
stiffness: 350,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const [lastY, setLastY] = useState(0)
|
||||||
|
|
||||||
|
function handleMouse(e: React.MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
const offsetX = e.clientX - rect.left - rect.width / 2
|
||||||
|
const offsetY = e.clientY - rect.top - rect.height / 2
|
||||||
|
|
||||||
|
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
|
||||||
|
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
|
||||||
|
|
||||||
|
rotateX.set(rotationX)
|
||||||
|
rotateY.set(rotationY)
|
||||||
|
|
||||||
|
x.set(e.clientX - rect.left)
|
||||||
|
y.set(e.clientY - rect.top)
|
||||||
|
|
||||||
|
const velocityY = offsetY - lastY
|
||||||
|
rotateFigcaption.set(-velocityY * 0.6)
|
||||||
|
setLastY(offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
scale.set(scaleOnHover)
|
||||||
|
opacity.set(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
opacity.set(0)
|
||||||
|
scale.set(1)
|
||||||
|
rotateX.set(0)
|
||||||
|
rotateY.set(0)
|
||||||
|
rotateFigcaption.set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure
|
||||||
|
ref={ref}
|
||||||
|
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
|
||||||
|
style={{
|
||||||
|
height: containerHeight,
|
||||||
|
width: containerWidth
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouse}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative [transform-style:preserve-3d]"
|
||||||
|
style={{
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
rotateX,
|
||||||
|
rotateY,
|
||||||
|
scale
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={altText}
|
||||||
|
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{displayOverlayContent && overlayContent && (
|
||||||
|
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
|
||||||
|
{overlayContent}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{showTooltip && (
|
||||||
|
<motion.figcaption
|
||||||
|
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
|
||||||
|
style={{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
opacity,
|
||||||
|
rotate: rotateFigcaption
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{captionText}
|
||||||
|
</motion.figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
@@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
begin="0ms"
|
begin="0ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
begin="800ms"
|
begin="800ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
begin="1600ms"
|
begin="1600ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
begin="2400ms"
|
begin="2400ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
begin="3200ms"
|
begin="3200ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="0ms"
|
begin="0ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="600ms"
|
begin="600ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="1200ms"
|
begin="1200ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="1800ms"
|
begin="1800ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="2400ms"
|
begin="2400ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="3000ms"
|
begin="3000ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="3600ms"
|
begin="3600ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
begin="4200ms"
|
begin="4200ms"
|
||||||
></animate>
|
></animate>
|
||||||
<animate
|
<animate
|
||||||
attributeName="fill-opacity"
|
attributeName="fillOpacity"
|
||||||
to="1"
|
to="1"
|
||||||
dur="800ms"
|
dur="800ms"
|
||||||
calcMode="linear"
|
calcMode="linear"
|
||||||
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
</svg>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const FileIcon = (props: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="_x36_"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlSpace="preserve"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#D4B476' }}
|
||||||
|
d="M441.853,393.794H70.147C31.566,393.794,0,362.228,0,323.647V106.969 c0-38.581,31.566-70.147,70.147-70.147h371.706c38.581,0,70.147,31.566,70.147,70.147v216.678 C512,362.228,480.434,393.794,441.853,393.794z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#D4B476' }}
|
||||||
|
d="M199.884,249.574H70.147C31.566,249.574,0,218.008,0,179.427V70.147C0,31.566,31.566,0,70.147,0 h129.737c38.581,0,70.147,31.566,70.147,70.147v109.28C270.031,218.008,238.465,249.574,199.884,249.574z"
|
||||||
|
></path>
|
||||||
|
<polygon
|
||||||
|
style={{ fill: '#F0EFEF' }}
|
||||||
|
points="485.439,329.388 87.357,347.774 78.653,130.095 476.734,111.709 "
|
||||||
|
></polygon>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="Adobe_OpacityMaskFilter"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
x="34.381"
|
||||||
|
y="60.216"
|
||||||
|
width="416.68"
|
||||||
|
height="259.557"
|
||||||
|
>
|
||||||
|
<feFlood
|
||||||
|
style={{
|
||||||
|
floodColor: 'white',
|
||||||
|
floodOpacity: 1
|
||||||
|
}}
|
||||||
|
result="back"
|
||||||
|
></feFlood>
|
||||||
|
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<mask
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="34.381"
|
||||||
|
y="60.216"
|
||||||
|
width="416.68"
|
||||||
|
height="259.557"
|
||||||
|
id="SVGID_1_"
|
||||||
|
>
|
||||||
|
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter)' }}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="Adobe_OpacityMaskFilter_1_"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
x="34.381"
|
||||||
|
y="60.216"
|
||||||
|
width="416.68"
|
||||||
|
height="259.557"
|
||||||
|
>
|
||||||
|
<feFlood
|
||||||
|
style={{ floodColor: 'white', floodOpacity: 1 }}
|
||||||
|
result="back"
|
||||||
|
></feFlood>
|
||||||
|
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<mask
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="34.381"
|
||||||
|
y="60.216"
|
||||||
|
width="416.68"
|
||||||
|
height="259.557"
|
||||||
|
id="SVGID_1_"
|
||||||
|
>
|
||||||
|
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter_1_)' }}> </g>
|
||||||
|
</mask>
|
||||||
|
<linearGradient
|
||||||
|
id="SVGID_2_"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="34.3814"
|
||||||
|
y1="189.9944"
|
||||||
|
x2="451.061"
|
||||||
|
y2="189.9944"
|
||||||
|
>
|
||||||
|
<stop offset="0.57" style={{ stopColor: '#F6F6F6' }}></stop>
|
||||||
|
<stop offset="0.6039" style={{ stopColor: '#F6F6F6' }}></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<polygon
|
||||||
|
style={{ mask: 'url(#SVGID_1_)', fill: 'url(#SVGID_2_)' }}
|
||||||
|
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
|
||||||
|
></polygon>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
<linearGradient
|
||||||
|
id="SVGID_3_"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="34.3814"
|
||||||
|
y1="189.9944"
|
||||||
|
x2="451.061"
|
||||||
|
y2="189.9944"
|
||||||
|
>
|
||||||
|
<stop offset="0.57" style={{ stopColor: '#FFFFFF' }}></stop>
|
||||||
|
<stop offset="0.6039" style={{ stopColor: '#F0F0F0' }}></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<polygon
|
||||||
|
style={{ fill: 'url(#SVGID_3_)' }}
|
||||||
|
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
|
||||||
|
></polygon>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#69A092' }}
|
||||||
|
d="M441.853,417.32H70.147C31.566,417.32,0,385.754,0,347.173V168.515h512v178.658 C512,385.754,480.434,417.32,441.853,417.32z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#D4B476' }}
|
||||||
|
d="M441.853,429.594H70.147C31.566,429.594,0,398.028,0,359.447V189.995h512v169.453 C512,398.028,480.434,429.594,441.853,429.594z"
|
||||||
|
></path>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#CBBC89' }}
|
||||||
|
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#CBBC89' }}
|
||||||
|
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#CBBC89' }}
|
||||||
|
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#CBBC89' }}
|
||||||
|
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#98806E' }}
|
||||||
|
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#98806E' }}
|
||||||
|
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#98806E' }}
|
||||||
|
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
style={{ fill: '#98806E' }}
|
||||||
|
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<polygon
|
||||||
|
style={{ fill: '#BBAF98' }}
|
||||||
|
points="276.167,208.741 0,302.069 0,186.053 512,186.053 512,302.069 "
|
||||||
|
></polygon>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const LogIcon = (props: IconSvgProps) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<rect width="48" height="48" fill="white" fillOpacity="0.01"></rect>
|
||||||
|
<rect
|
||||||
|
x="13"
|
||||||
|
y="10"
|
||||||
|
width="28"
|
||||||
|
height="34"
|
||||||
|
fill="#2F88FF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M35 10V4H8C7.44772 4 7 4.44772 7 5V38H13"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M21 22H33"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M21 30H33"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
@@ -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',
|
||||||
|
@@ -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"
|
||||||
|
@@ -67,7 +67,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||||
>
|
>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
<h2 className="font-bold">{api.description}</h2>
|
||||||
<div
|
<div
|
||||||
className={clsx('text-sm text-danger-200', {
|
className={clsx('text-sm text-danger-200', {
|
||||||
'!text-danger-400': apiName === selectedApi
|
'!text-danger-400': apiName === selectedApi
|
||||||
|
@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
|||||||
<PageLoading loading={loading} />
|
<PageLoading loading={loading} />
|
||||||
{error ? (
|
{error ? (
|
||||||
<CardBody className="items-center gap-1 justify-center">
|
<CardBody className="items-center gap-1 justify-center">
|
||||||
<div className="font-outfit flex-1 text-content1-foreground">
|
<div className="flex-1 text-content1-foreground">Error</div>
|
||||||
Error
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||||
{error.message}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col justify-center">
|
<div className="flex-col justify-center">
|
||||||
<div className="font-outfit text-lg truncate">{data?.nick}</div>
|
<div className="text-lg truncate">{data?.nick}</div>
|
||||||
<div className="font-ubuntu text-danger-500 text-sm">
|
<div className="text-danger-500 text-sm">{data?.uin}</div>
|
||||||
{data?.uin}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
)}
|
)}
|
||||||
|
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
|
@@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme'
|
|||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import type { MenuItem } from '@/config/site'
|
import type { MenuItem } from '@/config/site'
|
||||||
|
|
||||||
import { title } from '../primitives'
|
|
||||||
import Menus from './menus'
|
import Menus from './menus'
|
||||||
|
|
||||||
interface SideBarProps {
|
interface SideBarProps {
|
||||||
@@ -48,19 +47,15 @@ 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 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',
|
||||||
title({
|
'!text-2xl shiny-text'
|
||||||
shadow: true,
|
|
||||||
color: isDark ? 'violet' : 'pink'
|
|
||||||
}),
|
|
||||||
'!text-2xl'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
WebUI
|
NapCat
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto flex flex-col flex-1 px-4">
|
<div className="overflow-y-auto flex flex-col flex-1 px-4">
|
||||||
|
@@ -16,7 +16,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 {
|
||||||
@@ -198,11 +197,6 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
<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 +210,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" />}
|
||||||
|
89
napcat.webui/src/components/tabs/index.tsx
Normal file
89
napcat.webui/src/components/tabs/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
|
||||||
|
|
||||||
|
export interface TabsContextValue {
|
||||||
|
activeKey: string
|
||||||
|
onChange: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContext = createContext<TabsContextValue>({
|
||||||
|
activeKey: '',
|
||||||
|
onChange: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface TabsProps {
|
||||||
|
activeKey: string
|
||||||
|
onChange: (key: string) => void
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ activeKey, onChange }}>
|
||||||
|
<div className={clsx('flex flex-col gap-2', className)}>{children}</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabListProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabList({ children, className }: TabListProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex items-center gap-1', className)}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
isSelected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tab = forwardRef<HTMLDivElement, TabProps>(
|
||||||
|
({ className, isSelected, value, ...props }, ref) => {
|
||||||
|
const { onChange } = useContext(TabsContext)
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
onChange(value)
|
||||||
|
props.onClick?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-danger text-danger'
|
||||||
|
: 'border-transparent hover:border-default',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Tab.displayName = 'Tab'
|
||||||
|
|
||||||
|
export interface TabPanelProps {
|
||||||
|
value: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPanel({ value, children, className }: TabPanelProps) {
|
||||||
|
const { activeKey } = useContext(TabsContext)
|
||||||
|
|
||||||
|
if (value !== activeKey) return null
|
||||||
|
|
||||||
|
return <div className={clsx('flex-1', className)}>{children}</div>
|
||||||
|
}
|
38
napcat.webui/src/components/tabs/sortable_tab.tsx
Normal file
38
napcat.webui/src/components/tabs/sortable_tab.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
|
import { Tab } from '@/components/tabs'
|
||||||
|
import type { TabProps } from '@/components/tabs'
|
||||||
|
|
||||||
|
interface SortableTabProps extends TabProps {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTab({ id, ...props }: SortableTabProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging
|
||||||
|
} = useSortable({ id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
position: 'relative' as const,
|
||||||
|
touchAction: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@@ -19,7 +19,7 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
|||||||
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||||
a: ({ node, ...props }) => (
|
a: ({ node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 hover:underline"
|
className="text-primary-500 inline-block hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
|||||||
),
|
),
|
||||||
blockquote: ({ node, ...props }) => (
|
blockquote: ({ node, ...props }) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
className="border-l-4 border-gray-300 pl-4 italic"
|
className="border-l-4 border-default-300 pl-4 italic"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
code: ({ node, ...props }) => (
|
code: ({ node, ...props }) => (
|
||||||
<code className="bg-gray-100 p-1 rounded" {...props} />
|
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
56
napcat.webui/src/components/terminal/terminal-instance.tsx
Normal file
56
napcat.webui/src/components/terminal/terminal-instance.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import TerminalManager from '@/controllers/terminal_manager'
|
||||||
|
|
||||||
|
import XTerm, { XTermRef } from '../xterm'
|
||||||
|
|
||||||
|
interface TerminalInstanceProps {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||||
|
const termRef = useRef<XTermRef>(null)
|
||||||
|
const connected = useRef(false)
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.data) {
|
||||||
|
termRef.current?.write(parsed.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
termRef.current?.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (connected.current) {
|
||||||
|
TerminalManager.disconnectTerminal(id, handleData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const handleInput = (data: string) => {
|
||||||
|
TerminalManager.sendInput(id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
12
napcat.webui/src/components/under_construction.tsx
Normal file
12
napcat.webui/src/components/under_construction.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default function UnderConstruction() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full pt-4">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<div className="text-6xl font-bold text-gray-500">🚧</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-500">
|
||||||
|
Under Construction
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@@ -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,134 +19,174 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
export interface XTermProps
|
||||||
(props, ref) => {
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||||
const domRef = useRef<HTMLDivElement>(null)
|
onInput?: (data: string) => void
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
onKey?: (key: string, event: KeyboardEvent) => void
|
||||||
const { className, ...rest } = props
|
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||||
const { theme } = useTheme()
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (!domRef.current) {
|
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||||
return
|
const domRef = useRef<HTMLDivElement>(null)
|
||||||
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
|
const { className, onInput, onKey, onResize, ...rest } = props
|
||||||
|
const { theme } = useTheme()
|
||||||
|
useEffect(() => {
|
||||||
|
const terminal = new Terminal({
|
||||||
|
allowTransparency: true,
|
||||||
|
fontFamily:
|
||||||
|
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||||
|
cursorInactiveStyle: 'outline',
|
||||||
|
drawBoldTextInBrightColors: false,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2
|
||||||
|
})
|
||||||
|
terminalRef.current = terminal
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(
|
||||||
|
new WebLinksAddon((event, uri) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
window.open(uri, '_blank')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.open(domRef.current!)
|
||||||
|
|
||||||
|
terminal.loadAddon(new CanvasAddon())
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (onInput) {
|
||||||
|
onInput(data)
|
||||||
}
|
}
|
||||||
const terminal = new Terminal({
|
})
|
||||||
allowTransparency: true,
|
|
||||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
|
||||||
cursorInactiveStyle: 'outline',
|
|
||||||
drawBoldTextInBrightColors: false,
|
|
||||||
letterSpacing: 0,
|
|
||||||
lineHeight: 1.0
|
|
||||||
})
|
|
||||||
terminalRef.current = terminal
|
|
||||||
const fitAddon = new FitAddon()
|
|
||||||
terminal.loadAddon(
|
|
||||||
new WebLinksAddon((event, uri) => {
|
|
||||||
if (event.ctrlKey) {
|
|
||||||
window.open(uri, '_blank')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
terminal.loadAddon(fitAddon)
|
|
||||||
terminal.loadAddon(new WebglAddon())
|
|
||||||
terminal.open(domRef.current)
|
|
||||||
|
|
||||||
terminal.writeln(
|
terminal.onKey((event) => {
|
||||||
gradientText(
|
if (onKey) {
|
||||||
'Welcome to NapCat WebUI',
|
onKey(event.key, event.domEvent)
|
||||||
[255, 0, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
fitAddon.fit()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 字体加载完成后重新调整终端大小
|
|
||||||
document.fonts.ready.then(() => {
|
|
||||||
fitAddon.fit()
|
|
||||||
|
|
||||||
resizeObserver.observe(domRef.current!)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
setTimeout(() => {
|
|
||||||
terminal.dispose()
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
}, [])
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (terminalRef.current) {
|
fitAddon.fit()
|
||||||
|
// 获取当前终端尺寸
|
||||||
|
const cols = terminal.cols
|
||||||
|
const rows = terminal.rows
|
||||||
|
if (onResize) {
|
||||||
|
onResize(cols, rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字体加载完成后重新调整终端大小
|
||||||
|
document.fonts.ready.then(() => {
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
resizeObserver.observe(domRef.current!)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.dispose()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (terminalRef.current) {
|
||||||
|
if (theme === 'dark') {
|
||||||
terminalRef.current.options.theme = {
|
terminalRef.current.options.theme = {
|
||||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
background: '#00000000',
|
||||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
black: '#ffffff',
|
||||||
selectionBackground:
|
red: '#cd3131',
|
||||||
theme === 'dark'
|
green: '#0dbc79',
|
||||||
? 'rgba(179, 0, 0, 0.3)'
|
yellow: '#e5e510',
|
||||||
: 'rgba(255, 167, 167, 0.3)',
|
blue: '#2472c8',
|
||||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
cyan: '#11a8cd',
|
||||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
white: '#e5e5e5',
|
||||||
black: theme === 'dark' ? '#fff' : '#000'
|
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])
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
write: (...args) => {
|
write: (...args) => {
|
||||||
return terminalRef.current?.write(...args)
|
return terminalRef.current?.write(...args)
|
||||||
},
|
},
|
||||||
writeAsync: async (data) => {
|
writeAsync: async (data) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
terminalRef.current?.write(data, resolve)
|
terminalRef.current?.write(data, resolve)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
writeln: (...args) => {
|
writeln: (...args) => {
|
||||||
return terminalRef.current?.writeln(...args)
|
return terminalRef.current?.writeln(...args)
|
||||||
},
|
},
|
||||||
writelnAsync: async (data) => {
|
writelnAsync: async (data) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
terminalRef.current?.writeln(data, resolve)
|
terminalRef.current?.writeln(data, resolve)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
terminalRef.current?.clear()
|
terminalRef.current?.clear()
|
||||||
}
|
},
|
||||||
}),
|
terminalRef: terminalRef
|
||||||
[]
|
}),
|
||||||
)
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
|
||||||
|
theme === 'dark' ? 'bg-black' : 'bg-white',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
style={{
|
||||||
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
|
width: '100%',
|
||||||
theme === 'dark' ? 'bg-black' : 'bg-white',
|
height: '100%'
|
||||||
className
|
}}
|
||||||
)}
|
ref={domRef}
|
||||||
{...rest}
|
></div>
|
||||||
>
|
</div>
|
||||||
<div
|
)
|
||||||
style={{
|
})
|
||||||
width: '100%',
|
|
||||||
height: '100%'
|
|
||||||
}}
|
|
||||||
ref={domRef}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default XTerm
|
export default XTerm
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BugIcon2,
|
BugIcon2,
|
||||||
|
FileIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
LogIcon,
|
||||||
RouteIcon,
|
RouteIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
SignalTowerIcon,
|
SignalTowerIcon,
|
||||||
@@ -49,10 +51,10 @@ export const siteConfig = {
|
|||||||
href: '/config'
|
href: '/config'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '系统日志',
|
label: '猫猫日志',
|
||||||
icon: (
|
icon: (
|
||||||
<div className="w-5 h-5">
|
<div className="w-5 h-5">
|
||||||
<TerminalIcon />
|
<LogIcon />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
href: '/logs'
|
href: '/logs'
|
||||||
@@ -75,6 +77,24 @@ export const siteConfig = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '文件管理',
|
||||||
|
icon: (
|
||||||
|
<div className="w-5 h-5">
|
||||||
|
<FileIcon />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
href: '/file_manager'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '系统终端',
|
||||||
|
icon: (
|
||||||
|
<div className="w-5 h-5">
|
||||||
|
<TerminalIcon />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
href: '/terminal'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '关于我们',
|
label: '关于我们',
|
||||||
icon: (
|
icon: (
|
||||||
|
199
napcat.webui/src/controllers/file_manager.ts
Normal file
199
napcat.webui/src/controllers/file_manager.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { serverRequest } from '@/utils/request'
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
name: string
|
||||||
|
isDirectory: boolean
|
||||||
|
size: number
|
||||||
|
mtime: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class FileManager {
|
||||||
|
public static async listFiles(path: string = '/') {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||||
|
`/File/list?path=${encodeURIComponent(path)}`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:按目录获取
|
||||||
|
public static async listDirectories(path: string = '/') {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||||
|
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createDirectory(path: string): Promise<boolean> {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/mkdir',
|
||||||
|
{ path }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async delete(path: string) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/delete',
|
||||||
|
{ path }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async readFile(path: string) {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<string>>(
|
||||||
|
`/File/read?path=${encodeURIComponent(path)}`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async writeFile(path: string, content: string) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/write',
|
||||||
|
{ path, content }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createFile(path: string): Promise<boolean> {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/create',
|
||||||
|
{ path }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async batchDelete(paths: string[]) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/batchDelete',
|
||||||
|
{ paths }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async rename(oldPath: string, newPath: string) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/rename',
|
||||||
|
{ oldPath, newPath }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async move(sourcePath: string, targetPath: string) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/move',
|
||||||
|
{ sourcePath, targetPath }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async batchMove(
|
||||||
|
items: { sourcePath: string; targetPath: string }[]
|
||||||
|
) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/File/batchMove',
|
||||||
|
{ items }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static download(path: string) {
|
||||||
|
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||||
|
toast
|
||||||
|
.promise(
|
||||||
|
serverRequest
|
||||||
|
.post(downloadUrl, void 0, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('下载失败')
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: '正在下载文件...',
|
||||||
|
success: '下载成功',
|
||||||
|
error: '下载失败'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
let fileName = path.split('/').pop() || ''
|
||||||
|
if (path.split('.').length === 1) {
|
||||||
|
fileName += '.zip'
|
||||||
|
}
|
||||||
|
link.setAttribute('download', fileName)
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async batchDownload(paths: string[]) {
|
||||||
|
const downloadUrl = `/File/batchDownload`
|
||||||
|
toast
|
||||||
|
.promise(
|
||||||
|
serverRequest
|
||||||
|
.post(
|
||||||
|
downloadUrl,
|
||||||
|
{ paths },
|
||||||
|
{
|
||||||
|
responseType: 'blob'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('下载失败')
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: '正在下载文件...',
|
||||||
|
success: '下载成功',
|
||||||
|
error: '下载失败'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
const fileName = 'files.zip'
|
||||||
|
link.setAttribute('download', fileName)
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async downloadToURL(path: string) {
|
||||||
|
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||||
|
const response = await serverRequest.post(downloadUrl, void 0, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
return window.URL.createObjectURL(new Blob([response.data]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async upload(path: string, files: File[]) {
|
||||||
|
const formData = new FormData()
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('files', file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
`/File/upload?path=${encodeURIComponent(path)}`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
}
|
133
napcat.webui/src/controllers/terminal_manager.ts
Normal file
133
napcat.webui/src/controllers/terminal_manager.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { serverRequest } from '@/utils/request'
|
||||||
|
|
||||||
|
type TerminalCallback = (data: string) => void
|
||||||
|
|
||||||
|
interface TerminalConnection {
|
||||||
|
ws: WebSocket
|
||||||
|
callbacks: Set<TerminalCallback>
|
||||||
|
isConnected: boolean
|
||||||
|
buffer: string[] // 添加缓存数组
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalSession {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalInfo {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class TerminalManager {
|
||||||
|
private connections: Map<string, TerminalConnection> = new Map()
|
||||||
|
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
|
||||||
|
|
||||||
|
async createTerminal(cols: number, rows: number): Promise<TerminalSession> {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
|
||||||
|
'/Log/terminal/create',
|
||||||
|
{ cols, rows }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTerminal(id: string): Promise<void> {
|
||||||
|
await serverRequest.post(`/Log/terminal/${id}/close`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTerminalList(): Promise<TerminalInfo[]> {
|
||||||
|
const { data } =
|
||||||
|
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
|
||||||
|
'/Log/terminal/list'
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
connectTerminal(
|
||||||
|
id: string,
|
||||||
|
callback: TerminalCallback,
|
||||||
|
config?: {
|
||||||
|
cols?: number
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
): WebSocket {
|
||||||
|
let conn = this.connections.get(id)
|
||||||
|
const { cols = 80, rows = 24 } = config || {}
|
||||||
|
if (!conn) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.protocol = url.protocol.replace('http', 'ws')
|
||||||
|
url.pathname = `/api/ws/terminal`
|
||||||
|
url.searchParams.set('id', id)
|
||||||
|
const token = JSON.parse(localStorage.getItem('token') || '')
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No token found')
|
||||||
|
}
|
||||||
|
url.searchParams.set('token', token)
|
||||||
|
const ws = new WebSocket(url.toString())
|
||||||
|
conn = {
|
||||||
|
ws,
|
||||||
|
callbacks: new Set([callback]),
|
||||||
|
isConnected: false,
|
||||||
|
buffer: [] // 初始化缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = event.data
|
||||||
|
// 保存到缓存
|
||||||
|
conn?.buffer.push(data)
|
||||||
|
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
|
||||||
|
conn?.buffer.shift()
|
||||||
|
}
|
||||||
|
conn?.callbacks.forEach((cb) => cb(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (conn) conn.isConnected = true
|
||||||
|
this.sendResize(id, cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (conn) conn.isConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connections.set(id, conn)
|
||||||
|
} else {
|
||||||
|
conn.callbacks.add(callback)
|
||||||
|
// 恢复历史内容
|
||||||
|
conn.buffer.forEach((data) => callback(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.ws
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectTerminal(id: string, callback: TerminalCallback) {
|
||||||
|
const conn = this.connections.get(id)
|
||||||
|
if (!conn) return
|
||||||
|
|
||||||
|
conn.callbacks.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTerminal(id: string) {
|
||||||
|
const conn = this.connections.get(id)
|
||||||
|
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||||
|
conn.ws.close()
|
||||||
|
}
|
||||||
|
this.connections.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInput(id: string, data: string) {
|
||||||
|
const conn = this.connections.get(id)
|
||||||
|
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||||
|
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()
|
||||||
|
|
||||||
|
export default terminalManager
|
@@ -9,6 +9,14 @@ export interface Log {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalSession {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalInfo {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
export default class WebUIManager {
|
export default class WebUIManager {
|
||||||
public static async checkWebUiLogined() {
|
public static async checkWebUiLogined() {
|
||||||
const { data } =
|
const { data } =
|
||||||
@@ -24,12 +32,20 @@ export default class WebUIManager {
|
|||||||
return data.data.Credential
|
return data.data.Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async changePassword(oldToken: string, newToken: string) {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||||
|
'/auth/update_token',
|
||||||
|
{ oldToken, newToken }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
public static async proxy<T>(url = '') {
|
public static async proxy<T>(url = '') {
|
||||||
const data = await serverRequest.get<ServerResponse<string>>(
|
const data = await serverRequest.get<ServerResponse<string>>(
|
||||||
'/base/proxy?url=' + encodeURIComponent(url)
|
'/base/proxy?url=' + encodeURIComponent(url)
|
||||||
)
|
)
|
||||||
data.data.data = JSON.parse(data.data.data)
|
data.data.data = JSON.parse(data.data.data)
|
||||||
return data.data as ServerResponse<T>
|
return data.data as ServerResponse<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getPackageInfo() {
|
public static async getPackageInfo() {
|
||||||
|
@@ -14,10 +14,12 @@ const useConfig = () => {
|
|||||||
key: T,
|
key: T,
|
||||||
value: OneBotConfig['network'][T][0]
|
value: OneBotConfig['network'][T][0]
|
||||||
) => {
|
) => {
|
||||||
if (
|
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
|
||||||
value.name &&
|
const _key = key as keyof OneBotConfig['network']
|
||||||
config.network[key].some((item) => item.name === value.name)
|
return acc.concat(config.network[_key].map((item) => item.name))
|
||||||
) {
|
}, [] as string[])
|
||||||
|
|
||||||
|
if (value.name && allNetworkNames.includes(value.name)) {
|
||||||
throw new Error('已经存在相同的配置项名')
|
throw new Error('已经存在相同的配置项名')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
@@ -98,7 +98,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
|
||||||
'dark:bg-background dark:shadow-danger-100',
|
'dark:bg-background dark:shadow-danger-100',
|
||||||
'bg-background !bg-opacity-50',
|
'bg-background !bg-opacity-50',
|
||||||
'shadow-sm shadow-danger-50',
|
'shadow-sm shadow-danger-50',
|
||||||
|
@@ -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,91 +1,200 @@
|
|||||||
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 { 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 { BietiaopIcon, WebUIIcon } from '@/components/icons'
|
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'
|
||||||
|
|
||||||
import packageJson from '../../../package.json'
|
|
||||||
|
|
||||||
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-danger-500 drop-shadow-md">NapCat</div>
|
||||||
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
|
|
||||||
WebUI
|
|
||||||
</Chip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{packageJson.version}
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
startContent={
|
|
||||||
<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-danger-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 (
|
||||||
|
<div className="flex-1 h-32 flex items-center justify-center bg-default-100 rounded-lg">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className="flex-1 pointer-events-none select-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">
|
<div className="flex flex-col md:flex-row items-center">
|
||||||
<Image
|
<HoverTiltedCard imageSrc={logo} overlayContent="" />
|
||||||
alt="logo"
|
</div>
|
||||||
className="flex-shrink-0 w-52 md:w-48 mr-2"
|
<div className="flex-1 flex flex-col gap-2 py-2">
|
||||||
src={logo}
|
<VersionInfo />
|
||||||
/>
|
<div className="space-y-1">
|
||||||
<div className="flex -mt-9 md:mt-0">
|
<p className="font-bold text-danger-400">NapCat 是什么?</p>
|
||||||
<WebUIIcon />
|
<p className="text-default-800">
|
||||||
|
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||||
|
Node模块提供给客户端的接口,实现Bot的功能.
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-danger-400">魔法版介绍</p>
|
||||||
|
<p className="text-default-800">
|
||||||
|
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||||
|
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||||
|
WebSocket 请求按照规范读取,
|
||||||
|
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
|
</div>
|
||||||
Created By
|
<div className="flex flex-row gap-2 flex-wrap justify-around">
|
||||||
<div className="flex scale-80 -ml-5 -mr-5">
|
<Card
|
||||||
<BietiaopIcon />
|
as={Link}
|
||||||
</div>
|
shadow="sm"
|
||||||
</div>
|
isPressable
|
||||||
<VersionInfo />
|
isExternal
|
||||||
<div className="mb-6 flex flex-col items-center gap-4">
|
href="https://qm.qq.com/q/F9cgs1N3Mc"
|
||||||
<p
|
>
|
||||||
className={clsx(
|
<CardBody className="flex-row items-center gap-2">
|
||||||
title({
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
color: 'cyan',
|
<BsTencentQq size={16} />
|
||||||
shadow: true
|
</span>
|
||||||
}),
|
<span>官方社群1</span>
|
||||||
'!text-3xl'
|
</CardBody>
|
||||||
)}
|
</Card>
|
||||||
>
|
|
||||||
NapCat Contributors
|
<Card
|
||||||
</p>
|
as={Link}
|
||||||
<Image
|
shadow="sm"
|
||||||
className="w-[600px] max-w-full pointer-events-none select-none"
|
isPressable
|
||||||
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
|
isExternal
|
||||||
alt="Contributors"
|
href="https://qm.qq.com/q/hSt0u9PVn"
|
||||||
/>
|
>
|
||||||
|
<CardBody className="flex-row items-center gap-2">
|
||||||
|
<span className="p-2 rounded-small bg-primary-50">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<NapCatRepoInfo />
|
<NapCatRepoInfo />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
81
napcat.webui/src/pages/dashboard/config/change_password.tsx
Normal file
81
napcat.webui/src/pages/dashboard/config/change_password.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Input } from '@heroui/input'
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import key from '@/const/key'
|
||||||
|
|
||||||
|
import SaveButtons from '@/components/button/save_buttons'
|
||||||
|
|
||||||
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
|
|
||||||
|
const ChangePasswordCard = () => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit: handleWebuiSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
reset
|
||||||
|
} = useForm<{
|
||||||
|
oldToken: string
|
||||||
|
newToken: string
|
||||||
|
}>({
|
||||||
|
defaultValues: {
|
||||||
|
oldToken: '',
|
||||||
|
newToken: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [_, setToken] = useLocalStorage(key.token, '')
|
||||||
|
|
||||||
|
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||||
|
toast.success('修改成功')
|
||||||
|
setToken('')
|
||||||
|
localStorage.removeItem(key.token)
|
||||||
|
navigate('/web_login')
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message
|
||||||
|
toast.error(`修改失败: ${msg}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>修改密码 - NapCat WebUI</title>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="oldToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
label="旧密码"
|
||||||
|
placeholder="请输入旧密码"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="newToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
label="新密码"
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SaveButtons
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
reset={reset}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangePasswordCard
|
@@ -1,111 +1,27 @@
|
|||||||
|
import { Card, CardBody } from '@heroui/card'
|
||||||
import { Tab, Tabs } from '@heroui/tabs'
|
import { Tab, Tabs } from '@heroui/tabs'
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
|
||||||
import key from '@/const/key'
|
import ChangePasswordCard from './change_password'
|
||||||
|
|
||||||
import PageLoading from '@/components/page_loading'
|
|
||||||
|
|
||||||
import useConfig from '@/hooks/use-config'
|
|
||||||
import useMusic from '@/hooks/use-music'
|
|
||||||
|
|
||||||
import OneBotConfigCard from './onebot'
|
import OneBotConfigCard from './onebot'
|
||||||
import WebUIConfigCard from './webui'
|
import WebUIConfigCard from './webui'
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export interface ConfigPageProps {
|
||||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
children?: React.ReactNode
|
||||||
const [loading, setLoading] = useState(false)
|
}
|
||||||
const {
|
|
||||||
control: onebotControl,
|
|
||||||
handleSubmit: handleOnebotSubmit,
|
|
||||||
formState: { isSubmitting: isOnebotSubmitting },
|
|
||||||
setValue: setOnebotValue
|
|
||||||
} = useForm<IConfig['onebot']>({
|
|
||||||
defaultValues: {
|
|
||||||
musicSignUrl: '',
|
|
||||||
enableLocalFile2Url: false,
|
|
||||||
parseMultMsg: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
|
||||||
control: webuiControl,
|
return (
|
||||||
handleSubmit: handleWebuiSubmit,
|
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||||
formState: { isSubmitting: isWebuiSubmitting },
|
<CardBody className="items-center py-5">
|
||||||
setValue: setWebuiValue
|
<div className="w-96 max-w-full flex flex-col gap-2">{children}</div>
|
||||||
} = useForm<IConfig['webui']>({
|
</CardBody>
|
||||||
defaultValues: {
|
</Card>
|
||||||
background: '',
|
|
||||||
musicListID: '',
|
|
||||||
customIcons: {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
|
||||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
|
||||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
|
||||||
key.customIcons,
|
|
||||||
{}
|
|
||||||
)
|
)
|
||||||
const { setListId, listId } = useMusic()
|
}
|
||||||
const resetOneBot = () => {
|
|
||||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
|
||||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
|
||||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetWebUI = () => {
|
export default function ConfigPage() {
|
||||||
setWebuiValue('musicListID', listId)
|
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||||
setWebuiValue('customIcons', customIcons)
|
|
||||||
setWebuiValue('background', b64img)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onOneBotSubmit = handleOnebotSubmit((data) => {
|
|
||||||
try {
|
|
||||||
saveConfigWithoutNetwork(data)
|
|
||||||
toast.success('保存成功')
|
|
||||||
} catch (error) {
|
|
||||||
const msg = (error as Error).message
|
|
||||||
toast.error(`保存失败: ${msg}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onWebuiSubmit = handleWebuiSubmit((data) => {
|
|
||||||
try {
|
|
||||||
setListId(data.musicListID)
|
|
||||||
setCustomIcons(data.customIcons)
|
|
||||||
setB64img(data.background)
|
|
||||||
toast.success('保存成功')
|
|
||||||
} catch (error) {
|
|
||||||
const msg = (error as Error).message
|
|
||||||
toast.error(`保存失败: ${msg}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onRefresh = async (shotTip = true) => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
await refreshConfig()
|
|
||||||
if (shotTip) toast.success('刷新成功')
|
|
||||||
} catch (error) {
|
|
||||||
const msg = (error as Error).message
|
|
||||||
toast.error(`刷新失败: ${msg}`)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetOneBot()
|
|
||||||
resetWebUI()
|
|
||||||
}, [config])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onRefresh(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
|
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
|
||||||
@@ -122,23 +38,20 @@ export default function ConfigPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab title="OneBot配置" key="onebot">
|
<Tab title="OneBot配置" key="onebot">
|
||||||
<PageLoading loading={loading} />
|
<ConfingPageItem>
|
||||||
<OneBotConfigCard
|
<OneBotConfigCard />
|
||||||
isSubmitting={isOnebotSubmitting}
|
</ConfingPageItem>
|
||||||
onRefresh={onRefresh}
|
|
||||||
onSubmit={onOneBotSubmit}
|
|
||||||
control={onebotControl}
|
|
||||||
reset={resetOneBot}
|
|
||||||
/>
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="WebUI配置" key="webui">
|
<Tab title="WebUI配置" key="webui">
|
||||||
<WebUIConfigCard
|
<ConfingPageItem>
|
||||||
isSubmitting={isWebuiSubmitting}
|
<WebUIConfigCard />
|
||||||
onRefresh={onRefresh}
|
</ConfingPageItem>
|
||||||
onSubmit={onWebuiSubmit}
|
</Tab>
|
||||||
control={webuiControl}
|
|
||||||
reset={resetWebUI}
|
<Tab title="修改密码" key="token">
|
||||||
/>
|
<ConfingPageItem>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</ConfingPageItem>
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -1,68 +1,110 @@
|
|||||||
import { Card, CardBody } from '@heroui/card'
|
|
||||||
import { Input } from '@heroui/input'
|
import { Input } from '@heroui/input'
|
||||||
import { Controller } from 'react-hook-form'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Control } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import SaveButtons from '@/components/button/save_buttons'
|
import SaveButtons from '@/components/button/save_buttons'
|
||||||
|
import PageLoading from '@/components/page_loading'
|
||||||
import SwitchCard from '@/components/switch_card'
|
import SwitchCard from '@/components/switch_card'
|
||||||
|
|
||||||
export interface OneBotConfigCardProps {
|
import useConfig from '@/hooks/use-config'
|
||||||
control: Control<IConfig['onebot']>
|
|
||||||
onSubmit: () => void
|
const OneBotConfigCard = () => {
|
||||||
reset: () => void
|
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||||
isSubmitting: boolean
|
const [loading, setLoading] = useState(false)
|
||||||
onRefresh: () => void
|
const {
|
||||||
}
|
control,
|
||||||
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
|
handleSubmit: handleOnebotSubmit,
|
||||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
formState: { isSubmitting },
|
||||||
|
setValue: setOnebotValue
|
||||||
|
} = useForm<IConfig['onebot']>({
|
||||||
|
defaultValues: {
|
||||||
|
musicSignUrl: '',
|
||||||
|
enableLocalFile2Url: false,
|
||||||
|
parseMultMsg: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const reset = () => {
|
||||||
|
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||||
|
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||||
|
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = handleOnebotSubmit((data) => {
|
||||||
|
try {
|
||||||
|
saveConfigWithoutNetwork(data)
|
||||||
|
toast.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message
|
||||||
|
toast.error(`保存失败: ${msg}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onRefresh = async (shotTip = true) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
await refreshConfig()
|
||||||
|
if (shotTip) toast.success('刷新成功')
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message
|
||||||
|
toast.error(`刷新失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset()
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRefresh(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <PageLoading loading={true} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>OneBot配置 - NapCat WebUI</title>
|
<title>OneBot配置 - NapCat WebUI</title>
|
||||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
<Controller
|
||||||
<CardBody className="items-center py-5">
|
control={control}
|
||||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
name="musicSignUrl"
|
||||||
<Controller
|
render={({ field }) => (
|
||||||
control={control}
|
<Input
|
||||||
name="musicSignUrl"
|
{...field}
|
||||||
render={({ field }) => (
|
label="音乐签名地址"
|
||||||
<Input
|
placeholder="请输入音乐签名地址"
|
||||||
{...field}
|
/>
|
||||||
label="音乐签名地址"
|
)}
|
||||||
placeholder="请输入音乐签名地址"
|
/>
|
||||||
/>
|
<Controller
|
||||||
)}
|
control={control}
|
||||||
/>
|
name="enableLocalFile2Url"
|
||||||
<Controller
|
render={({ field }) => (
|
||||||
control={control}
|
<SwitchCard
|
||||||
name="enableLocalFile2Url"
|
{...field}
|
||||||
render={({ field }) => (
|
description="启用本地文件到URL"
|
||||||
<SwitchCard
|
label="启用本地文件到URL"
|
||||||
{...field}
|
/>
|
||||||
description="启用本地文件到URL"
|
)}
|
||||||
label="启用本地文件到URL"
|
/>
|
||||||
/>
|
<Controller
|
||||||
)}
|
control={control}
|
||||||
/>
|
name="parseMultMsg"
|
||||||
<Controller
|
render={({ field }) => (
|
||||||
control={control}
|
<SwitchCard
|
||||||
name="parseMultMsg"
|
{...field}
|
||||||
render={({ field }) => (
|
description="启用上报解析合并消息"
|
||||||
<SwitchCard
|
label="启用上报解析合并消息"
|
||||||
{...field}
|
/>
|
||||||
description="启用上报解析合并消息"
|
)}
|
||||||
label="启用上报解析合并消息"
|
/>
|
||||||
/>
|
<SaveButtons
|
||||||
)}
|
onSubmit={onSubmit}
|
||||||
/>
|
reset={reset}
|
||||||
<SaveButtons
|
isSubmitting={isSubmitting}
|
||||||
onSubmit={onSubmit}
|
refresh={onRefresh}
|
||||||
reset={reset}
|
/>
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
refresh={onRefresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,69 +1,99 @@
|
|||||||
import { Card, CardBody } from '@heroui/card'
|
|
||||||
import { Input } from '@heroui/input'
|
import { Input } from '@heroui/input'
|
||||||
import { Controller } from 'react-hook-form'
|
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||||
import type { Control } from 'react-hook-form'
|
import { useEffect } from 'react'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import key from '@/const/key'
|
||||||
|
|
||||||
import SaveButtons from '@/components/button/save_buttons'
|
import SaveButtons from '@/components/button/save_buttons'
|
||||||
import ImageInput from '@/components/input/image_input'
|
import ImageInput from '@/components/input/image_input'
|
||||||
|
|
||||||
|
import useMusic from '@/hooks/use-music'
|
||||||
|
|
||||||
import { siteConfig } from '@/config/site'
|
import { siteConfig } from '@/config/site'
|
||||||
|
|
||||||
export interface WebUIConfigCardProps {
|
const WebUIConfigCard = () => {
|
||||||
control: Control<IConfig['webui']>
|
const {
|
||||||
onSubmit: () => void
|
control,
|
||||||
reset: () => void
|
handleSubmit: handleWebuiSubmit,
|
||||||
isSubmitting: boolean
|
formState: { isSubmitting },
|
||||||
onRefresh: () => void
|
setValue: setWebuiValue
|
||||||
}
|
} = useForm<IConfig['webui']>({
|
||||||
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
|
defaultValues: {
|
||||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
background: '',
|
||||||
|
musicListID: '',
|
||||||
|
customIcons: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||||
|
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||||
|
key.customIcons,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
const { setListId, listId } = useMusic()
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setWebuiValue('musicListID', listId)
|
||||||
|
setWebuiValue('customIcons', customIcons)
|
||||||
|
setWebuiValue('background', b64img)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = handleWebuiSubmit((data) => {
|
||||||
|
try {
|
||||||
|
setListId(data.musicListID)
|
||||||
|
setCustomIcons(data.customIcons)
|
||||||
|
setB64img(data.background)
|
||||||
|
toast.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message
|
||||||
|
toast.error(`保存失败: ${msg}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset()
|
||||||
|
}, [listId, customIcons, b64img])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>WebUI配置 - NapCat WebUI</title>
|
<title>WebUI配置 - NapCat WebUI</title>
|
||||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
<Controller
|
||||||
<CardBody className="items-center py-5">
|
control={control}
|
||||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
name="musicListID"
|
||||||
<Controller
|
render={({ field }) => (
|
||||||
control={control}
|
<Input
|
||||||
name="musicListID"
|
{...field}
|
||||||
render={({ field }) => (
|
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||||
<Input
|
placeholder="请输入歌单ID"
|
||||||
{...field}
|
/>
|
||||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
)}
|
||||||
placeholder="请输入歌单ID"
|
/>
|
||||||
/>
|
<div className="flex flex-col gap-2">
|
||||||
)}
|
<div className="flex-shrink-0 w-full">背景图</div>
|
||||||
/>
|
<Controller
|
||||||
<div className="flex flex-col gap-2">
|
control={control}
|
||||||
<div className="flex-shrink-0 w-full">背景图</div>
|
name="background"
|
||||||
<Controller
|
render={({ field }) => <ImageInput {...field} />}
|
||||||
control={control}
|
/>
|
||||||
name="background"
|
</div>
|
||||||
render={({ field }) => <ImageInput {...field} />}
|
<div className="flex flex-col gap-2">
|
||||||
/>
|
<div>自定义图标</div>
|
||||||
</div>
|
{siteConfig.navItems.map((item) => (
|
||||||
<div className="flex flex-col gap-2">
|
<Controller
|
||||||
<div>自定义图标</div>
|
key={item.label}
|
||||||
{siteConfig.navItems.map((item) => (
|
control={control}
|
||||||
<Controller
|
name={`customIcons.${item.label}`}
|
||||||
key={item.label}
|
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||||
control={control}
|
/>
|
||||||
name={`customIcons.${item.label}`}
|
))}
|
||||||
render={({ field }) => (
|
</div>
|
||||||
<ImageInput {...field} label={item.label} />
|
<SaveButtons
|
||||||
)}
|
onSubmit={onSubmit}
|
||||||
/>
|
reset={reset}
|
||||||
))}
|
isSubmitting={isSubmitting}
|
||||||
</div>
|
/>
|
||||||
<SaveButtons
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
reset={reset}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
refresh={onRefresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -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, {
|
||||||
|
546
napcat.webui/src/pages/dashboard/file_manager.tsx
Normal file
546
napcat.webui/src/pages/dashboard/file_manager.tsx
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
|
||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Input } from '@heroui/input'
|
||||||
|
import type { Selection, SortDescriptor } from '@react-types/shared'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { FiDownload, FiMove, FiPlus, FiUpload } from 'react-icons/fi'
|
||||||
|
import { MdRefresh } from 'react-icons/md'
|
||||||
|
import { TbTrash } from 'react-icons/tb'
|
||||||
|
import { TiArrowBack } from 'react-icons/ti'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import CreateFileModal from '@/components/file_manage/create_file_modal'
|
||||||
|
import FileEditModal from '@/components/file_manage/file_edit_modal'
|
||||||
|
import FilePreviewModal from '@/components/file_manage/file_preview_modal'
|
||||||
|
import FileTable from '@/components/file_manage/file_table'
|
||||||
|
import MoveModal from '@/components/file_manage/move_modal'
|
||||||
|
import RenameModal from '@/components/file_manage/rename_modal'
|
||||||
|
|
||||||
|
import useDialog from '@/hooks/use-dialog'
|
||||||
|
|
||||||
|
import FileManager, { FileInfo } from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
export default function FileManagerPage() {
|
||||||
|
const [files, setFiles] = useState<FileInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||||
|
column: 'name',
|
||||||
|
direction: 'ascending'
|
||||||
|
})
|
||||||
|
const dialog = useDialog()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
// 修改 currentPath 初始化逻辑,去掉可能的前导斜杠
|
||||||
|
let currentPath = decodeURIComponent(location.hash.slice(1) || '/')
|
||||||
|
if (/^\/[A-Z]:$/i.test(currentPath)) {
|
||||||
|
currentPath = currentPath.slice(1)
|
||||||
|
}
|
||||||
|
const [editingFile, setEditingFile] = useState<{
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
} | null>(null)
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [newFileName, setNewFileName] = useState('')
|
||||||
|
const [fileType, setFileType] = useState<'file' | 'directory'>('file')
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Selection>(new Set())
|
||||||
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
|
||||||
|
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
|
||||||
|
const [renamingFile, setRenamingFile] = useState<string>('')
|
||||||
|
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||||||
|
const [jumpPath, setJumpPath] = useState('')
|
||||||
|
const [previewFile, setPreviewFile] = useState<string>('')
|
||||||
|
const [showUpload, setShowUpload] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
|
||||||
|
return [...files].sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
||||||
|
const direction = descriptor.direction === 'ascending' ? 1 : -1
|
||||||
|
switch (descriptor.column) {
|
||||||
|
case 'name':
|
||||||
|
return direction * a.name.localeCompare(b.name)
|
||||||
|
case 'type': {
|
||||||
|
const aType = a.isDirectory ? '目录' : '文件'
|
||||||
|
const bType = a.isDirectory ? '目录' : '文件'
|
||||||
|
return direction * aType.localeCompare(bType)
|
||||||
|
}
|
||||||
|
case 'size':
|
||||||
|
return direction * ((a.size || 0) - (b.size || 0))
|
||||||
|
case 'mtime':
|
||||||
|
return (
|
||||||
|
direction *
|
||||||
|
(new Date(a.mtime).getTime() - new Date(b.mtime).getTime())
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const fileList = await FileManager.listFiles(currentPath)
|
||||||
|
setFiles(sortFiles(fileList, sortDescriptor))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('加载文件列表失败')
|
||||||
|
setFiles([])
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
}, [currentPath])
|
||||||
|
|
||||||
|
const handleSortChange = (descriptor: typeof sortDescriptor) => {
|
||||||
|
setSortDescriptor(descriptor)
|
||||||
|
setFiles((prev) => sortFiles(prev, descriptor))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDirectoryClick = (dirPath: string) => {
|
||||||
|
if (dirPath === '..') {
|
||||||
|
if (/^[A-Z]:$/i.test(currentPath)) {
|
||||||
|
navigate('/file_manager#/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parentPath = path.dirname(currentPath)
|
||||||
|
navigate(
|
||||||
|
`/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(
|
||||||
|
`/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (filePath: string) => {
|
||||||
|
try {
|
||||||
|
const content = await FileManager.readFile(filePath)
|
||||||
|
setEditingFile({ path: filePath, content })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('打开文件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editingFile) return
|
||||||
|
try {
|
||||||
|
await FileManager.writeFile(editingFile.path, editingFile.content)
|
||||||
|
toast.success('保存成功')
|
||||||
|
setEditingFile(null)
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (filePath: string) => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '删除文件',
|
||||||
|
content: <div>确定要删除文件 {filePath} 吗?</div>,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await FileManager.delete(filePath)
|
||||||
|
toast.success('删除成功')
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newFileName) return
|
||||||
|
const newPath = path.join(currentPath, newFileName)
|
||||||
|
try {
|
||||||
|
if (fileType === 'directory') {
|
||||||
|
if (!(await FileManager.createDirectory(newPath))) {
|
||||||
|
toast.error('目录已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!(await FileManager.createFile(newPath))) {
|
||||||
|
toast.error('文件已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success('创建成功')
|
||||||
|
setIsCreateModalOpen(false)
|
||||||
|
setNewFileName('')
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || '创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
const selectedArray =
|
||||||
|
selectedFiles instanceof Set
|
||||||
|
? Array.from(selectedFiles)
|
||||||
|
: files.map((f) => f.name)
|
||||||
|
if (selectedArray.length === 0) return
|
||||||
|
dialog.confirm({
|
||||||
|
title: '批量删除',
|
||||||
|
content: <div>确定要删除选中的 {selectedArray.length} 个项目吗?</div>,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
const paths = selectedArray.map((key) =>
|
||||||
|
path.join(currentPath, key.toString())
|
||||||
|
)
|
||||||
|
await FileManager.batchDelete(paths)
|
||||||
|
toast.success('批量删除成功')
|
||||||
|
setSelectedFiles(new Set())
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!renamingFile || !newFileName) return
|
||||||
|
try {
|
||||||
|
await FileManager.rename(
|
||||||
|
path.join(currentPath, renamingFile),
|
||||||
|
path.join(currentPath, newFileName)
|
||||||
|
)
|
||||||
|
toast.success('重命名成功')
|
||||||
|
setIsRenameModalOpen(false)
|
||||||
|
setRenamingFile('')
|
||||||
|
setNewFileName('')
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('重命名失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = async (sourceName: string) => {
|
||||||
|
if (!moveTargetPath) return
|
||||||
|
try {
|
||||||
|
await FileManager.move(
|
||||||
|
path.join(currentPath, sourceName),
|
||||||
|
path.join(moveTargetPath, sourceName)
|
||||||
|
)
|
||||||
|
toast.success('移动成功')
|
||||||
|
setIsMoveModalOpen(false)
|
||||||
|
setMoveTargetPath('')
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('移动失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchMove = async () => {
|
||||||
|
if (!moveTargetPath) return
|
||||||
|
const selectedArray =
|
||||||
|
selectedFiles instanceof Set
|
||||||
|
? Array.from(selectedFiles)
|
||||||
|
: files.map((f) => f.name)
|
||||||
|
if (selectedArray.length === 0) return
|
||||||
|
try {
|
||||||
|
const items = selectedArray.map((name) => ({
|
||||||
|
sourcePath: path.join(currentPath, name.toString()),
|
||||||
|
targetPath: path.join(moveTargetPath, name.toString())
|
||||||
|
}))
|
||||||
|
await FileManager.batchMove(items)
|
||||||
|
toast.success('批量移动成功')
|
||||||
|
setIsMoveModalOpen(false)
|
||||||
|
setMoveTargetPath('')
|
||||||
|
setSelectedFiles(new Set())
|
||||||
|
loadFiles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('批量移动失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyPath = (fileName: string) => {
|
||||||
|
navigator.clipboard.writeText(path.join(currentPath, fileName))
|
||||||
|
toast.success('路径已复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveClick = (fileName: string) => {
|
||||||
|
setRenamingFile(fileName)
|
||||||
|
setMoveTargetPath('')
|
||||||
|
setIsMoveModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (filePath: string) => {
|
||||||
|
FileManager.download(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDownload = async () => {
|
||||||
|
const selectedArray =
|
||||||
|
selectedFiles instanceof Set
|
||||||
|
? Array.from(selectedFiles)
|
||||||
|
: files.map((f) => f.name)
|
||||||
|
if (selectedArray.length === 0) return
|
||||||
|
const paths = selectedArray.map((key) =>
|
||||||
|
path.join(currentPath, key.toString())
|
||||||
|
)
|
||||||
|
await FileManager.batchDownload(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = (filePath: string) => {
|
||||||
|
setPreviewFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = async (acceptedFiles: File[]) => {
|
||||||
|
try {
|
||||||
|
// 遍历处理文件,保持文件夹结构
|
||||||
|
const processedFiles = acceptedFiles.map((file) => {
|
||||||
|
const relativePath = file.webkitRelativePath || file.name
|
||||||
|
// 不需要额外的编码处理,浏览器会自动处理
|
||||||
|
return new File([file], relativePath, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
toast
|
||||||
|
.promise(FileManager.upload(currentPath, processedFiles), {
|
||||||
|
loading: '正在上传文件...',
|
||||||
|
success: '上传成功',
|
||||||
|
error: '上传失败'
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
loadFiles()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
noClick: true,
|
||||||
|
onDragOver: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
},
|
||||||
|
useFsAccessApi: false // 添加此选项以避免某些浏览器的文件系统API问题
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleDirectoryClick('..')}
|
||||||
|
className="text-lg"
|
||||||
|
>
|
||||||
|
<TiArrowBack />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setIsCreateModalOpen(true)}
|
||||||
|
className="text-lg"
|
||||||
|
>
|
||||||
|
<FiPlus />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
isLoading={loading}
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
variant="flat"
|
||||||
|
onPress={loadFiles}
|
||||||
|
className="text-lg"
|
||||||
|
>
|
||||||
|
<MdRefresh />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setShowUpload((prev) => !prev)}
|
||||||
|
className="text-lg"
|
||||||
|
>
|
||||||
|
<FiUpload />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
|
||||||
|
selectedFiles === 'all') && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={handleBatchDelete}
|
||||||
|
className="text-sm"
|
||||||
|
startContent={<TbTrash className="text-lg" />}
|
||||||
|
>
|
||||||
|
(
|
||||||
|
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => {
|
||||||
|
setMoveTargetPath('')
|
||||||
|
setIsMoveModalOpen(true)
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
startContent={<FiMove className="text-lg" />}
|
||||||
|
>
|
||||||
|
(
|
||||||
|
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={handleBatchDownload}
|
||||||
|
className="text-sm"
|
||||||
|
startContent={<FiDownload className="text-lg" />}
|
||||||
|
>
|
||||||
|
(
|
||||||
|
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
|
||||||
|
{currentPath.split('/').map((part, index, parts) => (
|
||||||
|
<BreadcrumbItem
|
||||||
|
key={part}
|
||||||
|
isCurrent={index === parts.length - 1}
|
||||||
|
onPress={() => {
|
||||||
|
const newPath = parts.slice(0, index + 1).join('/')
|
||||||
|
navigate(`/file_manager#${encodeURIComponent(newPath)}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
))}
|
||||||
|
</Breadcrumbs>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="输入跳转路径"
|
||||||
|
value={jumpPath}
|
||||||
|
onChange={(e) => setJumpPath(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && jumpPath.trim() !== '') {
|
||||||
|
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ml-auto w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: showUpload ? 'auto' : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={clsx(
|
||||||
|
'border-dashed rounded-lg text-center',
|
||||||
|
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
|
||||||
|
showUpload ? 'mb-4 border-2' : 'border-none'
|
||||||
|
)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div {...getRootProps()} className="w-full h-full p-4">
|
||||||
|
<input {...getInputProps()} multiple />
|
||||||
|
<p>拖拽文件或文件夹到此处上传,或点击选择文件</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<FileTable
|
||||||
|
files={files}
|
||||||
|
currentPath={currentPath}
|
||||||
|
loading={loading}
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onSelectionChange={setSelectedFiles}
|
||||||
|
onDirectoryClick={handleDirectoryClick}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
onRenameRequest={(name) => {
|
||||||
|
setRenamingFile(name)
|
||||||
|
setNewFileName(name)
|
||||||
|
setIsRenameModalOpen(true)
|
||||||
|
}}
|
||||||
|
onMoveRequest={handleMoveClick}
|
||||||
|
onCopyPath={handleCopyPath}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileEditModal
|
||||||
|
isOpen={!!editingFile}
|
||||||
|
file={editingFile}
|
||||||
|
onClose={() => setEditingFile(null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onContentChange={(newContent) =>
|
||||||
|
setEditingFile((prev) =>
|
||||||
|
prev ? { ...prev, content: newContent ?? '' } : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilePreviewModal
|
||||||
|
isOpen={!!previewFile}
|
||||||
|
filePath={previewFile}
|
||||||
|
onClose={() => setPreviewFile('')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateFileModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
fileType={fileType}
|
||||||
|
newFileName={newFileName}
|
||||||
|
onTypeChange={setFileType}
|
||||||
|
onNameChange={(e) => setNewFileName(e.target.value)}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameModal
|
||||||
|
isOpen={isRenameModalOpen}
|
||||||
|
newFileName={newFileName}
|
||||||
|
onNameChange={(e) => setNewFileName(e.target.value)}
|
||||||
|
onClose={() => setIsRenameModalOpen(false)}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MoveModal
|
||||||
|
isOpen={isMoveModalOpen}
|
||||||
|
moveTargetPath={moveTargetPath}
|
||||||
|
selectionInfo={
|
||||||
|
selectedFiles instanceof Set && selectedFiles.size > 0
|
||||||
|
? `${selectedFiles.size} 个项目`
|
||||||
|
: renamingFile
|
||||||
|
}
|
||||||
|
onClose={() => setIsMoveModalOpen(false)}
|
||||||
|
onMove={() =>
|
||||||
|
selectedFiles instanceof Set && selectedFiles.size > 0
|
||||||
|
? handleBatchMove()
|
||||||
|
: handleMove(renamingFile)
|
||||||
|
}
|
||||||
|
onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
171
napcat.webui/src/pages/dashboard/terminal.tsx
Normal file
171
napcat.webui/src/pages/dashboard/terminal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
horizontalListSortingStrategy
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { IoAdd, IoClose } from 'react-icons/io5'
|
||||||
|
|
||||||
|
import { TabList, TabPanel, Tabs } from '@/components/tabs'
|
||||||
|
import { SortableTab } from '@/components/tabs/sortable_tab.tsx'
|
||||||
|
import { TerminalInstance } from '@/components/terminal/terminal-instance'
|
||||||
|
|
||||||
|
import terminalManager from '@/controllers/terminal_manager'
|
||||||
|
|
||||||
|
interface TerminalTab {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalPage() {
|
||||||
|
const [tabs, setTabs] = useState<TerminalTab[]>([])
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 获取已存在的终端列表
|
||||||
|
terminalManager.getTerminalList().then((terminals) => {
|
||||||
|
if (terminals.length === 0) return
|
||||||
|
|
||||||
|
const newTabs = terminals.map((terminal) => ({
|
||||||
|
id: terminal.id,
|
||||||
|
title: terminal.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTabs(newTabs)
|
||||||
|
setSelectedTab(newTabs[0].id)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createNewTerminal = async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await terminalManager.createTerminal(80, 24)
|
||||||
|
const newTab = {
|
||||||
|
id,
|
||||||
|
title: id
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabs((prev) => [...prev, newTab])
|
||||||
|
setSelectedTab(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create terminal:', error)
|
||||||
|
toast.error('创建终端失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTerminal = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await terminalManager.closeTerminal(id)
|
||||||
|
terminalManager.removeTerminal(id)
|
||||||
|
if (selectedTab === id) {
|
||||||
|
const previousIndex = tabs.findIndex((tab) => tab.id === id) - 1
|
||||||
|
if (previousIndex >= 0) {
|
||||||
|
setSelectedTab(tabs[previousIndex].id)
|
||||||
|
} else {
|
||||||
|
setSelectedTab(tabs[0]?.id || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTabs((prev) => prev.filter((tab) => tab.id !== id))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('关闭终端失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (active.id !== over?.id) {
|
||||||
|
setTabs((items) => {
|
||||||
|
const oldIndex = items.findIndex((item) => item.id === active.id)
|
||||||
|
const newIndex = items.findIndex((item) => item.id === over?.id)
|
||||||
|
return arrayMove(items, oldIndex, newIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={selectedTab}
|
||||||
|
onChange={setSelectedTab}
|
||||||
|
className="h-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 flex-grow-0">
|
||||||
|
<TabList className="flex-1 !overflow-x-auto w-full hide-scrollbar">
|
||||||
|
<SortableContext
|
||||||
|
items={tabs}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<SortableTab
|
||||||
|
key={tab.id}
|
||||||
|
id={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
isSelected={selectedTab === tab.id}
|
||||||
|
className="flex gap-2 items-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
radius="full"
|
||||||
|
variant="flat"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0 w-4 h-4 flex-shrink-0"
|
||||||
|
onPress={() => closeTerminal(tab.id)}
|
||||||
|
color={selectedTab === tab.id ? 'danger' : 'default'}
|
||||||
|
>
|
||||||
|
<IoClose />
|
||||||
|
</Button>
|
||||||
|
</SortableTab>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</TabList>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={createNewTerminal}
|
||||||
|
startContent={<IoAdd />}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-hidden">
|
||||||
|
{tabs.length === 0 && (
|
||||||
|
<div className="flex flex-col gap-2 items-center justify-center h-full text-gray-500 py-5">
|
||||||
|
<IoAdd className="text-4xl" />
|
||||||
|
<div className="text-sm">点击右上角按钮创建终端</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabPanel key={tab.id} value={tab.id} className="h-full">
|
||||||
|
<TerminalInstance id={tab.id} />
|
||||||
|
</TabPanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@@ -1,42 +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 LogsPage from './dashboard/logs'
|
|
||||||
import NetworkPage from './dashboard/network'
|
|
||||||
|
|
||||||
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={<AboutPage />} path="/about" />
|
<Outlet />
|
||||||
</Routes>
|
</motion.div>
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
</Suspense>
|
||||||
</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');
|
|
||||||
}
|
|
||||||
|
@@ -1,28 +1,36 @@
|
|||||||
@import url("./fonts.css");
|
@import url('./fonts.css');
|
||||||
|
@import url('./text.css');
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
font-family:
|
||||||
|
'Aa偷吃可爱长大的',
|
||||||
|
PingFang SC,
|
||||||
|
Helvetica Neue,
|
||||||
|
Microsoft YaHei,
|
||||||
|
sans-serif !important;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-smooth: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.hm-medium {
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
width: 0 !important;
|
||||||
@apply font-bold;
|
height: 0 !important;
|
||||||
}
|
}
|
||||||
.font-ubuntu {
|
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||||
font-family: 'Ubuntu', sans-serif;
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
.font-outfit {
|
.hide-scrollbar::-webkit-scrollbar-track {
|
||||||
font-family: 'Outfit', sans-serif;
|
width: 0 !important;
|
||||||
}
|
height: 0 !important;
|
||||||
.font-libre {
|
background-color: transparent !important;
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
}
|
|
||||||
.font-noto-serif {
|
|
||||||
font-family: 'Noto Serif SC', serif;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +59,8 @@ body {
|
|||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monaco-editor, .monaco-editor-background {
|
.monaco-editor,
|
||||||
|
.monaco-editor-background {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +86,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-view.monaco-menu-container * {
|
.context-view.monaco-menu-container * {
|
||||||
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
font-family:
|
||||||
|
PingFang SC,
|
||||||
|
'Aa偷吃可爱长大的',
|
||||||
|
Helvetica Neue,
|
||||||
|
Microsoft YaHei,
|
||||||
|
sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-hidden {
|
.ql-hidden {
|
||||||
@@ -85,16 +99,4 @@ body {
|
|||||||
}
|
}
|
||||||
.ql-editor img {
|
.ql-editor img {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
}
|
}
|
||||||
/* input.ql-image {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
.ql-image svg {
|
|
||||||
fill: none;
|
|
||||||
}
|
|
||||||
.ql-fill {
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
.ql-stroke {
|
|
||||||
stroke: currentColor;
|
|
||||||
} */
|
|
34
napcat.webui/src/styles/text.css
Normal file
34
napcat.webui/src/styles/text.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@layer base {
|
||||||
|
.shiny-text {
|
||||||
|
@apply text-pink-400 text-opacity-60;
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
animation: shine 5s linear infinite;
|
||||||
|
}
|
||||||
|
.shiny-text {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgba(255, 50, 50, 0) 40%,
|
||||||
|
rgba(255, 76, 76, 0.8) 50%,
|
||||||
|
rgba(255, 50, 50, 0) 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.dark .shiny-text {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgba(255, 255, 255, 0) 40%,
|
||||||
|
rgba(206, 21, 21, 0.8) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
background-position: 100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -195,7 +195,7 @@ export interface OneBot11GroupUpload extends NoticeBase {
|
|||||||
name: string
|
name: string
|
||||||
/** 文件大小(字节数) */
|
/** 文件大小(字节数) */
|
||||||
size: number
|
size: number
|
||||||
/** busid(目前不清楚有什么作用) */
|
/** busid 无作用 */
|
||||||
busid: number
|
busid: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
napcat.webui/src/types/user.d.ts
vendored
2
napcat.webui/src/types/user.d.ts
vendored
@@ -164,7 +164,7 @@ interface CommonExt {
|
|||||||
address: string
|
address: string
|
||||||
regTime: number
|
regTime: number
|
||||||
interest: string
|
interest: string
|
||||||
labels: unknown[]
|
labels: string[]
|
||||||
qqLevel: QQLevel
|
qqLevel: QQLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -45,6 +45,10 @@ serverRequest.interceptors.request.use((config) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
serverRequest.interceptors.response.use((response) => {
|
serverRequest.interceptors.response.use((response) => {
|
||||||
|
// 如果是流式传输的文件
|
||||||
|
if (response.headers['content-type'] === 'application/octet-stream') {
|
||||||
|
return response
|
||||||
|
}
|
||||||
if (response.data.code !== 0) {
|
if (response.data.code !== 0) {
|
||||||
if (response.data.message === 'Unauthorized') {
|
if (response.data.message === 'Unauthorized') {
|
||||||
const token = localStorage.getItem(key.token)
|
const token = localStorage.getItem(key.token)
|
||||||
|
@@ -29,6 +29,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
base: '/webui/',
|
base: '/webui/',
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/api/ws/terminal': {
|
||||||
|
target: backendDebugUrl,
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
'/api': backendDebugUrl
|
'/api': backendDebugUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
18
package.json
18
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.4.12",
|
"version": "4.5.5",
|
||||||
"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",
|
||||||
@@ -17,19 +17,20 @@
|
|||||||
"dev:depend": "npm i && cd napcat.webui && npm i"
|
"dev:depend": "npm i && cd napcat.webui && npm i"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"json5": "^2.2.3",
|
|
||||||
"esbuild": "0.24.0",
|
|
||||||
"@babel/preset-typescript": "^7.24.7",
|
"@babel/preset-typescript": "^7.24.7",
|
||||||
"@eslint/compat": "^1.2.2",
|
"@eslint/compat": "^1.2.2",
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
"@eslint/js": "^9.14.0",
|
"@eslint/js": "^9.14.0",
|
||||||
|
"@ffmpeg.wasm/main": "^0.13.1",
|
||||||
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
|
||||||
"@log4js-node/log4js-api": "^1.0.2",
|
"@log4js-node/log4js-api": "^1.0.2",
|
||||||
"@napneko/nap-proto-core": "^0.0.4",
|
"@napneko/nap-proto-core": "^0.0.4",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
|
||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@types/cors": "^2.8.17",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@sinclair/typebox": "^0.34.9",
|
"@sinclair/typebox": "^0.34.9",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.0.1",
|
"@types/node": "^22.0.1",
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
@@ -39,13 +40,17 @@
|
|||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"esbuild": "0.24.0",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
"fast-xml-parser": "^4.3.6",
|
"fast-xml-parser": "^4.3.6",
|
||||||
"file-type": "^20.0.0",
|
"file-type": "^20.0.0",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"image-size": "^1.1.1",
|
"image-size": "^1.1.1",
|
||||||
|
"json5": "^2.2.3",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.13.0",
|
"typescript-eslint": "^8.13.0",
|
||||||
"vite": "^6.0.1",
|
"vite": "^6.0.1",
|
||||||
@@ -55,10 +60,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
||||||
"@ffmpeg.wasm/main": "^0.13.1",
|
"compressing": "^1.10.1",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"piscina": "^4.7.0",
|
"piscina": "^4.7.0",
|
||||||
"qrcode-terminal": "^0.12.0",
|
|
||||||
"silk-wasm": "^3.6.1",
|
"silk-wasm": "^3.6.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { encode } from "silk-wasm";
|
import { encode } from 'silk-wasm';
|
||||||
|
|
||||||
export interface EncodeArgs {
|
export interface EncodeArgs {
|
||||||
input: ArrayBufferView | ArrayBuffer
|
input: ArrayBufferView | ArrayBuffer
|
||||||
|
@@ -4,8 +4,8 @@ import path from 'node:path';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
import { EncodeArgs } from "@/common/audio-worker";
|
import { EncodeArgs } from '@/common/audio-worker';
|
||||||
import { FFmpegService } from "@/common/ffmpeg";
|
import { FFmpegService } from '@/common/ffmpeg';
|
||||||
|
|
||||||
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
|||||||
|
|
||||||
async function guessDuration(pttPath: string, logger: LogWrapper) {
|
async function guessDuration(pttPath: string, logger: LogWrapper) {
|
||||||
const pttFileInfo = await fsPromise.stat(pttPath);
|
const pttFileInfo = await fsPromise.stat(pttPath);
|
||||||
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
||||||
logger.log('通过文件大小估算语音的时长:', duration);
|
logger.log('通过文件大小估算语音的时长:', duration);
|
||||||
return duration;
|
return duration;
|
||||||
}
|
}
|
||||||
@@ -27,12 +27,11 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
|
|||||||
async function handleWavFile(
|
async function handleWavFile(
|
||||||
file: Buffer,
|
file: Buffer,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
pcmPath: string,
|
pcmPath: string
|
||||||
logger: LogWrapper
|
|
||||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||||
const { fmt } = getWavFileInfo(file);
|
const { fmt } = getWavFileInfo(file);
|
||||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||||
return { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
return { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
||||||
}
|
}
|
||||||
return { input: file, sampleRate: fmt.sampleRate };
|
return { input: file, sampleRate: fmt.sampleRate };
|
||||||
}
|
}
|
||||||
@@ -45,9 +44,10 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
|||||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||||
const pcmPath = `${pttPath}.pcm`;
|
const pcmPath = `${pttPath}.pcm`;
|
||||||
const { input, sampleRate } = isWav(file)
|
const { input, sampleRate } = isWav(file)
|
||||||
? (await handleWavFile(file, filePath, pcmPath, logger))
|
? await handleWavFile(file, filePath, pcmPath)
|
||||||
: { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
||||||
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
|
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
|
||||||
|
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||||
return {
|
return {
|
||||||
@@ -59,8 +59,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
|||||||
let duration = 0;
|
let duration = 0;
|
||||||
try {
|
try {
|
||||||
duration = getDuration(file) / 1000;
|
duration = getDuration(file) / 1000;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, e.stack);
|
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||||
duration = await guessDuration(filePath, logger);
|
duration = await guessDuration(filePath, logger);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -69,8 +69,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
|||||||
duration,
|
duration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
logger.logError('convert silk failed', error.stack);
|
logger.logError('convert silk failed', (error as Error).stack);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
|
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
|
||||||
|
|
||||||
export class CancelableTask<T> {
|
export class CancelableTask<T> {
|
||||||
@@ -7,30 +8,34 @@ export class CancelableTask<T> {
|
|||||||
private cancelListeners: Array<() => void> = [];
|
private cancelListeners: Array<() => void> = [];
|
||||||
|
|
||||||
constructor(executor: TaskExecutor<T>) {
|
constructor(executor: TaskExecutor<T>) {
|
||||||
this.promise = new Promise<T>(async (resolve, reject) => {
|
this.promise = new Promise<T>((resolve, reject) => {
|
||||||
const onCancel = (callback: () => void) => {
|
const onCancel = (callback: () => void) => {
|
||||||
this.cancelCallback = callback;
|
this.cancelCallback = callback;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const execute = async () => {
|
||||||
await executor(
|
try {
|
||||||
(value) => {
|
await executor(
|
||||||
if (!this.isCanceled) {
|
(value) => {
|
||||||
resolve(value);
|
if (!this.isCanceled) {
|
||||||
}
|
resolve(value);
|
||||||
},
|
}
|
||||||
(reason) => {
|
},
|
||||||
if (!this.isCanceled) {
|
(reason) => {
|
||||||
reject(reason);
|
if (!this.isCanceled) {
|
||||||
}
|
reject(reason);
|
||||||
},
|
}
|
||||||
onCancel
|
},
|
||||||
);
|
onCancel
|
||||||
} catch (error) {
|
);
|
||||||
if (!this.isCanceled) {
|
} catch (error) {
|
||||||
reject(error);
|
if (!this.isCanceled) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,41 +77,4 @@ export class CancelableTask<T> {
|
|||||||
next: () => this.promise.then(value => ({ value, done: true })),
|
next: () => this.promise.then(value => ({ value, done: true })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function demoAwait() {
|
|
||||||
const executor: TaskExecutor<number> = async (resolve, reject, onCancel) => {
|
|
||||||
let count = 0;
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
count++;
|
|
||||||
console.log(`Task is running... Count: ${count}`);
|
|
||||||
if (count === 5) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
resolve(count);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
onCancel(() => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
console.log('Task has been canceled.');
|
|
||||||
reject(new Error('Task was canceled'));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const task = new CancelableTask(executor);
|
|
||||||
|
|
||||||
task.onCancel(() => {
|
|
||||||
console.log('Cancel listener triggered.');
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
task.cancel(); // 取消任务
|
|
||||||
}, 6000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await task;
|
|
||||||
console.log(`Task completed with result: ${result}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Task failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
@@ -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: any) {
|
|
||||||
this.core.context.logger.logError(`[Core] [Config] 创建配置文件时发生错误:`, e.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: any) {
|
} 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.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: any) {
|
} catch (e: unknown) {
|
||||||
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, e.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,22 +0,0 @@
|
|||||||
// decoratorAsyncMethod(this,function,wrapper)
|
|
||||||
async function decoratorMethod<T, R>(
|
|
||||||
target: T,
|
|
||||||
method: () => Promise<R>,
|
|
||||||
wrapper: (result: R) => Promise<any>,
|
|
||||||
executeImmediately: boolean = true
|
|
||||||
): Promise<any> {
|
|
||||||
const execute = async () => {
|
|
||||||
try {
|
|
||||||
const result = await method.call(target);
|
|
||||||
return wrapper(result);
|
|
||||||
} catch (error) {
|
|
||||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (executeImmediately) {
|
|
||||||
return execute();
|
|
||||||
} else {
|
|
||||||
return execute;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { NodeIQQNTWrapperSession } from '@/core/wrapper';
|
import { NodeIQQNTWrapperSession } from '@/core/wrapper';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/core';
|
import { ListenerNamingMapping, ServiceNamingMapping } from '@/core';
|
||||||
@@ -60,17 +61,22 @@ export class NTEventWrapper {
|
|||||||
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>> };
|
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>> };
|
||||||
};
|
};
|
||||||
if (eventNameArr.length > 1) {
|
if (eventNameArr.length > 1) {
|
||||||
const serviceName = 'get' + eventNameArr[0].replace('NodeIKernel', '');
|
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||||
const eventName = eventNameArr[1];
|
const eventName = eventNameArr[1];
|
||||||
const services = (this.WrapperSession as unknown as eventType)[serviceName]();
|
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
|
||||||
|
if (!services || !eventName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let event = services[eventName];
|
let event = services[eventName];
|
||||||
|
|
||||||
//重新绑定this
|
//重新绑定this
|
||||||
event = event.bind(services);
|
event = event?.bind(services);
|
||||||
if (event) {
|
if (event) {
|
||||||
return event as T;
|
return event as T;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
createListenerFunction<T>(listenerMainName: string, uniqueCode: string = ''): T {
|
createListenerFunction<T>(listenerMainName: string, uniqueCode: string = ''): T {
|
||||||
@@ -126,8 +132,8 @@ export class NTEventWrapper {
|
|||||||
) {
|
) {
|
||||||
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||||
const ListenerNameList = listenerAndMethod.split('/');
|
const ListenerNameList = listenerAndMethod.split('/');
|
||||||
const ListenerMainName = ListenerNameList[0];
|
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||||
const ListenerSubName = ListenerNameList[1];
|
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
let complete = 0;
|
let complete = 0;
|
||||||
let retData: Parameters<ListenerType> | undefined = undefined;
|
let retData: Parameters<ListenerType> | undefined = undefined;
|
||||||
@@ -205,8 +211,8 @@ export class NTEventWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListenerNameList = listenerAndMethod.split('/');
|
const ListenerNameList = listenerAndMethod.split('/');
|
||||||
const ListenerMainName = ListenerNameList[0];
|
const ListenerMainName = ListenerNameList[0]??'';
|
||||||
const ListenerSubName = ListenerNameList[1];
|
const ListenerSubName = ListenerNameList[1]??'';
|
||||||
|
|
||||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
|
@@ -1,18 +1,18 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { FFmpeg } from '@ffmpeg.wasm/main';
|
import { FFmpeg } from '@ffmpeg.wasm/main';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { readFileSync, statSync, writeFileSync } from 'fs';
|
import { readFileSync, statSync, writeFileSync } from 'fs';
|
||||||
import type { LogWrapper } from './log';
|
|
||||||
import type { VideoInfo } from './video';
|
import type { VideoInfo } from './video';
|
||||||
import { fileTypeFromFile } from 'file-type';
|
import { fileTypeFromFile } from 'file-type';
|
||||||
import imageSize from 'image-size';
|
import imageSize from 'image-size';
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
const videoFileName = `${randomUUID()}.mp4`;
|
const videoFileName = `${randomUUID()}.mp4`;
|
||||||
const outputFileName = `${randomUUID()}.jpg`;
|
const outputFileName = `${randomUUID()}.jpg`;
|
||||||
try {
|
try {
|
||||||
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
||||||
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ import imageSize from 'image-size';
|
|||||||
const params = format === 'amr'
|
const params = format === 'amr'
|
||||||
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
||||||
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
||||||
let code = await ffmpegInstance.run(...params);
|
const code = await ffmpegInstance.run(...params);
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||||
}
|
}
|
||||||
@@ -67,14 +67,14 @@ import imageSize from 'image-size';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
const inputFileName = `${randomUUID()}.input`;
|
const inputFileName = `${randomUUID()}.input`;
|
||||||
const outputFileName = `${randomUUID()}.pcm`;
|
const outputFileName = `${randomUUID()}.pcm`;
|
||||||
try {
|
try {
|
||||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
||||||
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
||||||
let code = await ffmpegInstance.run(...params);
|
const code = await ffmpegInstance.run(...params);
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error('FFmpeg process exited with code ' + code);
|
throw new Error('FFmpeg process exited with code ' + code);
|
||||||
}
|
}
|
||||||
@@ -87,36 +87,36 @@ import imageSize from 'image-size';
|
|||||||
try {
|
try {
|
||||||
ffmpegInstance.fs.unlink(outputFileName);
|
ffmpegInstance.fs.unlink(outputFileName);
|
||||||
} catch (unlinkError) {
|
} catch (unlinkError) {
|
||||||
logger.log('Error unlinking output file:', unlinkError);
|
console.error('Error unlinking output file:', unlinkError);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ffmpegInstance.fs.unlink(inputFileName);
|
ffmpegInstance.fs.unlink(inputFileName);
|
||||||
} catch (unlinkError) {
|
} catch (unlinkError) {
|
||||||
logger.log('Error unlinking input file:', unlinkError);
|
console.error('Error unlinking output file:', unlinkError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||||
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
||||||
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
||||||
const inputFileName = `${randomUUID()}.${fileType}`;
|
const inputFileName = `${randomUUID()}.${fileType}`;
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
||||||
ffmpegInstance.setLogging(true);
|
ffmpegInstance.setLogging(true);
|
||||||
let duration = 60;
|
let duration = 60;
|
||||||
ffmpegInstance.setLogger((level, ...msg) => {
|
ffmpegInstance.setLogger((_level, ...msg) => {
|
||||||
const message = msg.join(' ');
|
const message = msg.join(' ');
|
||||||
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
||||||
if (durationMatch) {
|
if (durationMatch) {
|
||||||
const hours = parseInt(durationMatch[1], 10);
|
const hours = parseInt(durationMatch[1] ?? '0', 10);
|
||||||
const minutes = parseInt(durationMatch[2], 10);
|
const minutes = parseInt(durationMatch[2] ?? '0', 10);
|
||||||
const seconds = parseFloat(durationMatch[3]);
|
const seconds = parseFloat(durationMatch[3] ?? '0');
|
||||||
duration = hours * 3600 + minutes * 60 + seconds;
|
duration = hours * 3600 + minutes * 60 + seconds;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await ffmpegInstance.run('-i', inputFileName);
|
await ffmpegInstance.run('-i', inputFileName);
|
||||||
let image = imageSize(thumbnailPath);
|
const image = imageSize(thumbnailPath);
|
||||||
ffmpegInstance.fs.unlink(inputFileName);
|
ffmpegInstance.fs.unlink(inputFileName);
|
||||||
const fileSize = statSync(videoPath).size;
|
const fileSize = statSync(videoPath).size;
|
||||||
return {
|
return {
|
||||||
@@ -126,7 +126,7 @@ import imageSize from 'image-size';
|
|||||||
format: fileType,
|
format: fileType,
|
||||||
size: fileSize,
|
size: fileSize,
|
||||||
filePath: videoPath
|
filePath: videoPath
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||||
@@ -137,15 +137,15 @@ interface FFmpegTask {
|
|||||||
}
|
}
|
||||||
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'extractThumbnail':
|
case 'extractThumbnail':
|
||||||
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||||
case 'convertFile':
|
case 'convertFile':
|
||||||
return await FFmpegService.convertFile(...args as [string, string, string]);
|
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||||
case 'convert':
|
case 'convert':
|
||||||
return await FFmpegService.convert(...args as [string, string, LogWrapper]);
|
return await FFmpegService.convert(...args as [string, string]);
|
||||||
case 'getVideoInfo':
|
case 'getVideoInfo':
|
||||||
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${method}`);
|
throw new Error(`Unknown method: ${method}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
import Piscina from "piscina";
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { VideoInfo } from "./video";
|
import Piscina from 'piscina';
|
||||||
import type { LogWrapper } from "./log";
|
import { VideoInfo } from './video';
|
||||||
|
|
||||||
type EncodeArgs = {
|
type EncodeArgs = {
|
||||||
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||||
@@ -30,11 +30,11 @@ export class FFmpegService {
|
|||||||
await piscina.destroy();
|
await piscina.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||||
filename: await getWorkerPath(),
|
filename: await getWorkerPath(),
|
||||||
});
|
});
|
||||||
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath, logger] });
|
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath] });
|
||||||
await piscina.destroy();
|
await piscina.destroy();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -47,4 +47,4 @@ export class FFmpegService {
|
|||||||
await piscina.destroy();
|
await piscina.destroy();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -83,7 +83,7 @@ class FileUUIDManager {
|
|||||||
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public encode(data: FileUUIDData, endString: string = "", customUUID?: string): string {
|
public encode(data: FileUUIDData, endString: string = '', customUUID?: string): string {
|
||||||
const uuid = customUUID ? customUUID : randomUUID().replace(/-/g, '') + endString;
|
const uuid = customUUID ? customUUID : randomUUID().replace(/-/g, '') + endString;
|
||||||
this.cache.put(uuid, data);
|
this.cache.put(uuid, data);
|
||||||
return uuid;
|
return uuid;
|
||||||
@@ -101,7 +101,7 @@ export class FileNapCatOneBotUUIDWrap {
|
|||||||
this.manager = new FileUUIDManager(ttl);
|
this.manager = new FileUUIDManager(ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public encodeModelId(peer: Peer, modelId: string, fileId: string, fileUUID: string = "", endString: string = "", customUUID?: string): string {
|
public encodeModelId(peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
|
||||||
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
|
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@ export class FileNapCatOneBotUUIDWrap {
|
|||||||
return this.manager.decode(uuid);
|
return this.manager.decode(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = "", customUUID?: string): string {
|
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
|
||||||
return this.manager.encode({ peer, msgId, elementId, fileUUID }, "", customUUID);
|
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
public decode(uuid: string): FileUUIDData | undefined {
|
public decode(uuid: string): FileUUIDData | undefined {
|
||||||
|
@@ -58,8 +58,8 @@ function timeoutPromise(timeout: number, errorMsg: string): Promise<void> {
|
|||||||
async function checkFile(path: string): Promise<void> {
|
async function checkFile(path: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await stat(path);
|
await stat(path);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.code === 'ENOENT') {
|
if ((error as Error & { code: string }).code === 'ENOENT') {
|
||||||
// 如果文件不存在,则抛出一个错误
|
// 如果文件不存在,则抛出一个错误
|
||||||
throw new Error(`文件不存在: ${path}`);
|
throw new Error(`文件不存在: ${path}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -169,6 +169,7 @@ export async function checkUriType(Uri: string) {
|
|||||||
const data = uri.split(',')[1];
|
const data = uri.split(',')[1];
|
||||||
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}, Uri);
|
}, Uri);
|
||||||
if (OtherFileRet) return OtherFileRet;
|
if (OtherFileRet) return OtherFileRet;
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
case FileUriType.Remote: {
|
case FileUriType.Remote: {
|
||||||
const buffer = await httpDownload({ url: HandledUri, headers: headers });
|
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||||
fs.writeFileSync(filePath, buffer);
|
fs.writeFileSync(filePath, buffer);
|
||||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import * as crypto from "node:crypto";
|
import * as crypto from 'node:crypto';
|
||||||
import { PacketMsg } from "@/core/packet/message/message";
|
import { PacketMsg } from '@/core/packet/message/message';
|
||||||
|
|
||||||
interface ForwardMsgJson {
|
interface ForwardMsgJson {
|
||||||
app: string
|
app: string
|
||||||
@@ -50,15 +50,15 @@ interface ForwardAdaptMsgElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ForwardMsgBuilder {
|
export class ForwardMsgBuilder {
|
||||||
private static build(resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail["news"], summary?: string, prompt?: string): ForwardMsgJson {
|
private static build(resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
source = isGroupMsg ? "群聊的聊天记录" : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录';
|
source = isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录';
|
||||||
}
|
}
|
||||||
if (!news) {
|
if (!news) {
|
||||||
news = msg.length === 0 ? [{
|
news = msg.length === 0 ? [{
|
||||||
text: "Nya~ This message is send from NapCat.Packet!",
|
text: 'Nya~ This message is send from NapCat.Packet!',
|
||||||
}] : msg.map(m => ({
|
}] : msg.map(m => ({
|
||||||
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
||||||
}));
|
}));
|
||||||
@@ -67,15 +67,15 @@ export class ForwardMsgBuilder {
|
|||||||
summary = `查看${msg.length}条转发消息`;
|
summary = `查看${msg.length}条转发消息`;
|
||||||
}
|
}
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
prompt = "[聊天记录]";
|
prompt = '[聊天记录]';
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
app: "com.tencent.multimsg",
|
app: 'com.tencent.multimsg',
|
||||||
config: {
|
config: {
|
||||||
autosize: 1,
|
autosize: 1,
|
||||||
forward: 1,
|
forward: 1,
|
||||||
round: 1,
|
round: 1,
|
||||||
type: "normal",
|
type: 'normal',
|
||||||
width: 300
|
width: 300
|
||||||
},
|
},
|
||||||
desc: prompt,
|
desc: prompt,
|
||||||
@@ -93,8 +93,8 @@ export class ForwardMsgBuilder {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
prompt,
|
prompt,
|
||||||
ver: "0.0.0.5",
|
ver: '0.0.0.5',
|
||||||
view: "contact",
|
view: 'contact',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +102,12 @@ export class ForwardMsgBuilder {
|
|||||||
return this.build(resId, []);
|
return this.build(resId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPacketMsg(resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail["news"], summary?: string, prompt?: string): ForwardMsgJson {
|
static fromPacketMsg(resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||||
return this.build(resId, packetMsg.map(msg => ({
|
return this.build(resId, packetMsg.map(msg => ({
|
||||||
senderName: msg.senderName,
|
senderName: msg.senderName,
|
||||||
isGroupMsg: msg.groupId !== undefined,
|
isGroupMsg: msg.groupId !== undefined,
|
||||||
msg: msg.msg.map(m => ({
|
msg: msg.msg.map(m => ({
|
||||||
preview: m.valid ? m.toPreview() : "[该消息类型暂不支持查看]",
|
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||||
}))
|
}))
|
||||||
})), source, news, summary, prompt);
|
})), source, news, summary, prompt);
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,16 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { QQLevel } from '@/core';
|
import { QQLevel } from '@/core';
|
||||||
|
import { QQVersionConfigType } from './types';
|
||||||
|
|
||||||
export async function solveProblem<T extends (...arg: any[]) => any>(func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
export async function solveProblem<T extends (...arg: any[]) => any>(func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
||||||
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||||
try {
|
try {
|
||||||
const result = func(...args);
|
const result = func(...args);
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} catch (e) {
|
} catch {
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -166,14 +168,14 @@ export function calcQQLevel(level?: QQLevel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyWithBigInt(obj: any) {
|
export function stringifyWithBigInt(obj: any) {
|
||||||
return JSON.stringify(obj, (key, value) =>
|
return JSON.stringify(obj, (_key, value) =>
|
||||||
typeof value === 'bigint' ? value.toString() : value
|
typeof value === 'bigint' ? value.toString() : value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
||||||
const hexSequence = "A4 09 00 00 00 35";
|
const hexSequence = 'A4 09 00 00 00 35';
|
||||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ""), "hex");
|
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||||
const filePath = path.resolve(nodeMajor);
|
const filePath = path.resolve(nodeMajor);
|
||||||
const fileContent = fs.readFileSync(filePath);
|
const fileContent = fs.readFileSync(filePath);
|
||||||
|
|
||||||
@@ -192,8 +194,8 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
|||||||
const content = fileContent.subarray(start, end);
|
const content = fileContent.subarray(start, end);
|
||||||
if (!content.every(byte => byte === 0x00)) {
|
if (!content.every(byte => byte === 0x00)) {
|
||||||
try {
|
try {
|
||||||
return content.toString("utf-8");
|
return content.toString('utf-8');
|
||||||
} catch (error) {
|
} catch {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import winston, { format, transports } from 'winston';
|
import winston, { format, transports } from 'winston';
|
||||||
import { truncateString } from '@/common/helper';
|
import { truncateString } from '@/common/helper';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -34,7 +35,7 @@ class Subscription {
|
|||||||
for (const history of Subscription.history) {
|
for (const history of Subscription.history) {
|
||||||
try {
|
try {
|
||||||
listener(history);
|
listener(history);
|
||||||
} catch (_) {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export class LogWrapper {
|
|||||||
format: format.combine(
|
format: format.combine(
|
||||||
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
||||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
|
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@@ -83,7 +84,7 @@ export class LogWrapper {
|
|||||||
format: format.combine(
|
format: format.combine(
|
||||||
format.colorize(),
|
format.colorize(),
|
||||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
|
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@@ -303,7 +304,7 @@ function textElementToText(textElement: any): string {
|
|||||||
const originalContentLines = textElement.content.split('\n');
|
const originalContentLines = textElement.content.split('\n');
|
||||||
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
||||||
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||||
return `@全体成员`;
|
return '@全体成员';
|
||||||
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
||||||
return `${textElement.content} (${textElement.atUid})`;
|
return `${textElement.content} (${textElement.atUid})`;
|
||||||
}
|
}
|
||||||
|
@@ -68,7 +68,10 @@ export class LimitedHashTable<K, V> {
|
|||||||
const listSize = Math.min(size, keyList.length);
|
const listSize = Math.min(size, keyList.length);
|
||||||
for (let i = 0; i < listSize; i++) {
|
for (let i = 0; i < listSize; i++) {
|
||||||
const key = keyList[listSize - i];
|
const key = keyList[listSize - i];
|
||||||
result.push({ key, value: this.keyToValue.get(key)! });
|
if (key !== undefined) {
|
||||||
|
result.push({ key, value: this.keyToValue.get(key)! });
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -96,8 +99,10 @@ class MessageUniqueWrapper {
|
|||||||
createUniqueMsgId(peer: Peer, msgId: string) {
|
createUniqueMsgId(peer: Peer, msgId: string) {
|
||||||
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
||||||
const hash = crypto.createHash('md5').update(key).digest();
|
const hash = crypto.createHash('md5').update(key).digest();
|
||||||
//设置第一个bit为0 保证shortId为正数
|
if (hash[0]) {
|
||||||
hash[0] &= 0x7f;
|
//设置第一个bit为0 保证shortId为正数
|
||||||
|
hash[0] &= 0x7f;
|
||||||
|
}
|
||||||
const shortId = hash.readInt32BE(0);
|
const shortId = hash.readInt32BE(0);
|
||||||
//减少性能损耗
|
//减少性能损耗
|
||||||
this.msgIdMap.set(msgId, shortId);
|
this.msgIdMap.set(msgId, shortId);
|
||||||
@@ -110,11 +115,11 @@ class MessageUniqueWrapper {
|
|||||||
if (data) {
|
if (data) {
|
||||||
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||||
const peer: Peer = {
|
const peer: Peer = {
|
||||||
chatType: parseInt(chatTypeStr),
|
chatType: parseInt(chatTypeStr ?? '0'),
|
||||||
peerUid,
|
peerUid: peerUid ?? '',
|
||||||
guildId: '',
|
guildId: '',
|
||||||
};
|
};
|
||||||
return { MsgId: msgId, Peer: peer };
|
return { MsgId: msgId ?? '0', Peer: peer };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
|
|
||||||
export function proxyHandlerOf(logger: LogWrapper) {
|
export function proxyHandlerOf(logger: LogWrapper) {
|
||||||
@@ -5,6 +6,7 @@ export function proxyHandlerOf(logger: LogWrapper) {
|
|||||||
get(target: any, prop: any, receiver: any) {
|
get(target: any, prop: any, receiver: any) {
|
||||||
if (typeof target[prop] === 'undefined') {
|
if (typeof target[prop] === 'undefined') {
|
||||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
return (..._args: unknown[]) => {
|
return (..._args: unknown[]) => {
|
||||||
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
||||||
};
|
};
|
||||||
|
@@ -4,6 +4,7 @@ import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfig
|
|||||||
import AppidTable from '@/core/external/appid.json';
|
import AppidTable from '@/core/external/appid.json';
|
||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
import { getMajorPath } from '@/core';
|
import { getMajorPath } from '@/core';
|
||||||
|
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
|
||||||
|
|
||||||
export class QQBasicInfoWrapper {
|
export class QQBasicInfoWrapper {
|
||||||
QQMainPath: string | undefined;
|
QQMainPath: string | undefined;
|
||||||
@@ -86,14 +87,14 @@ export class QQBasicInfoWrapper {
|
|||||||
try {
|
try {
|
||||||
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
||||||
if (majorAppid) {
|
if (majorAppid) {
|
||||||
this.context.logger.log(`[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat`);
|
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
|
||||||
return { appid: majorAppid, qua: this.getQUAFallback() };
|
return { appid: majorAppid, qua: this.getQUAFallback() };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
this.context.logger.log(`[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常`);
|
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||||
}
|
}
|
||||||
// 最终兜底为老版本
|
// 最终兜底为老版本
|
||||||
this.context.logger.log(`[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常`);
|
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||||
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`,);
|
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`,);
|
||||||
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
@@ -41,11 +42,13 @@ export class RequestUtil {
|
|||||||
|
|
||||||
private static extractCookies(setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
private static extractCookies(setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||||
setCookieHeaders.forEach((cookie) => {
|
setCookieHeaders.forEach((cookie) => {
|
||||||
const parts = cookie.split(';')[0].split('=');
|
const parts = cookie.split(';')[0]?.split('=');
|
||||||
const key = parts[0];
|
if (parts) {
|
||||||
const value = parts[1];
|
const key = parts[0];
|
||||||
if (key && value && key.length > 0 && value.length > 0) {
|
const value = parts[1];
|
||||||
cookies[key] = value;
|
if (key && value && key.length > 0 && value.length > 0) {
|
||||||
|
cookies[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
190
src/common/store.ts
Normal file
190
src/common/store.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
export type StoreValueType = string | number | boolean | object | null;
|
||||||
|
|
||||||
|
export type StoreValue<T extends StoreValueType = StoreValueType> = {
|
||||||
|
value: T;
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
// 使用Map存储键值对
|
||||||
|
private store: Map<string, StoreValue>;
|
||||||
|
// 定时清理器
|
||||||
|
private cleanerTimer: NodeJS.Timeout;
|
||||||
|
// 用于分批次扫描的游标
|
||||||
|
private scanCursor: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store
|
||||||
|
* @param cleanInterval 清理间隔
|
||||||
|
* @param scanLimit 扫描限制(每次最多检查的键数)
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
cleanInterval: number = 1000, // 默认1秒执行一次
|
||||||
|
private scanLimit: number = 100 // 每次最多检查100个键
|
||||||
|
) {
|
||||||
|
this.store = new Map();
|
||||||
|
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置键值对
|
||||||
|
* @param key 键
|
||||||
|
* @param value 值
|
||||||
|
* @param ttl 过期时间
|
||||||
|
* @returns void
|
||||||
|
* @example store.set('key', 'value', 60)
|
||||||
|
*/
|
||||||
|
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
|
||||||
|
if (ttl && ttl <= 0) {
|
||||||
|
this.del(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||||
|
this.store.set(key, { value, expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期键
|
||||||
|
*/
|
||||||
|
private cleanupExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const keys = Array.from(this.store.keys());
|
||||||
|
let scanned = 0;
|
||||||
|
|
||||||
|
// 分批次扫描
|
||||||
|
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
|
||||||
|
const key = keys[this.scanCursor++];
|
||||||
|
const entry = this.store.get(key!)!;
|
||||||
|
|
||||||
|
if (entry.expiresAt && entry.expiresAt < now) {
|
||||||
|
this.store.delete(key!);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置游标(环形扫描)
|
||||||
|
if (this.scanCursor >= keys.length) {
|
||||||
|
this.scanCursor = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取键值
|
||||||
|
* @param key 键
|
||||||
|
* @returns T | null
|
||||||
|
* @example store.get('key')
|
||||||
|
*/
|
||||||
|
get<T extends StoreValueType>(key: string): T | null {
|
||||||
|
this.checkKeyExpiry(key); // 每次访问都检查
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
return entry ? (entry.value as T) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否过期
|
||||||
|
* @param key 键
|
||||||
|
*/
|
||||||
|
private checkKeyExpiry(key: string): void {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否存在
|
||||||
|
* @param keys 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.exists('key1', 'key2')
|
||||||
|
*/
|
||||||
|
exists(...keys: string[]): number {
|
||||||
|
return keys.filter((key) => {
|
||||||
|
this.checkKeyExpiry(key);
|
||||||
|
return this.store.has(key);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭存储器
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
clearInterval(this.cleanerTimer);
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除键
|
||||||
|
* @param keys 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.del('key1', 'key2')
|
||||||
|
*/
|
||||||
|
del(...keys: string[]): number {
|
||||||
|
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置键的过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @param seconds 过期时间(秒)
|
||||||
|
* @returns boolean
|
||||||
|
* @example store.expire('key', 60)
|
||||||
|
*/
|
||||||
|
expire(key: string, seconds: number): boolean {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
entry.expiresAt = Date.now() + seconds * 1000;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取键的过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @returns number | null
|
||||||
|
* @example store.ttl('key')
|
||||||
|
*/
|
||||||
|
ttl(key: string): number | null {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (!entry.expiresAt) return -1;
|
||||||
|
const remaining = entry.expiresAt - Date.now();
|
||||||
|
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键值数字递增
|
||||||
|
* @param key 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.incr('key')
|
||||||
|
*/
|
||||||
|
incr(key: string): number {
|
||||||
|
const current = this.get<StoreValueType>(key);
|
||||||
|
|
||||||
|
if (current === null) {
|
||||||
|
this.set(key, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let numericValue: number;
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
numericValue = current;
|
||||||
|
} else if (typeof current === 'string') {
|
||||||
|
if (!/^-?\d+$/.test(current)) {
|
||||||
|
throw new Error('ERR value is not an integer');
|
||||||
|
}
|
||||||
|
numericValue = parseInt(current, 10);
|
||||||
|
} else {
|
||||||
|
throw new Error('ERR value is not an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = numericValue + 1;
|
||||||
|
this.set(key, newValue);
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store();
|
||||||
|
|
||||||
|
export default store;
|
@@ -6,7 +6,7 @@ let osName: string;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
osName = os.hostname();
|
osName = os.hostname();
|
||||||
} catch (e) {
|
} catch {
|
||||||
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,17 +1,17 @@
|
|||||||
//QQVersionType
|
//QQVersionType
|
||||||
type QQPackageInfoType = {
|
export type QQPackageInfoType = {
|
||||||
version: string;
|
version: string;
|
||||||
buildVersion: string;
|
buildVersion: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
eleArch: string;
|
eleArch: string;
|
||||||
}
|
}
|
||||||
type QQVersionConfigType = {
|
export type QQVersionConfigType = {
|
||||||
baseVersion: string;
|
baseVersion: string;
|
||||||
curVersion: string;
|
curVersion: string;
|
||||||
prevVersion: string;
|
prevVersion: string;
|
||||||
onErrorVersions: Array<any>;
|
onErrorVersions: Array<unknown>;
|
||||||
buildId: string;
|
buildId: string;
|
||||||
}
|
}
|
||||||
type QQAppidTableType = {
|
export type QQAppidTableType = {
|
||||||
[key: string]: { appid: string, qua: string };
|
[key: string]: { appid: string, qua: string };
|
||||||
}
|
}
|
||||||
|
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.4.12';
|
export const napCatVersion = '4.5.5';
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user