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
|
||||
charset = utf-8
|
||||
|
||||
# 2 space indentation
|
||||
# 4 space indentation
|
||||
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -21,4 +21,4 @@ indent_size = 4
|
||||
charset = latin1
|
||||
|
||||
# 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
|
||||
node_modules/
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
out/
|
||||
dist/
|
||||
/src/core.lib/common/
|
||||
@@ -13,4 +14,4 @@ devconfig/*
|
||||
# Build
|
||||
*.db
|
||||
checkVersion.sh
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -5,5 +5,6 @@
|
||||
".env.universal": ".env.*",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
}
|
||||
},
|
||||
"css.customData": [".vscode/tailwindcss.json"],
|
||||
}
|
55
.vscode/tailwindcss.json
vendored
Normal file
55
.vscode/tailwindcss.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@tailwind",
|
||||
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@responsive",
|
||||
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@screen",
|
||||
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@variants",
|
||||
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -64,4 +64,4 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
|
||||
|
||||
## 开源附加
|
||||
|
||||
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
|
||||
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高IM易用性,实现类似Hook推送,此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||
|
@@ -1,70 +1,32 @@
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import _import from "eslint-plugin-import";
|
||||
import { fixupPluginRules } from "@eslint/compat";
|
||||
import eslint from '@eslint/js';
|
||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsEslintParser from '@typescript-eslint/parser';
|
||||
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 dirname = path.dirname(filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [{
|
||||
ignores: ["src/core/proto/"],
|
||||
}, ...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,
|
||||
const customTsFlatConfig = [
|
||||
{
|
||||
name: 'typescript-eslint/base',
|
||||
languageOptions: {
|
||||
parser: tsEslintParser,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
NodeJS: 'readonly', // 添加 NodeJS 全局变量
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
indent: ["error", 4],
|
||||
semi: ["error", "always"],
|
||||
"no-unused-vars": "off",
|
||||
"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,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
...tsEslintPlugin.configs.recommended.rules,
|
||||
'quotes': ['error', 'single'], // 使用单引号
|
||||
'semi': ['error', 'always'], // 强制使用分号
|
||||
'indent': ['error', 4], // 使用 4 空格缩进
|
||||
},
|
||||
ecmaVersion: 5,
|
||||
sourceType: "commonjs",
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin,
|
||||
},
|
||||
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
|
||||
},
|
||||
}];
|
||||
];
|
||||
|
||||
export default [eslint.configs.recommended, ...customTsFlatConfig];
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.4.12",
|
||||
"version": "4.5.5",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -4,12 +4,15 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host=0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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/breadcrumbs": "2.2.7",
|
||||
"@heroui/button": "2.2.10",
|
||||
@@ -26,6 +29,7 @@
|
||||
"@heroui/listbox": "2.3.10",
|
||||
"@heroui/modal": "2.2.8",
|
||||
"@heroui/navbar": "2.2.9",
|
||||
"@heroui/pagination": "^2.2.9",
|
||||
"@heroui/popover": "2.3.10",
|
||||
"@heroui/select": "2.4.10",
|
||||
"@heroui/slider": "2.4.8",
|
||||
@@ -33,73 +37,78 @@
|
||||
"@heroui/spinner": "2.2.7",
|
||||
"@heroui/switch": "2.2.9",
|
||||
"@heroui/system": "2.4.7",
|
||||
"@heroui/table": "^2.2.9",
|
||||
"@heroui/tabs": "2.2.8",
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@react-aria/visually-hidden": "3.8.18",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@react-aria/visually-hidden": "^3.8.19",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "2.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^5.5.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"framer-motion": "^11.15.0",
|
||||
"framer-motion": "^12.0.6",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^11.15.0",
|
||||
"motion": "^12.0.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"qface": "^1.4.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "7.1.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwind-variants": "0.3.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@eslint/js": "^9.19.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/fabric": "^5.3.9",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"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-hooks": "^5.1.0",
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.2",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"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 IndexPage = lazy(() => import('@/pages/index'))
|
||||
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() {
|
||||
return (
|
||||
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<IndexPage />} path="/*" />
|
||||
<Route element={<QQLoginPage />} path="/qq_login" />
|
||||
<Route element={<WebLoginPage />} path="/web_login" />
|
||||
<Route path="/" element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { IoMdRefresh } from 'react-icons/io'
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh: () => void
|
||||
refresh?: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
@@ -27,21 +27,23 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
color="danger"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@@ -27,7 +27,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'font-outfit flex-1',
|
||||
'flex-1',
|
||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
|
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="font-noto-serif">{data?.hitokoto}</div>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className="text-right">
|
||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
|
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"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
begin="800ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
begin="1600ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
begin="2400ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
||||
begin="3200ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="0ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="600ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="1200ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="1800ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="2400ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="3000ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="3600ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
begin="4200ms"
|
||||
></animate>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
attributeName="fillOpacity"
|
||||
to="1"
|
||||
dur="800ms"
|
||||
calcMode="linear"
|
||||
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
||||
</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,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
port: 3001,
|
||||
reportSelfMessage: false,
|
||||
enableForcePushEvent: true,
|
||||
messagePostFormat: 'array',
|
||||
|
@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
shadow="sm"
|
||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||
>
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
|
@@ -67,7 +67,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
||||
<h2 className="font-bold">{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-danger-200', {
|
||||
'!text-danger-400': apiName === selectedApi
|
||||
|
@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<PageLoading loading={loading} />
|
||||
{error ? (
|
||||
<CardBody className="items-center gap-1 justify-center">
|
||||
<div className="font-outfit flex-1 text-content1-foreground">
|
||||
Error
|
||||
</div>
|
||||
<div className="flex-1 text-content1-foreground">Error</div>
|
||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||
{error.message}
|
||||
</div>
|
||||
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex-col justify-center">
|
||||
<div className="font-outfit text-lg truncate">{data?.nick}</div>
|
||||
<div className="font-ubuntu text-danger-500 text-sm">
|
||||
{data?.uin}
|
||||
</div>
|
||||
<div className="text-lg truncate">{data?.nick}</div>
|
||||
<div className="text-danger-500 text-sm">{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
|
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 type { MenuItem } from '@/config/site'
|
||||
|
||||
import { title } from '../primitives'
|
||||
import Menus from './menus'
|
||||
|
||||
interface SideBarProps {
|
||||
@@ -48,19 +47,15 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
||||
<div className="flex justify-center items-center mt-2 gap-2">
|
||||
<Image height={40} src={logo} className="mb-2" />
|
||||
<div className="flex justify-center items-center my-2 gap-2">
|
||||
<Image radius="none" height={40} src={logo} className="mb-2" />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center hm-medium',
|
||||
title({
|
||||
shadow: true,
|
||||
color: isDark ? 'violet' : 'pink'
|
||||
}),
|
||||
'!text-2xl'
|
||||
'flex items-center font-bold',
|
||||
'!text-2xl shiny-text'
|
||||
)}
|
||||
>
|
||||
WebUI
|
||||
NapCat
|
||||
</div>
|
||||
</div>
|
||||
<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 { GithubRelease } from '@/types/github'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import TailwindMarkdown from './tailwind_markdown'
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
@@ -198,11 +197,6 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
<CardBody className="flex-1">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title="WebUI 版本"
|
||||
icon={<IoLogoChrome className="text-xl" />}
|
||||
value={packageJson.version}
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title="QQ 版本"
|
||||
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
|
||||
title="系统版本"
|
||||
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} />,
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-blue-500 hover:underline"
|
||||
className="text-primary-500 inline-block hover:underline"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
@@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-gray-300 pl-4 italic"
|
||||
className="border-l-4 border-default-300 pl-4 italic"
|
||||
{...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 { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
// import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import clsx from 'clsx'
|
||||
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import { gradientText } from '@/utils/terminal'
|
||||
|
||||
export type XTermRef = {
|
||||
write: (
|
||||
...args: Parameters<Terminal['write']>
|
||||
@@ -20,134 +19,174 @@ export type XTermRef = {
|
||||
) => ReturnType<Terminal['writeln']>
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
terminalRef: React.RefObject<Terminal | null>
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||
(props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const { className, ...rest } = props
|
||||
const { theme } = useTheme()
|
||||
useEffect(() => {
|
||||
if (!domRef.current) {
|
||||
return
|
||||
export interface XTermProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||
onInput?: (data: string) => void
|
||||
onKey?: (key: string, event: KeyboardEvent) => void
|
||||
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const { className, onInput, onKey, 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(
|
||||
gradientText(
|
||||
'Welcome to NapCat WebUI',
|
||||
[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)
|
||||
terminal.onKey((event) => {
|
||||
if (onKey) {
|
||||
onKey(event.key, event.domEvent)
|
||||
}
|
||||
}, [])
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
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 = {
|
||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
||||
selectionBackground:
|
||||
theme === 'dark'
|
||||
? 'rgba(179, 0, 0, 0.3)'
|
||||
: 'rgba(255, 167, 167, 0.3)',
|
||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
||||
black: theme === 'dark' ? '#fff' : '#000'
|
||||
background: '#00000000',
|
||||
black: '#ffffff',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5',
|
||||
foreground: '#cccccc',
|
||||
selectionBackground: '#3a3d41',
|
||||
cursor: '#ffffff'
|
||||
}
|
||||
} else {
|
||||
terminalRef.current.options.theme = {
|
||||
background: '#ffffff00',
|
||||
black: '#000000',
|
||||
red: '#aa3731',
|
||||
green: '#448c27',
|
||||
yellow: '#cb9000',
|
||||
blue: '#325cc0',
|
||||
cyan: '#0083b2',
|
||||
white: '#7f7f7f',
|
||||
brightBlack: '#777777',
|
||||
brightRed: '#f05050',
|
||||
brightGreen: '#60cb00',
|
||||
brightYellow: '#ffbc5d',
|
||||
brightBlue: '#007acc',
|
||||
brightCyan: '#00aacb',
|
||||
brightWhite: '#b0b0b0',
|
||||
foreground: '#000000',
|
||||
selectionBackground: '#bfdbfe',
|
||||
cursor: '#007acc'
|
||||
}
|
||||
terminalRef.current.options.fontWeight =
|
||||
theme === 'dark' ? 'normal' : '600'
|
||||
terminalRef.current.options.fontWeightBold =
|
||||
theme === 'dark' ? 'bold' : '900'
|
||||
}
|
||||
}, [theme])
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
write: (...args) => {
|
||||
return terminalRef.current?.write(...args)
|
||||
},
|
||||
writeAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.write(data, resolve)
|
||||
})
|
||||
},
|
||||
writeln: (...args) => {
|
||||
return terminalRef.current?.writeln(...args)
|
||||
},
|
||||
writelnAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.writeln(data, resolve)
|
||||
})
|
||||
},
|
||||
clear: () => {
|
||||
terminalRef.current?.clear()
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
write: (...args) => {
|
||||
return terminalRef.current?.write(...args)
|
||||
},
|
||||
writeAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.write(data, resolve)
|
||||
})
|
||||
},
|
||||
writeln: (...args) => {
|
||||
return terminalRef.current?.writeln(...args)
|
||||
},
|
||||
writelnAsync: async (data) => {
|
||||
return new Promise((resolve) => {
|
||||
terminalRef.current?.writeln(data, resolve)
|
||||
})
|
||||
},
|
||||
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
|
||||
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
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
ref={domRef}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
ref={domRef}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default XTerm
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BugIcon2,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
LogIcon,
|
||||
RouteIcon,
|
||||
SettingsIcon,
|
||||
SignalTowerIcon,
|
||||
@@ -49,10 +51,10 @@ export const siteConfig = {
|
||||
href: '/config'
|
||||
},
|
||||
{
|
||||
label: '系统日志',
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<TerminalIcon />
|
||||
<LogIcon />
|
||||
</div>
|
||||
),
|
||||
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: '关于我们',
|
||||
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
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default class WebUIManager {
|
||||
public static async checkWebUiLogined() {
|
||||
const { data } =
|
||||
@@ -24,12 +32,20 @@ export default class WebUIManager {
|
||||
return data.data.Credential
|
||||
}
|
||||
|
||||
public static async changePassword(oldToken: string, newToken: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/auth/update_token',
|
||||
{ oldToken, newToken }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async proxy<T>(url = '') {
|
||||
const data = await serverRequest.get<ServerResponse<string>>(
|
||||
'/base/proxy?url=' + encodeURIComponent(url)
|
||||
)
|
||||
data.data.data = JSON.parse(data.data.data)
|
||||
return data.data as ServerResponse<T>
|
||||
return data.data as ServerResponse<T>
|
||||
}
|
||||
|
||||
public static async getPackageInfo() {
|
||||
|
@@ -14,10 +14,12 @@ const useConfig = () => {
|
||||
key: T,
|
||||
value: OneBotConfig['network'][T][0]
|
||||
) => {
|
||||
if (
|
||||
value.name &&
|
||||
config.network[key].some((item) => item.name === value.name)
|
||||
) {
|
||||
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
|
||||
const _key = key as keyof OneBotConfig['network']
|
||||
return acc.concat(config.network[_key].map((item) => item.name))
|
||||
}, [] as string[])
|
||||
|
||||
if (value.name && allNetworkNames.includes(value.name)) {
|
||||
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
|
||||
className={clsx(
|
||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
||||
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
|
||||
'dark:bg-background dark:shadow-danger-100',
|
||||
'bg-background !bg-opacity-50',
|
||||
'shadow-sm shadow-danger-50',
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import 'react-photo-view/dist/react-photo-view.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import App from '@/App.tsx'
|
||||
|
@@ -1,91 +1,200 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Link } from '@heroui/link'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
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 { 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 WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
import packageJson from '../../../package.json'
|
||||
|
||||
function VersionInfo() {
|
||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Chip
|
||||
startContent={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-danger-500 drop-shadow-md">NapCat</div>
|
||||
{error ? (
|
||||
error.message
|
||||
) : loading ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
||||
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
||||
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div className="flex flex-col md:flex-row items-center">
|
||||
<Image
|
||||
alt="logo"
|
||||
className="flex-shrink-0 w-52 md:w-48 mr-2"
|
||||
src={logo}
|
||||
/>
|
||||
<div className="flex -mt-9 md:mt-0">
|
||||
<WebUIIcon />
|
||||
<HoverTiltedCard imageSrc={logo} overlayContent="" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 py-2">
|
||||
<VersionInfo />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-danger-400">NapCat 是什么?</p>
|
||||
<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 className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
|
||||
Created By
|
||||
<div className="flex scale-80 -ml-5 -mr-5">
|
||||
<BietiaopIcon />
|
||||
</div>
|
||||
</div>
|
||||
<VersionInfo />
|
||||
<div className="mb-6 flex flex-col items-center gap-4">
|
||||
<p
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'cyan',
|
||||
shadow: true
|
||||
}),
|
||||
'!text-3xl'
|
||||
)}
|
||||
>
|
||||
NapCat Contributors
|
||||
</p>
|
||||
<Image
|
||||
className="w-[600px] max-w-full pointer-events-none select-none"
|
||||
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
|
||||
alt="Contributors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-around">
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://qm.qq.com/q/F9cgs1N3Mc"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50">
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群1</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://qm.qq.com/q/hSt0u9PVn"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50">
|
||||
<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>
|
||||
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</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 { 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 key from '@/const/key'
|
||||
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import ChangePasswordCard from './change_password'
|
||||
import OneBotConfigCard from './onebot'
|
||||
import WebUIConfigCard from './webui'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
control: onebotControl,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting: isOnebotSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const {
|
||||
control: webuiControl,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting: isWebuiSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
|
||||
return (
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">{children}</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
const resetOneBot = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const resetWebUI = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onOneBotSubmit = handleOnebotSubmit((data) => {
|
||||
try {
|
||||
saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onWebuiSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async (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)
|
||||
}, [])
|
||||
export default function ConfigPage() {
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
|
||||
return (
|
||||
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
|
||||
@@ -122,23 +38,20 @@ export default function ConfigPage() {
|
||||
}}
|
||||
>
|
||||
<Tab title="OneBot配置" key="onebot">
|
||||
<PageLoading loading={loading} />
|
||||
<OneBotConfigCard
|
||||
isSubmitting={isOnebotSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onOneBotSubmit}
|
||||
control={onebotControl}
|
||||
reset={resetOneBot}
|
||||
/>
|
||||
<ConfingPageItem>
|
||||
<OneBotConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title="WebUI配置" key="webui">
|
||||
<WebUIConfigCard
|
||||
isSubmitting={isWebuiSubmitting}
|
||||
onRefresh={onRefresh}
|
||||
onSubmit={onWebuiSubmit}
|
||||
control={webuiControl}
|
||||
reset={resetWebUI}
|
||||
/>
|
||||
<ConfingPageItem>
|
||||
<WebUIConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
|
||||
<Tab title="修改密码" key="token">
|
||||
<ConfingPageItem>
|
||||
<ChangePasswordCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
@@ -1,68 +1,110 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import SwitchCard from '@/components/switch_card'
|
||||
|
||||
export interface OneBotConfigCardProps {
|
||||
control: Control<IConfig['onebot']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
const OneBotConfigCard = () => {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
const reset = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
|
||||
const onSubmit = handleOnebotSubmit((data) => {
|
||||
try {
|
||||
saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async (shotTip = true) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await refreshConfig()
|
||||
if (shotTip) toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh(false)
|
||||
}, [])
|
||||
|
||||
if (loading) return <PageLoading loading={true} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -1,69 +1,99 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import ImageInput from '@/components/input/image_input'
|
||||
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import { siteConfig } from '@/config/site'
|
||||
|
||||
export interface WebUIConfigCardProps {
|
||||
control: Control<IConfig['webui']>
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
isSubmitting: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
|
||||
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
|
||||
const WebUIConfigCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [listId, customIcons, b64img])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="items-center py-5">
|
||||
<div className="w-96 max-w-full flex flex-col gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => (
|
||||
<ImageInput {...field} label={item.label} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
||||
|
||||
export default function WSDebug() {
|
||||
const url = new URL(window.location.origin)
|
||||
url.port = '3000'
|
||||
url.port = '3001'
|
||||
url.protocol = 'ws:'
|
||||
const defaultWsUrl = url.href
|
||||
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 { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
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() {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route element={<DashboardIndexPage />} path="/" />
|
||||
<Route element={<NetworkPage />} path="/network" />
|
||||
<Route element={<ConfigPage />} path="/config" />
|
||||
<Route element={<LogsPage />} path="/logs" />
|
||||
<Route element={<DebugPage />} path="/debug">
|
||||
<Route path="ws" element={<WSDebug />} />
|
||||
<Route path="http" element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route element={<AboutPage />} path="/about" />
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center px-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeInOut'
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Suspense>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
@@ -1,111 +1,13 @@
|
||||
/* HarmonyOS Sans SC */
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-family: 'Aa偷吃可爱长大的';
|
||||
src: url('/fonts/AaCute.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Harmony';
|
||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Ubuntu */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* LibreBaskerville */
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Libre Baskerville';
|
||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* NotoSerifSC */
|
||||
@font-face {
|
||||
font-family: 'Noto Serif SC';
|
||||
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Outfit */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* FiraCode */
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
@@ -1,28 +1,36 @@
|
||||
@import url("./fonts.css");
|
||||
|
||||
@import url('./fonts.css');
|
||||
@import url('./text.css');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
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 {
|
||||
.hm-medium {
|
||||
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
||||
@apply font-bold;
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
.font-ubuntu {
|
||||
font-family: 'Ubuntu', sans-serif;
|
||||
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.font-outfit {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
.font-libre {
|
||||
font-family: 'Libre Baskerville', serif;
|
||||
}
|
||||
.font-noto-serif {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
.hide-scrollbar::-webkit-scrollbar-track {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +59,8 @@ body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.monaco-editor, .monaco-editor-background {
|
||||
.monaco-editor,
|
||||
.monaco-editor-background {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -77,7 +86,12 @@ body {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -85,16 +99,4 @@ body {
|
||||
}
|
||||
.ql-editor img {
|
||||
@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
|
||||
/** 文件大小(字节数) */
|
||||
size: number
|
||||
/** busid(目前不清楚有什么作用) */
|
||||
/** busid 无作用 */
|
||||
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
|
||||
regTime: number
|
||||
interest: string
|
||||
labels: unknown[]
|
||||
labels: string[]
|
||||
qqLevel: QQLevel
|
||||
}
|
||||
|
||||
|
@@ -45,6 +45,10 @@ serverRequest.interceptors.request.use((config) => {
|
||||
})
|
||||
|
||||
serverRequest.interceptors.response.use((response) => {
|
||||
// 如果是流式传输的文件
|
||||
if (response.headers['content-type'] === 'application/octet-stream') {
|
||||
return response
|
||||
}
|
||||
if (response.data.code !== 0) {
|
||||
if (response.data.message === 'Unauthorized') {
|
||||
const token = localStorage.getItem(key.token)
|
||||
|
@@ -29,6 +29,11 @@ export default defineConfig(({ mode }) => {
|
||||
base: '/webui/',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/ws/terminal': {
|
||||
target: backendDebugUrl,
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api': backendDebugUrl
|
||||
}
|
||||
},
|
||||
|
18
package.json
18
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.4.12",
|
||||
"version": "4.5.5",
|
||||
"scripts": {
|
||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||
@@ -17,19 +17,20 @@
|
||||
"dev:depend": "npm i && cd napcat.webui && npm i"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"esbuild": "0.24.0",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@eslint/compat": "^1.2.2",
|
||||
"@eslint/eslintrc": "^3.1.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",
|
||||
"@napneko/nap-proto-core": "^0.0.4",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@sinclair/typebox": "^0.34.9",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.1",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.5.12",
|
||||
@@ -39,13 +40,17 @@
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^13.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"esbuild": "0.24.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^4.3.6",
|
||||
"file-type": "^20.0.0",
|
||||
"globals": "^15.12.0",
|
||||
"image-size": "^1.1.1",
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"vite": "^6.0.1",
|
||||
@@ -55,10 +60,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
||||
"@ffmpeg.wasm/main": "^0.13.1",
|
||||
"compressing": "^1.10.1",
|
||||
"express": "^5.0.0",
|
||||
"piscina": "^4.7.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { encode } from "silk-wasm";
|
||||
import { encode } from 'silk-wasm';
|
||||
|
||||
export interface EncodeArgs {
|
||||
input: ArrayBufferView | ArrayBuffer
|
||||
|
@@ -4,8 +4,8 @@ import path from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import { EncodeArgs } from "@/common/audio-worker";
|
||||
import { FFmpegService } from "@/common/ffmpeg";
|
||||
import { EncodeArgs } from '@/common/audio-worker';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
|
||||
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) {
|
||||
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);
|
||||
return duration;
|
||||
}
|
||||
@@ -27,12 +27,11 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
|
||||
async function handleWavFile(
|
||||
file: Buffer,
|
||||
filePath: string,
|
||||
pcmPath: string,
|
||||
logger: LogWrapper
|
||||
pcmPath: string
|
||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
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 };
|
||||
}
|
||||
@@ -45,9 +44,10 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
const { input, sampleRate } = isWav(file)
|
||||
? (await handleWavFile(file, filePath, pcmPath, logger))
|
||||
: { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
|
||||
? await handleWavFile(file, filePath, pcmPath)
|
||||
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
||||
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));
|
||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||
return {
|
||||
@@ -59,8 +59,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
let duration = 0;
|
||||
try {
|
||||
duration = getDuration(file) / 1000;
|
||||
} catch (e: any) {
|
||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, e.stack);
|
||||
} catch (e: unknown) {
|
||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||
duration = await guessDuration(filePath, logger);
|
||||
}
|
||||
return {
|
||||
@@ -69,8 +69,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
duration,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.logError('convert silk failed', error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.logError('convert silk failed', (error as Error).stack);
|
||||
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 class CancelableTask<T> {
|
||||
@@ -7,30 +8,34 @@ export class CancelableTask<T> {
|
||||
private cancelListeners: Array<() => void> = [];
|
||||
|
||||
constructor(executor: TaskExecutor<T>) {
|
||||
this.promise = new Promise<T>(async (resolve, reject) => {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
const onCancel = (callback: () => void) => {
|
||||
this.cancelCallback = callback;
|
||||
};
|
||||
|
||||
try {
|
||||
await executor(
|
||||
(value) => {
|
||||
if (!this.isCanceled) {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
if (!this.isCanceled) {
|
||||
reject(reason);
|
||||
}
|
||||
},
|
||||
onCancel
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isCanceled) {
|
||||
reject(error);
|
||||
const execute = async () => {
|
||||
try {
|
||||
await executor(
|
||||
(value) => {
|
||||
if (!this.isCanceled) {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
if (!this.isCanceled) {
|
||||
reject(reason);
|
||||
}
|
||||
},
|
||||
onCancel
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isCanceled) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
execute();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,41 +77,4 @@ export class CancelableTask<T> {
|
||||
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 type { NapCatCore } from '@/core';
|
||||
import json5 from 'json5';
|
||||
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
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.core = core;
|
||||
this.configPath = configPath;
|
||||
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
this.read(copy_default);
|
||||
this.read();
|
||||
}
|
||||
|
||||
protected getKeys(): string[] | null {
|
||||
// 决定 key 在json配置文件中的顺序
|
||||
return null;
|
||||
getConfigPath(pathName?: string): string {
|
||||
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||
return path.join(this.configPath, filename);
|
||||
}
|
||||
|
||||
getConfigPath(pathName: string | undefined): string {
|
||||
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 {
|
||||
|
||||
read(): T {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
if (!fs.existsSync(configPath) && copy_default) {
|
||||
try {
|
||||
fs.writeFileSync(configPath, fs.readFileSync(this.getConfigPath(undefined), 'utf-8'));
|
||||
this.core.context.logger.log(`[Core] [Config] 配置文件创建成功!\n`);
|
||||
} catch (e: any) {
|
||||
this.core.context.logger.logError(`[Core] [Config] 创建配置文件时发生错误:`, e.message);
|
||||
const defaultConfigPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
this.configData = this.loadConfig(defaultConfigPath);
|
||||
}
|
||||
} else if (!fs.existsSync(configPath) && !copy_default) {
|
||||
fs.writeFileSync(configPath, '{}');
|
||||
this.save();
|
||||
return this.configData;
|
||||
}
|
||||
return this.loadConfig(configPath);
|
||||
}
|
||||
|
||||
private loadConfig(configPath: string): T {
|
||||
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);
|
||||
return this.configData;
|
||||
} catch (e: any) {
|
||||
if (e instanceof SyntaxError) {
|
||||
this.core.context.logger.logError(`[Core] [Config] 配置文件格式错误,请检查配置文件:`, e.message);
|
||||
} else {
|
||||
this.core.context.logger.logError(`[Core] [Config] 读取配置文件时发生错误:`, e.message);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, '读取配置文件时发生错误');
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
save(newConfigData: T = this.configData) {
|
||||
const selfInfo = this.core.selfInfo;
|
||||
save(newConfigData: T = this.configData): void {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
const configPath = this.getConfigPath(selfInfo.uin);
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
|
||||
} catch (e: any) {
|
||||
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, e.message);
|
||||
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||
} catch (e: unknown) {
|
||||
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 { randomUUID } from 'crypto';
|
||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/core';
|
||||
@@ -60,17 +61,22 @@ export class NTEventWrapper {
|
||||
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>> };
|
||||
};
|
||||
if (eventNameArr.length > 1) {
|
||||
const serviceName = 'get' + eventNameArr[0].replace('NodeIKernel', '');
|
||||
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||
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];
|
||||
|
||||
//重新绑定this
|
||||
event = event.bind(services);
|
||||
event = event?.bind(services);
|
||||
if (event) {
|
||||
return event as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
createListenerFunction<T>(listenerMainName: string, uniqueCode: string = ''): T {
|
||||
@@ -126,8 +132,8 @@ export class NTEventWrapper {
|
||||
) {
|
||||
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0];
|
||||
const ListenerSubName = ListenerNameList[1];
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined = undefined;
|
||||
@@ -205,8 +211,8 @@ export class NTEventWrapper {
|
||||
}
|
||||
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0];
|
||||
const ListenerSubName = ListenerNameList[1];
|
||||
const ListenerMainName = ListenerNameList[0]??'';
|
||||
const ListenerSubName = ListenerNameList[1]??'';
|
||||
|
||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||
(resolve, reject) => {
|
||||
|
@@ -1,18 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { FFmpeg } from '@ffmpeg.wasm/main';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync, statSync, writeFileSync } from 'fs';
|
||||
import type { LogWrapper } from './log';
|
||||
import type { VideoInfo } from './video';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import imageSize from 'image-size';
|
||||
class FFmpegService {
|
||||
class FFmpegService {
|
||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
const videoFileName = `${randomUUID()}.mp4`;
|
||||
const outputFileName = `${randomUUID()}.jpg`;
|
||||
try {
|
||||
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
||||
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
||||
const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
||||
if (code !== 0) {
|
||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import imageSize from 'image-size';
|
||||
const params = format === 'amr'
|
||||
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
||||
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
||||
let code = await ffmpegInstance.run(...params);
|
||||
const code = await ffmpegInstance.run(...params);
|
||||
if (code !== 0) {
|
||||
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 inputFileName = `${randomUUID()}.input`;
|
||||
const outputFileName = `${randomUUID()}.pcm`;
|
||||
try {
|
||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
||||
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
||||
let code = await ffmpegInstance.run(...params);
|
||||
const code = await ffmpegInstance.run(...params);
|
||||
if (code !== 0) {
|
||||
throw new Error('FFmpeg process exited with code ' + code);
|
||||
}
|
||||
@@ -87,36 +87,36 @@ import imageSize from 'image-size';
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(outputFileName);
|
||||
} catch (unlinkError) {
|
||||
logger.log('Error unlinking output file:', unlinkError);
|
||||
console.error('Error unlinking output file:', unlinkError);
|
||||
}
|
||||
try {
|
||||
ffmpegInstance.fs.unlink(inputFileName);
|
||||
} 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> {
|
||||
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
||||
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
||||
const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
||||
const inputFileName = `${randomUUID()}.${fileType}`;
|
||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
||||
ffmpegInstance.setLogging(true);
|
||||
let duration = 60;
|
||||
ffmpegInstance.setLogger((level, ...msg) => {
|
||||
ffmpegInstance.setLogger((_level, ...msg) => {
|
||||
const message = msg.join(' ');
|
||||
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
||||
if (durationMatch) {
|
||||
const hours = parseInt(durationMatch[1], 10);
|
||||
const minutes = parseInt(durationMatch[2], 10);
|
||||
const seconds = parseFloat(durationMatch[3]);
|
||||
const hours = parseInt(durationMatch[1] ?? '0', 10);
|
||||
const minutes = parseInt(durationMatch[2] ?? '0', 10);
|
||||
const seconds = parseFloat(durationMatch[3] ?? '0');
|
||||
duration = hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
});
|
||||
await ffmpegInstance.run('-i', inputFileName);
|
||||
let image = imageSize(thumbnailPath);
|
||||
const image = imageSize(thumbnailPath);
|
||||
ffmpegInstance.fs.unlink(inputFileName);
|
||||
const fileSize = statSync(videoPath).size;
|
||||
return {
|
||||
@@ -126,7 +126,7 @@ import imageSize from 'image-size';
|
||||
format: fileType,
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||
@@ -137,15 +137,15 @@ interface FFmpegTask {
|
||||
}
|
||||
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
||||
switch (method) {
|
||||
case 'extractThumbnail':
|
||||
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||
case 'convertFile':
|
||||
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||
case 'convert':
|
||||
return await FFmpegService.convert(...args as [string, string, LogWrapper]);
|
||||
case 'getVideoInfo':
|
||||
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
case 'extractThumbnail':
|
||||
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||
case 'convertFile':
|
||||
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||
case 'convert':
|
||||
return await FFmpegService.convert(...args as [string, string]);
|
||||
case 'getVideoInfo':
|
||||
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import Piscina from "piscina";
|
||||
import { VideoInfo } from "./video";
|
||||
import type { LogWrapper } from "./log";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Piscina from 'piscina';
|
||||
import { VideoInfo } from './video';
|
||||
|
||||
type EncodeArgs = {
|
||||
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||
@@ -30,11 +30,11 @@ export class FFmpegService {
|
||||
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>({
|
||||
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();
|
||||
return result;
|
||||
}
|
||||
@@ -47,4 +47,4 @@ export class FFmpegService {
|
||||
await piscina.destroy();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -83,7 +83,7 @@ class FileUUIDManager {
|
||||
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;
|
||||
this.cache.put(uuid, data);
|
||||
return uuid;
|
||||
@@ -101,7 +101,7 @@ export class FileNapCatOneBotUUIDWrap {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ export class FileNapCatOneBotUUIDWrap {
|
||||
return this.manager.decode(uuid);
|
||||
}
|
||||
|
||||
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = "", customUUID?: string): string {
|
||||
return this.manager.encode({ peer, msgId, elementId, fileUUID }, "", customUUID);
|
||||
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
|
||||
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error & { code: string }).code === 'ENOENT') {
|
||||
// 如果文件不存在,则抛出一个错误
|
||||
throw new Error(`文件不存在: ${path}`);
|
||||
} else {
|
||||
@@ -169,6 +169,7 @@ export async function checkUriType(Uri: string) {
|
||||
const data = uri.split(',')[1];
|
||||
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
||||
}
|
||||
return;
|
||||
}, Uri);
|
||||
if (OtherFileRet) return OtherFileRet;
|
||||
|
||||
@@ -190,7 +191,7 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers });
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import { PacketMsg } from "@/core/packet/message/message";
|
||||
import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
@@ -50,15 +50,15 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
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 isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
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) {
|
||||
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 => ({
|
||||
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
||||
}));
|
||||
@@ -67,15 +67,15 @@ export class ForwardMsgBuilder {
|
||||
summary = `查看${msg.length}条转发消息`;
|
||||
}
|
||||
if (!prompt) {
|
||||
prompt = "[聊天记录]";
|
||||
prompt = '[聊天记录]';
|
||||
}
|
||||
return {
|
||||
app: "com.tencent.multimsg",
|
||||
app: 'com.tencent.multimsg',
|
||||
config: {
|
||||
autosize: 1,
|
||||
forward: 1,
|
||||
round: 1,
|
||||
type: "normal",
|
||||
type: 'normal',
|
||||
width: 300
|
||||
},
|
||||
desc: prompt,
|
||||
@@ -93,8 +93,8 @@ export class ForwardMsgBuilder {
|
||||
}
|
||||
},
|
||||
prompt,
|
||||
ver: "0.0.0.5",
|
||||
view: "contact",
|
||||
ver: '0.0.0.5',
|
||||
view: 'contact',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,12 +102,12 @@ export class ForwardMsgBuilder {
|
||||
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 => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : "[该消息类型暂不支持查看]",
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
}))
|
||||
})), source, news, summary, prompt);
|
||||
}
|
||||
|
@@ -1,14 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import path from 'node:path';
|
||||
import fs from 'fs';
|
||||
import os from 'node:os';
|
||||
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> {
|
||||
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||
try {
|
||||
const result = func(...args);
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
@@ -166,14 +168,14 @@ export function calcQQLevel(level?: QQLevel) {
|
||||
}
|
||||
|
||||
export function stringifyWithBigInt(obj: any) {
|
||||
return JSON.stringify(obj, (key, value) =>
|
||||
return JSON.stringify(obj, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
||||
const hexSequence = "A4 09 00 00 00 35";
|
||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ""), "hex");
|
||||
const hexSequence = 'A4 09 00 00 00 35';
|
||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||
const filePath = path.resolve(nodeMajor);
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
|
||||
@@ -192,8 +194,8 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
||||
const content = fileContent.subarray(start, end);
|
||||
if (!content.every(byte => byte === 0x00)) {
|
||||
try {
|
||||
return content.toString("utf-8");
|
||||
} catch (error) {
|
||||
return content.toString('utf-8');
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import winston, { format, transports } from 'winston';
|
||||
import { truncateString } from '@/common/helper';
|
||||
import path from 'node:path';
|
||||
@@ -34,7 +35,7 @@ class Subscription {
|
||||
for (const history of Subscription.history) {
|
||||
try {
|
||||
listener(history);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -68,7 +69,7 @@ export class LogWrapper {
|
||||
format: format.combine(
|
||||
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
@@ -83,7 +84,7 @@ export class LogWrapper {
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
@@ -303,7 +304,7 @@ function textElementToText(textElement: any): string {
|
||||
const originalContentLines = textElement.content.split('\n');
|
||||
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||
return `@全体成员`;
|
||||
return '@全体成员';
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
||||
return `${textElement.content} (${textElement.atUid})`;
|
||||
}
|
||||
|
@@ -68,7 +68,10 @@ export class LimitedHashTable<K, V> {
|
||||
const listSize = Math.min(size, keyList.length);
|
||||
for (let i = 0; i < 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;
|
||||
}
|
||||
@@ -96,8 +99,10 @@ class MessageUniqueWrapper {
|
||||
createUniqueMsgId(peer: Peer, msgId: string) {
|
||||
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
||||
const hash = crypto.createHash('md5').update(key).digest();
|
||||
//设置第一个bit为0 保证shortId为正数
|
||||
hash[0] &= 0x7f;
|
||||
if (hash[0]) {
|
||||
//设置第一个bit为0 保证shortId为正数
|
||||
hash[0] &= 0x7f;
|
||||
}
|
||||
const shortId = hash.readInt32BE(0);
|
||||
//减少性能损耗
|
||||
this.msgIdMap.set(msgId, shortId);
|
||||
@@ -110,11 +115,11 @@ class MessageUniqueWrapper {
|
||||
if (data) {
|
||||
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||
const peer: Peer = {
|
||||
chatType: parseInt(chatTypeStr),
|
||||
peerUid,
|
||||
chatType: parseInt(chatTypeStr ?? '0'),
|
||||
peerUid: peerUid ?? '',
|
||||
guildId: '',
|
||||
};
|
||||
return { MsgId: msgId, Peer: peer };
|
||||
return { MsgId: msgId ?? '0', Peer: peer };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { LogWrapper } from '@/common/log';
|
||||
|
||||
export function proxyHandlerOf(logger: LogWrapper) {
|
||||
@@ -5,6 +6,7 @@ export function proxyHandlerOf(logger: LogWrapper) {
|
||||
get(target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return (..._args: unknown[]) => {
|
||||
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 { LogWrapper } from '@/common/log';
|
||||
import { getMajorPath } from '@/core';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
|
||||
|
||||
export class QQBasicInfoWrapper {
|
||||
QQMainPath: string | undefined;
|
||||
@@ -86,14 +87,14 @@ export class QQBasicInfoWrapper {
|
||||
try {
|
||||
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
||||
if (majorAppid) {
|
||||
this.context.logger.log(`[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat`);
|
||||
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
|
||||
return { appid: majorAppid, qua: this.getQUAFallback() };
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.log(`[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常`);
|
||||
} catch {
|
||||
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} 版本兼容性不佳,可能会导致一些功能无法正常使用`,);
|
||||
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
|
||||
@@ -41,11 +42,13 @@ export class RequestUtil {
|
||||
|
||||
private static extractCookies(setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||
setCookieHeaders.forEach((cookie) => {
|
||||
const parts = cookie.split(';')[0].split('=');
|
||||
const key = parts[0];
|
||||
const value = parts[1];
|
||||
if (key && value && key.length > 0 && value.length > 0) {
|
||||
cookies[key] = value;
|
||||
const parts = cookie.split(';')[0]?.split('=');
|
||||
if (parts) {
|
||||
const key = parts[0];
|
||||
const value = parts[1];
|
||||
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 {
|
||||
osName = os.hostname();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
||||
}
|
||||
|
||||
|
@@ -1,17 +1,17 @@
|
||||
//QQVersionType
|
||||
type QQPackageInfoType = {
|
||||
export type QQPackageInfoType = {
|
||||
version: string;
|
||||
buildVersion: string;
|
||||
platform: string;
|
||||
eleArch: string;
|
||||
}
|
||||
type QQVersionConfigType = {
|
||||
export type QQVersionConfigType = {
|
||||
baseVersion: string;
|
||||
curVersion: string;
|
||||
prevVersion: string;
|
||||
onErrorVersions: Array<any>;
|
||||
onErrorVersions: Array<unknown>;
|
||||
buildId: string;
|
||||
}
|
||||
type QQAppidTableType = {
|
||||
export type QQAppidTableType = {
|
||||
[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