Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
813a541e7f | ||
![]() |
efd489bfd4 | ||
![]() |
9c88fcb610 | ||
![]() |
c1cac8de19 | ||
![]() |
b03fa54e63 | ||
![]() |
9785731f25 | ||
![]() |
566afde18b | ||
![]() |
bae8dabc5c | ||
![]() |
0a9dfea20a | ||
![]() |
50498aa52d | ||
![]() |
cc99fa8346 | ||
![]() |
500c10ea7a | ||
![]() |
71a62caf8f | ||
![]() |
fe9c565ad4 | ||
![]() |
f5f915dc91 | ||
![]() |
eba17fd9b4 | ||
![]() |
86f6caa714 | ||
![]() |
8ec5a4d071 | ||
![]() |
eae49667ef | ||
![]() |
9a87b5ec1a | ||
![]() |
ee1291e42c | ||
![]() |
15aa1fd0b8 | ||
![]() |
e069e0e8aa | ||
![]() |
57e72c197f | ||
![]() |
1d0d25eea2 | ||
![]() |
1a6194b38c | ||
![]() |
22057083ce | ||
![]() |
6b041becb0 | ||
![]() |
96d4a91ee9 | ||
![]() |
3069900202 | ||
![]() |
c46fb0f48a | ||
![]() |
07cd8f883e | ||
![]() |
cfdb9d64ad | ||
![]() |
b73e3aa3b7 | ||
![]() |
cd315b0e71 | ||
![]() |
4d4d79e66f | ||
![]() |
395ce97a78 | ||
![]() |
e44e8423d0 | ||
![]() |
fa13a56697 | ||
![]() |
6383164aec | ||
![]() |
d9adfad1c0 |
4
.gitignore
vendored
@@ -4,7 +4,7 @@ package-lock.json
|
||||
out/
|
||||
dist/
|
||||
/src/core.lib/common/
|
||||
/localdebug/
|
||||
devconfig/*
|
||||
|
||||
# Editor
|
||||
!.vscode/extensions.json
|
||||
@@ -13,4 +13,4 @@ dist/
|
||||
# Build
|
||||
*.db
|
||||
checkVersion.sh
|
||||
bun.lockb
|
||||
bun.lockb
|
@@ -53,7 +53,7 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
|
||||
## 感谢他们
|
||||
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
感谢 Tencent Tdesign / Vue3 强力驱动 NapCat.WebUi
|
||||
感谢 React 强力驱动 NapCat.WebUi
|
||||
|
||||
不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.4.1",
|
||||
"version": "4.4.7",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
1
napcat.webui/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
8
napcat.webui/.gitignore
vendored
@@ -22,3 +22,11 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# NPM LOCK files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
|
||||
dist.zip
|
1
napcat.webui/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
public-hoist-pattern[]=*@heroui/*
|
7
napcat.webui/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
*.md
|
||||
*.html
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
23
napcat.webui/.prettierrc
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"importOrder": [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/const/(.*)$",
|
||||
"^@/store/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/contexts/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/utils/(.*)$",
|
||||
"^@/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports"]
|
||||
}
|
3
napcat.webui/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
21
napcat.webui/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 bietiaop
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@@ -1,5 +1,32 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# NapCat WebUI
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
## 功能
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
- WebUI登录
|
||||
- QQ登录
|
||||
- 网络配置
|
||||
- OneBot/WebUI配置
|
||||
- 日志查看(实时日志、历史日志)
|
||||
- HTTP调试
|
||||
- WS调试
|
||||
- 在线音乐播放器,支持网易云音乐歌单(大屏在页面右下角,小屏在页面下方)
|
||||
|
||||
如果你有更多功能需求,欢迎在 issue 中提出。
|
||||
|
||||
# License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
# Related Projects
|
||||
|
||||
- [NapCat](https://github.com/NapNeko/NapCatQQ/)
|
||||
- [Karin](https://github.com/KarinJS/Karin/)
|
||||
|
||||
# Thanks to
|
||||
|
||||
- [Vercel](https://vercel.com/)
|
||||
- [React](https://react.dev/)
|
||||
- [HeroUI](https://nextui.org/)
|
||||
- and more open-source projects
|
||||
|
||||
感谢群友“维拉”提供的在线音乐API。
|
@@ -1,52 +1,91 @@
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import prettier from 'eslint-plugin-prettier/recommended';
|
||||
import eslint_js from '@eslint/js'
|
||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import tsEslintParser from '@typescript-eslint/parser'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import globals from 'globals'
|
||||
|
||||
const customTsFlatConfig = [
|
||||
{
|
||||
name: 'typescript-eslint/base',
|
||||
languageOptions: {
|
||||
parser: tsEslintParser,
|
||||
sourceType: 'module'
|
||||
},
|
||||
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
rules: {
|
||||
...tsEslintPlugin.configs.recommended.rules
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default [
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
eslint_js.configs.recommended,
|
||||
|
||||
eslintPluginPrettierRecommended,
|
||||
|
||||
...customTsFlatConfig,
|
||||
{
|
||||
name: 'global config',
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.es2022,
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parserOptions: {
|
||||
warnOnUnsupportedTypeScriptVersion: false
|
||||
}
|
||||
},
|
||||
...ts.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'warn',
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'no-undef': 'off',
|
||||
//关闭不能再promise中使用ansyc
|
||||
'no-async-promise-executor': 'off',
|
||||
//关闭不能再常量中使用??
|
||||
'no-constant-binary-expression': 'off',
|
||||
'@typescript-eslint/ban-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
//禁止失去精度的字面数字
|
||||
'@typescript-eslint/no-loss-of-precision': 'off',
|
||||
//禁止使用any
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['**/node_modules', '**/dist', '**/output']
|
||||
},
|
||||
{
|
||||
name: 'react-eslint',
|
||||
files: ['src/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin
|
||||
},
|
||||
...vue.configs['flat/base'],
|
||||
{
|
||||
files: ['*.vue', '**/*.vue'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
languageOptions: {
|
||||
...reactPlugin.configs.recommended.languageOptions
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
indent: ['error', 4],
|
||||
semi: ['error', 'always'],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'warn',
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'vue/v-for-delimiter-style': ['error', 'in'],
|
||||
'vue/require-name-property': 'warn',
|
||||
'vue/prefer-true-attribute-shorthand': 'warn',
|
||||
'prefer-arrow-callback': 'warn',
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
},
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
settings: {
|
||||
react: {
|
||||
// 需要显示安装 react
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } }
|
||||
},
|
||||
eslintConfigPrettier
|
||||
]
|
||||
|
@@ -1,13 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./logo_webui.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NapCat WebUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NapCat WebUI</title>
|
||||
<meta key="title" content="NapCat WebUI" property="og:title" />
|
||||
<meta content="NapCat WebUI,基于React+tailwind+NextUI" property="og:description" />
|
||||
<meta content="NapCat WebUI,基于React+tailwind+NextUI" name="description" />
|
||||
<meta key="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
name="viewport" />
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,37 +1,115 @@
|
||||
{
|
||||
"name": "napcat.webui",
|
||||
"name": "napcat-webui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"webui:lint": "eslint --fix src/**/*.{js,ts,vue}",
|
||||
"webui:dev": "vite --host",
|
||||
"webui:build": "vite build",
|
||||
"webui:preview": "vite preview"
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@heroui/avatar": "2.2.7",
|
||||
"@heroui/breadcrumbs": "2.2.7",
|
||||
"@heroui/button": "2.2.10",
|
||||
"@heroui/card": "2.2.10",
|
||||
"@heroui/checkbox": "2.3.9",
|
||||
"@heroui/chip": "2.2.7",
|
||||
"@heroui/code": "2.2.7",
|
||||
"@heroui/dropdown": "2.3.10",
|
||||
"@heroui/form": "2.1.9",
|
||||
"@heroui/image": "2.2.6",
|
||||
"@heroui/input": "2.4.10",
|
||||
"@heroui/kbd": "2.2.7",
|
||||
"@heroui/link": "2.2.8",
|
||||
"@heroui/listbox": "2.3.10",
|
||||
"@heroui/modal": "2.2.8",
|
||||
"@heroui/navbar": "2.2.9",
|
||||
"@heroui/popover": "2.3.10",
|
||||
"@heroui/select": "2.4.10",
|
||||
"@heroui/slider": "2.4.8",
|
||||
"@heroui/snippet": "2.2.11",
|
||||
"@heroui/spinner": "2.2.7",
|
||||
"@heroui/switch": "2.2.9",
|
||||
"@heroui/system": "2.4.7",
|
||||
"@heroui/tabs": "2.2.8",
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@react-aria/visually-hidden": "3.8.18",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@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",
|
||||
"echarts": "^5.5.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"mitt": "^3.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"tdesign-icons-vue-next": "^0.3.3",
|
||||
"tdesign-vue-next": "^1.10.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
"framer-motion": "^11.15.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^11.15.0",
|
||||
"qface": "^1.4.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "7.1.0",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
"tailwind-variants": "0.3.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@react-types/shared": "^3.26.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-legacy": "^5.4.3",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"globals": "^15.12.0",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10",
|
||||
"vue-tsc": "^2.1.8"
|
||||
"@types/fabric": "^5.3.9",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"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-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"ahooks": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"react-window": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
napcat.webui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
BIN
napcat.webui/public/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
napcat.webui/public/fonts/FiraCode-VariableFont_wght.ttf
Normal file
BIN
napcat.webui/public/fonts/NotoSerifSC-VariableFont_wght.ttf
Normal file
BIN
napcat.webui/public/fonts/Outfit-VariableFont_wght.ttf
Normal file
BIN
napcat.webui/public/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf
Executable file
BIN
napcat.webui/public/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf
Executable file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-BoldItalic.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-Italic.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-Light.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-LightItalic.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-Medium.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-MediumItalic.ttf
Normal file
BIN
napcat.webui/public/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
Before Width: | Height: | Size: 335 KiB |
Before Width: | Height: | Size: 201 KiB |
5
napcat.webui/public/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/" }
|
||||
]
|
||||
}
|
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
68
napcat.webui/src/App.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
|
||||
import PageBackground from '@/components/page_background'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import Toaster from '@/components/toaster'
|
||||
|
||||
import DialogProvider from '@/contexts/dialog'
|
||||
import AudioProvider from '@/contexts/songs'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
|
||||
import store from '@/store'
|
||||
|
||||
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
||||
const IndexPage = lazy(() => import('@/pages/index'))
|
||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthChecker({ children }: { children: React.ReactNode }) {
|
||||
const { isAuth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuth) {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const token = search.get('token')
|
||||
let url = '/web_login'
|
||||
|
||||
if (token) {
|
||||
url += `?token=${token}`
|
||||
}
|
||||
navigate(url, { replace: true })
|
||||
}
|
||||
}, [isAuth, navigate])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<IndexPage />} path="/*" />
|
||||
<Route element={<QQLoginPage />} path="/qq_login" />
|
||||
<Route element={<WebLoginPage />} path="/web_login" />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div id="app" theme-mode="dark">
|
||||
<router-view />
|
||||
</div>
|
||||
<div v-if="show">
|
||||
<t-sticky-tool shape="round" placement="right-bottom" :offset="[-50, 10]" @click="changeTheme">
|
||||
<t-sticky-item label="浅色" popup="切换浅色模式">
|
||||
<template #icon><sunny-icon /></template>
|
||||
</t-sticky-item>
|
||||
<t-sticky-item label="深色" popup="切换深色模式">
|
||||
<template #icon><mode-dark-icon /></template>
|
||||
</t-sticky-item>
|
||||
<t-sticky-item label="自动" popup="跟随系统">
|
||||
<template #icon><control-platform-icon /></template>
|
||||
</t-sticky-item>
|
||||
</t-sticky-tool>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ControlPlatformIcon, ModeDarkIcon, SunnyIcon } from 'tdesign-icons-vue-next';
|
||||
const smallScreen = window.matchMedia('(max-width: 768px)');
|
||||
interface Item {
|
||||
label: string;
|
||||
popup: string;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
item: Item;
|
||||
}
|
||||
enum ThemeMode {
|
||||
Dark = 'dark',
|
||||
Light = 'light',
|
||||
Auto = 'auto',
|
||||
}
|
||||
const themeLabelMap: Record<string, ThemeMode> = {
|
||||
"浅色": ThemeMode.Light,
|
||||
"深色": ThemeMode.Dark,
|
||||
"自动": ThemeMode.Auto,
|
||||
};
|
||||
const show = ref<boolean>(true);
|
||||
const createSetThemeAttributeFunction = () => {
|
||||
let mediaQueryForAutoTheme: MediaQueryList | null = null;
|
||||
return (mode: ThemeMode | null) => {
|
||||
const element = document.documentElement;
|
||||
if (mode === ThemeMode.Dark) {
|
||||
element.setAttribute('theme-mode', ThemeMode.Dark);
|
||||
} else if (mode === ThemeMode.Light) {
|
||||
element.removeAttribute('theme-mode');
|
||||
} else if (mode === ThemeMode.Auto) {
|
||||
mediaQueryForAutoTheme = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) {
|
||||
element.setAttribute('theme-mode', ThemeMode.Dark);
|
||||
} else {
|
||||
element.removeAttribute('theme-mode');
|
||||
}
|
||||
};
|
||||
mediaQueryForAutoTheme.addEventListener('change', handleMediaChange);
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'matches', {
|
||||
value: mediaQueryForAutoTheme.matches,
|
||||
writable: false,
|
||||
});
|
||||
mediaQueryForAutoTheme.dispatchEvent(event);
|
||||
onBeforeUnmount(() => {
|
||||
if (mediaQueryForAutoTheme) {
|
||||
mediaQueryForAutoTheme.removeEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const setThemeAttribute = createSetThemeAttributeFunction();
|
||||
|
||||
const getStoredTheme = (): ThemeMode | null => {
|
||||
return localStorage.getItem('theme') as ThemeMode | null;
|
||||
};
|
||||
|
||||
const initTheme = () => {
|
||||
const storedTheme = getStoredTheme();
|
||||
if (storedTheme === null) {
|
||||
setThemeAttribute(ThemeMode.Auto);
|
||||
} else {
|
||||
setThemeAttribute(storedTheme);
|
||||
}
|
||||
};
|
||||
|
||||
const changeTheme = (context: Context) => {
|
||||
const themeLabel = themeLabelMap[context.item.label] as ThemeMode;
|
||||
console.log(themeLabel);
|
||||
setThemeAttribute(themeLabel);
|
||||
localStorage.setItem('theme', themeLabel);
|
||||
};
|
||||
const haddingFbars = () => {
|
||||
show.value = !smallScreen.matches;
|
||||
if (smallScreen.matches) {
|
||||
localStorage.setItem('theme', 'auto');
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
initTheme();
|
||||
haddingFbars();
|
||||
window.addEventListener('resize', haddingFbars);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', haddingFbars);
|
||||
});
|
||||
</script>
|
||||
<style></style>
|
After Width: | Height: | Size: 133 KiB |
BIN
napcat.webui/src/assets/images/bkg-color.png
Normal file
After Width: | Height: | Size: 123 KiB |
BIN
napcat.webui/src/assets/images/logo.png
Normal file
After Width: | Height: | Size: 684 KiB |
Before Width: | Height: | Size: 335 KiB |
Before Width: | Height: | Size: 201 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
Before Width: | Height: | Size: 496 B |
@@ -1,66 +0,0 @@
|
||||
export class githubApiManager {
|
||||
public async GetBaseData(): Promise<Response | null> {
|
||||
try {
|
||||
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
return await ConfigResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting github data :', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public async GetReleasesData(): Promise<Response | null> {
|
||||
try {
|
||||
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/releases', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
return await ConfigResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting releases data:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public async GetPullsData(): Promise<Response | null> {
|
||||
try {
|
||||
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/pulls', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
return await ConfigResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting Pulls data:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public async GetContributors(): Promise<Response | null> {
|
||||
try {
|
||||
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/contributors', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
return await ConfigResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting Pulls data:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@@ -1,72 +0,0 @@
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
type LogListItem = string;
|
||||
type LogListData = LogListItem[];
|
||||
let eventSourcePoly: EventSourcePolyfill | null = null;
|
||||
export class LogManager {
|
||||
private readonly retCredential: string;
|
||||
private readonly apiPrefix: string;
|
||||
|
||||
//调试时http://127.0.0.1:6099/api 打包时 ../api
|
||||
constructor(retCredential: string, apiPrefix: string = '../api') {
|
||||
this.retCredential = retCredential;
|
||||
this.apiPrefix = apiPrefix;
|
||||
}
|
||||
public async GetLogList(): Promise<LogListData> {
|
||||
try {
|
||||
const ConfigResponse = await fetch(`${this.apiPrefix}/Log/GetLogList`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
const ConfigResponseJson = await ConfigResponse.json();
|
||||
if (ConfigResponseJson.code == 0) {
|
||||
return ConfigResponseJson?.data as LogListData;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting LogList:', error);
|
||||
}
|
||||
return [] as LogListData;
|
||||
}
|
||||
public async GetLog(FileName: string): Promise<string> {
|
||||
try {
|
||||
const ConfigResponse = await fetch(`${this.apiPrefix}/Log/GetLog?id=${FileName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
const ConfigResponseJson = await ConfigResponse.json();
|
||||
if (ConfigResponseJson.code == 0) {
|
||||
return ConfigResponseJson?.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting LogData:', error);
|
||||
}
|
||||
return 'null';
|
||||
}
|
||||
public async getRealTimeLogs(): Promise<EventSourcePolyfill | null> {
|
||||
this.creatEventSource();
|
||||
return eventSourcePoly;
|
||||
}
|
||||
private creatEventSource() {
|
||||
try {
|
||||
eventSourcePoly = new EventSourcePolyfill(`${this.apiPrefix}/Log/GetLogRealTime`, {
|
||||
heartbeatTimeout: 3 * 60 * 1000,
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建SSE连接出错:', error);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,205 +0,0 @@
|
||||
import { OneBotConfig } from '../../../src/onebot/config/config';
|
||||
import { ResponseCode } from '../../../src/webui/src/const/status';
|
||||
export class QQLoginManager {
|
||||
private retCredential: string;
|
||||
private readonly apiPrefix: string;
|
||||
|
||||
//调试时http://127.0.0.1:6099/api 打包时 ../api
|
||||
constructor(retCredential: string, apiPrefix: string = '../api') {
|
||||
this.retCredential = retCredential;
|
||||
this.apiPrefix = apiPrefix;
|
||||
}
|
||||
|
||||
// TODO:
|
||||
public async GetOB11Config(): Promise<OneBotConfig> {
|
||||
try {
|
||||
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/GetConfig`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
const ConfigResponseJson = await ConfigResponse.json();
|
||||
if (ConfigResponseJson.code == ResponseCode.Success) {
|
||||
return ConfigResponseJson.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting OB11 config:', error);
|
||||
}
|
||||
return {} as OneBotConfig;
|
||||
}
|
||||
|
||||
public async SetOB11Config(config: OneBotConfig): Promise<boolean> {
|
||||
try {
|
||||
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/SetConfig`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ config: JSON.stringify(config) }),
|
||||
});
|
||||
if (ConfigResponse.status == 200) {
|
||||
const ConfigResponseJson = await ConfigResponse.json();
|
||||
if (ConfigResponseJson.code == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting OB11 config:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async checkQQLoginStatus(): Promise<boolean> {
|
||||
try {
|
||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (QQLoginResponse.status == 200) {
|
||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
||||
if (QQLoginResponseJson.code == 0) {
|
||||
return QQLoginResponseJson.data.isLogin;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking QQ login status:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public async checkQQLoginStatusWithQrcode(): Promise<{ qrcodeurl: string; isLogin: string } | undefined> {
|
||||
try {
|
||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (QQLoginResponse.status == 200) {
|
||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
||||
if (QQLoginResponseJson.code == 0) {
|
||||
return QQLoginResponseJson.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking QQ login status:', error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async checkWebUiLogined(): Promise<boolean> {
|
||||
try {
|
||||
const LoginResponse = await fetch(`${this.apiPrefix}/auth/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (LoginResponse.status == 200) {
|
||||
const LoginResponseJson = await LoginResponse.json();
|
||||
if (LoginResponseJson.code == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking web UI login status:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async loginWithToken(token: string): Promise<string | null> {
|
||||
try {
|
||||
const loginResponse = await fetch(`${this.apiPrefix}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: token }),
|
||||
});
|
||||
const loginResponseJson = await loginResponse.json();
|
||||
const retCode = loginResponseJson.code;
|
||||
if (retCode === 0) {
|
||||
this.retCredential = loginResponseJson.data.Credential;
|
||||
return this.retCredential;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging in with token:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getQQLoginQrcode(): Promise<string> {
|
||||
try {
|
||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQQLoginQrcode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (QQLoginResponse.status == 200) {
|
||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
||||
if (QQLoginResponseJson.code == 0) {
|
||||
return QQLoginResponseJson.data.qrcode || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting QQ login QR code:', error);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public async getQQQuickLoginList(): Promise<string[]> {
|
||||
try {
|
||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQuickLoginList`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (QQLoginResponse.status == 200) {
|
||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
||||
if (QQLoginResponseJson.code == 0) {
|
||||
return QQLoginResponseJson.data || [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting QQ quick login list:', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public async setQuickLogin(uin: string): Promise<{ result: boolean; errMsg: string }> {
|
||||
try {
|
||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/SetQuickLogin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + this.retCredential,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ uin: uin }),
|
||||
});
|
||||
if (QQLoginResponse.status == 200) {
|
||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
||||
if (QQLoginResponseJson.code == 0) {
|
||||
return { result: true, errMsg: '' };
|
||||
} else {
|
||||
return { result: false, errMsg: QQLoginResponseJson.message };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting quick login:', error);
|
||||
}
|
||||
return { result: false, errMsg: '接口异常' };
|
||||
}
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<t-layout class="dashboard-container">
|
||||
<div v-if="!mediaQuery.matches">
|
||||
<SidebarMenu
|
||||
:menu-items="menuItems"
|
||||
class="sidebar-menu"
|
||||
:menu-width="sidebarWidth"
|
||||
/>
|
||||
</div>
|
||||
<t-layout>
|
||||
<router-view />
|
||||
</t-layout>
|
||||
<div v-if="mediaQuery.matches" class="bottom-menu">
|
||||
<BottomMenu :menu-items="menuItems" />
|
||||
</div>
|
||||
</t-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import SidebarMenu from './webui/Nav.vue';
|
||||
import BottomMenu from './webui/NavBottom.vue';
|
||||
import emitter from '@/ts/event-bus';
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const sidebarWidth = ['232px', '64px'];
|
||||
interface MenuItem {
|
||||
value: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ value: 'item1', icon: 'dashboard', label: '基础信息', route: '/dashboard/basic-info' },
|
||||
{ value: 'item3', icon: 'wifi-1', label: '网络配置', route: '/dashboard/network-config' },
|
||||
{ value: 'item4', icon: 'setting', label: '其余配置', route: '/dashboard/other-config' },
|
||||
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
|
||||
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
|
||||
]);
|
||||
emitter.on('sendMenu', (event) => {
|
||||
const menuWidth = event ? sidebarWidth[1] : sidebarWidth[0];
|
||||
emitter.emit('sendWidth', menuWidth);
|
||||
localStorage.setItem('menuWidth', menuWidth.toString() || '0');
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (mediaQuery.matches){
|
||||
localStorage.setItem('menuWidth', '0');
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.bottom-menu {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
.t-head-menu__inner .t-menu:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.t-head-menu__inner{
|
||||
width: 100%;
|
||||
}
|
||||
.t-head-menu .t-menu{
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
.t-menu__content{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,187 +0,0 @@
|
||||
<template>
|
||||
<t-card class="layout" :bordered="false">
|
||||
<div class="login-container">
|
||||
<h2 class="sotheby-font">QQ Login</h2>
|
||||
<div class="login-methods">
|
||||
<t-tooltip content="快速登录">
|
||||
<t-button id="quick-login" class="login-method" :class="{ active: loginMethod === 'quick' }"
|
||||
@click="loginMethod = 'quick'">Quick Login</t-button>
|
||||
</t-tooltip>
|
||||
<t-tooltip content="二维码登录">
|
||||
<t-button id="qrcode-login" class="login-method" :class="{ active: loginMethod === 'qrcode' }"
|
||||
@click="loginMethod = 'qrcode'">QR Code</t-button>
|
||||
</t-tooltip>
|
||||
</div>
|
||||
<div v-show="loginMethod === 'quick'" id="quick-login-dropdown" class="login-form">
|
||||
<t-select id="quick-login-select" v-model="selectedAccount" placeholder="Select Account"
|
||||
@change="selectAccount">
|
||||
<t-option v-for="account in quickLoginList" :key="account" :value="account">{{ account }}</t-option>
|
||||
</t-select>
|
||||
</div>
|
||||
<div v-show="loginMethod === 'qrcode'" id="qrcode" class="qrcode">
|
||||
<canvas ref="qrcodeCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
|
||||
</t-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { QQLoginManager } from '@/backend/shell';
|
||||
|
||||
const router = useRouter();
|
||||
const loginMethod = ref<'quick' | 'qrcode'>('quick');
|
||||
const quickLoginList = ref<string[]>([]);
|
||||
const selectedAccount = ref<string>('');
|
||||
const qrcodeCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const qqLoginManager = new QQLoginManager(localStorage.getItem('auth') || '');
|
||||
let heartBeatTimer: number | null = null;
|
||||
let qrcodeUrl: string = '';
|
||||
|
||||
const selectAccount = async (accountName: string): Promise<void> => {
|
||||
const { result, errMsg } = await qqLoginManager.setQuickLogin(accountName);
|
||||
if (result) {
|
||||
if (heartBeatTimer) {
|
||||
clearInterval(heartBeatTimer);
|
||||
}
|
||||
await MessagePlugin.success('登录成功即将跳转');
|
||||
await router.push({ path: '/dashboard/basic-info' });
|
||||
} else {
|
||||
await MessagePlugin.error('登录失败,' + errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const generateQrCode = (data: string, canvas: HTMLCanvasElement | null): void => {
|
||||
if (!canvas) {
|
||||
console.error('Canvas element not found');
|
||||
return;
|
||||
}
|
||||
QRCode.toCanvas(canvas, data, function (error: Error | null | undefined) {
|
||||
if (error) {
|
||||
console.error('Error generating QR Code:', error);
|
||||
} else {
|
||||
console.log('QR Code generated!');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const HeartBeat = async (): Promise<void> => {
|
||||
const isLogined = await qqLoginManager.checkQQLoginStatusWithQrcode();
|
||||
if (isLogined?.isLogin) {
|
||||
if (heartBeatTimer) {
|
||||
clearInterval(heartBeatTimer);
|
||||
}
|
||||
await MessagePlugin.success('登录成功即将跳转');
|
||||
await router.push({ path: '/dashboard/basic-info' });
|
||||
} else if (isLogined?.qrcodeurl && qrcodeUrl !== isLogined.qrcodeurl) {
|
||||
qrcodeUrl = isLogined.qrcodeurl;
|
||||
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
|
||||
}
|
||||
};
|
||||
|
||||
const InitPages = async (): Promise<void> => {
|
||||
quickLoginList.value = await qqLoginManager.getQQQuickLoginList();
|
||||
qrcodeUrl = await qqLoginManager.getQQLoginQrcode();
|
||||
await nextTick();
|
||||
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
InitPages().then().catch((err) => {
|
||||
console.error('InitPages Error:', err);
|
||||
});
|
||||
heartBeatTimer = window.setInterval(HeartBeat, 3000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (heartBeatTimer) {
|
||||
clearInterval(heartBeatTimer);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
watch(loginMethod, async (newMethod) => {
|
||||
if (newMethod === 'qrcode') {
|
||||
await nextTick();
|
||||
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
max-width: 400px;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-container {
|
||||
width: 90%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.login-methods {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-method {
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.login-method.active {
|
||||
background-color: #e6f0ff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.login-form,
|
||||
.qrcode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qrcode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sotheby-font {
|
||||
font-family: Sotheby, Helvetica, monospace;
|
||||
font-size: 3.125rem;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<t-card class="layout" :bordered="false">
|
||||
<div class="login-container">
|
||||
<h2 class="sotheby-font">WebUi Login</h2>
|
||||
<t-form ref="form" :data="formData" colon :label-width="0" @submit="onSubmit">
|
||||
<t-form-item name="password">
|
||||
<t-input v-model="formData.token" type="password" clearable placeholder="请输入Token">
|
||||
<template #prefix-icon>
|
||||
<lock-on-icon />
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-button theme="primary" type="submit" block>登录</t-button>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</div>
|
||||
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
|
||||
</t-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '../css/style.css';
|
||||
import '../css/font.css';
|
||||
import { reactive, onMounted } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { LockOnIcon } from 'tdesign-icons-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { QQLoginManager } from '@/backend/shell';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
interface FormData {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const formData: FormData = reactive({
|
||||
token: '',
|
||||
});
|
||||
|
||||
const handleLoginSuccess = async (credential: string) => {
|
||||
localStorage.setItem('auth', credential);
|
||||
await checkLoginStatus();
|
||||
};
|
||||
|
||||
const handleLoginFailure = (message: string) => {
|
||||
MessagePlugin.error(message);
|
||||
};
|
||||
|
||||
const checkLoginStatus = async () => {
|
||||
const storedCredential = localStorage.getItem('auth');
|
||||
if (!storedCredential) {
|
||||
return;
|
||||
}
|
||||
const loginManager = new QQLoginManager(storedCredential);
|
||||
const isWenUiLoggedIn = await loginManager.checkWebUiLogined();
|
||||
console.log('isWenUiLoggedIn', isWenUiLoggedIn);
|
||||
if (!isWenUiLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isQQLoggedIn = await loginManager.checkQQLoginStatus();
|
||||
if (isQQLoggedIn) {
|
||||
await router.push({ path: '/dashboard/basic-info' });
|
||||
} else {
|
||||
await router.push({ path: '/qqlogin' });
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithToken = async (token: string) => {
|
||||
const loginManager = new QQLoginManager('');
|
||||
const credential = await loginManager.loginWithToken(token);
|
||||
if (credential) {
|
||||
await handleLoginSuccess(credential);
|
||||
} else {
|
||||
handleLoginFailure('登录失败,请检查Token');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get('token');
|
||||
if (token) {
|
||||
loginWithToken(token);
|
||||
}
|
||||
checkLoginStatus();
|
||||
});
|
||||
|
||||
const onSubmit = async ({ validateResult }: { validateResult: boolean }) => {
|
||||
if (validateResult) {
|
||||
await loginWithToken(formData.token);
|
||||
} else {
|
||||
handleLoginFailure('请填写Token');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100vh;
|
||||
}
|
||||
.login-container {
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
max-width: 400px;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-container {
|
||||
width: 90%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tdesign-demo-block-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.tdesign-demo-block-column-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 32px;
|
||||
}
|
||||
|
||||
.tdesign-demo-block-row {
|
||||
display: flex;
|
||||
column-gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sotheby-font {
|
||||
font-family: Sotheby, Helvetica, monospace;
|
||||
font-size: 3.125rem;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
425
napcat.webui/src/components/audio_player.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Slider } from '@heroui/slider'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
BiSolidSkipNextCircle,
|
||||
BiSolidSkipPreviousCircle
|
||||
} from 'react-icons/bi'
|
||||
import {
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaRegHandPointRight,
|
||||
FaRepeat,
|
||||
FaShuffle
|
||||
} from 'react-icons/fa6'
|
||||
import { TbRepeatOnce } from 'react-icons/tb'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import { PlayMode } from '@/const/enum'
|
||||
import key from '@/const/key'
|
||||
|
||||
import { VolumeHighIcon, VolumeLowIcon } from './icons'
|
||||
|
||||
export interface AudioPlayerProps
|
||||
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||
src: string
|
||||
title?: string
|
||||
artist?: string
|
||||
cover?: string
|
||||
pressNext?: () => void
|
||||
pressPrevious?: () => void
|
||||
onPlayEnd?: () => void
|
||||
onChangeMode?: (mode: PlayMode) => void
|
||||
mode?: PlayMode
|
||||
}
|
||||
|
||||
export default function AudioPlayer(props: AudioPlayerProps) {
|
||||
const {
|
||||
src,
|
||||
pressNext,
|
||||
pressPrevious,
|
||||
cover = 'https://nextui.org/images/album-cover.png',
|
||||
title = '未知',
|
||||
artist = '未知',
|
||||
onTimeUpdate,
|
||||
onLoadedData,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlayEnd,
|
||||
onChangeMode,
|
||||
autoPlay,
|
||||
mode = PlayMode.Loop,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [volume, setVolume] = useState(100)
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||
key.isCollapsedMusicPlayer,
|
||||
false
|
||||
)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const startY = useRef(0)
|
||||
const startX = useRef(0)
|
||||
const [translateY, setTranslateY] = useState(0)
|
||||
const [translateX, setTranslateX] = useState(0)
|
||||
const isSmallScreen = useMediaQuery({ maxWidth: 767 })
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
const shouldAdd = useRef(false)
|
||||
const currentProgress = (currentTime / duration) * 100
|
||||
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||
key.autoPlay,
|
||||
true
|
||||
)
|
||||
|
||||
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement
|
||||
setCurrentTime(audio.currentTime)
|
||||
onTimeUpdate?.(event)
|
||||
}
|
||||
|
||||
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement
|
||||
setDuration(audio.duration)
|
||||
onLoadedData?.(event)
|
||||
}
|
||||
|
||||
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(true)
|
||||
setStorageAutoPlay(true)
|
||||
onPlay?.(e)
|
||||
}
|
||||
|
||||
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(false)
|
||||
onPause?.(e)
|
||||
}
|
||||
|
||||
const changeMode = () => {
|
||||
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single]
|
||||
const currentIndex = modes.findIndex((_mode) => _mode === mode)
|
||||
const nextIndex = currentIndex + 1
|
||||
const nextMode = modes[nextIndex] || modes[0]
|
||||
onChangeMode?.(nextMode)
|
||||
}
|
||||
|
||||
const volumeChange = (value: number) => {
|
||||
setVolume(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.volume = volume / 100
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY
|
||||
startX.current = e.touches[0].clientX
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
const deltaY = e.touches[0].clientY - startY.current
|
||||
const deltaX = e.touches[0].clientX - startX.current
|
||||
const container = cardRef.current
|
||||
const header = cardRef.current?.querySelector('[data-header]')
|
||||
const headerHeight = header?.clientHeight || 20
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
|
||||
const _shouldAdd = isCollapsed && deltaY < 0
|
||||
if (isSmallScreen) {
|
||||
shouldAdd.current = _shouldAdd
|
||||
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY)
|
||||
} else {
|
||||
setTranslateX(deltaX)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSmallScreen) {
|
||||
const container = cardRef.current
|
||||
const header = cardRef.current?.querySelector('[data-header]')
|
||||
const headerHeight = header?.clientHeight || 20
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
|
||||
const _translateY = translateY - (shouldAdd.current ? addHeight : 0)
|
||||
if (_translateY > 100) {
|
||||
setIsCollapsed(true)
|
||||
} else if (_translateY < -100) {
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
setTranslateY(0)
|
||||
} else {
|
||||
if (translateX > 100) {
|
||||
setIsCollapsed(true)
|
||||
} else if (translateX < -100) {
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
setTranslateX(0)
|
||||
}
|
||||
}
|
||||
|
||||
const dragTranslate = isSmallScreen
|
||||
? translateY
|
||||
? `translateY(${translateY}px)`
|
||||
: ''
|
||||
: translateX
|
||||
? `translateX(${translateX}px)`
|
||||
: ''
|
||||
const collapsedTranslate = isCollapsed
|
||||
? isSmallScreen
|
||||
? 'translateY(90%)'
|
||||
: 'translateX(96%)'
|
||||
: ''
|
||||
|
||||
const translateStyle = dragTranslate || collapsedTranslate
|
||||
|
||||
if (!src) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[9999] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
style={{
|
||||
transform: translateStyle
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={src}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={onPlayEnd}
|
||||
autoPlay={autoPlay ?? storageAutoPlay}
|
||||
{...rest}
|
||||
controls={false}
|
||||
hidden
|
||||
ref={audioRef}
|
||||
/>
|
||||
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||
)}
|
||||
classNames={{
|
||||
body: 'p-0'
|
||||
}}
|
||||
shadow="sm"
|
||||
radius="none"
|
||||
>
|
||||
{isMediumUp && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||
isCollapsed
|
||||
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||
)}
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<FaRegHandPointRight />
|
||||
</Button>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<CardHeader
|
||||
data-header
|
||||
className="flex-row justify-center pt-4"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className="w-24 h-2 rounded-full bg-content2-foreground shadow-sm"></div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0">
|
||||
<div className="relative col-span-6 md:col-span-4 flex justify-center">
|
||||
<Image
|
||||
alt="Album cover"
|
||||
className="object-cover"
|
||||
classNames={{
|
||||
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||
img: 'block w-full h-full'
|
||||
}}
|
||||
shadow="md"
|
||||
src={cover}
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col col-span-6 md:col-span-8">
|
||||
<div className="flex flex-col gap-0">
|
||||
<h1 className="font-medium truncate">{title}</h1>
|
||||
<p className="text-xs text-foreground/80 truncate">{artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Slider
|
||||
aria-label="Music progress"
|
||||
classNames={{
|
||||
track: 'bg-default-500/30 border-none',
|
||||
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||
filler: 'rounded-full'
|
||||
}}
|
||||
color="foreground"
|
||||
value={currentProgress || 0}
|
||||
defaultValue={0}
|
||||
size="sm"
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.currentTime = (value / 100) * duration
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between h-3">
|
||||
<p className="text-xs">
|
||||
{Math.floor(currentTime / 60)}:
|
||||
{Math.floor(currentTime % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
{Math.floor(duration / 60)}:
|
||||
{Math.floor(duration % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Tooltip
|
||||
content={
|
||||
mode === PlayMode.Loop
|
||||
? '列表循环'
|
||||
: mode === PlayMode.Random
|
||||
? '随机播放'
|
||||
: '单曲循环'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-lg md:text-medium"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={changeMode}
|
||||
>
|
||||
{mode === PlayMode.Loop && (
|
||||
<FaRepeat className="text-foreground/80" />
|
||||
)}
|
||||
{mode === PlayMode.Random && (
|
||||
<FaShuffle className="text-foreground/80" />
|
||||
)}
|
||||
{mode === PlayMode.Single && (
|
||||
<TbRepeatOnce className="text-foreground/80 text-xl" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="上一首">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={pressPrevious}
|
||||
>
|
||||
<BiSolidSkipPreviousCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-3xl md:text-3xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause()
|
||||
setStorageAutoPlay(false)
|
||||
} else {
|
||||
audioRef.current?.play()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <FaPause /> : <FaPlay className="ml-1" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="下一首">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={pressNext}
|
||||
>
|
||||
<BiSolidSkipNextCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
placement="top"
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md'
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
>
|
||||
<VolumeHighIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
showTooltip
|
||||
aria-label="Volume"
|
||||
className="h-40"
|
||||
color="primary"
|
||||
defaultValue={volume}
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value
|
||||
volumeChange(value)
|
||||
}}
|
||||
startContent={<VolumeHighIcon className="text-2xl" />}
|
||||
size="sm"
|
||||
endContent={<VolumeLowIcon className="text-2xl" />}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
208
napcat.webui/src/components/button/add_button.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger
|
||||
} from '@heroui/dropdown'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { FaRegCircleQuestion } from 'react-icons/fa6'
|
||||
import { IoAddCircleOutline } from 'react-icons/io5'
|
||||
|
||||
import {
|
||||
HTTPClientIcon,
|
||||
HTTPServerIcon,
|
||||
PCIcon,
|
||||
PlusIcon,
|
||||
WebsocketIcon
|
||||
} from '../icons'
|
||||
|
||||
export interface AddButtonProps {
|
||||
onOpen: (key: keyof OneBotConfig['network']) => void
|
||||
}
|
||||
|
||||
const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
const { onOpen } = props
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md'
|
||||
}}
|
||||
placement="right"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
color="danger"
|
||||
startContent={<IoAddCircleOutline className="text-2xl" />}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Create Network Config"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onAction={(key) => {
|
||||
onOpen(key as keyof OneBotConfig['network'])
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key="title"
|
||||
isReadOnly
|
||||
className="cursor-default hover:!bg-transparent"
|
||||
textValue="title"
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<div className="w-5 h-5 -ml-3">
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className="text-primary-400">新建网络配置</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="httpServers"
|
||||
textValue="httpServers"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
HTTP服务器
|
||||
<Tooltip
|
||||
content="「由NapCat建立」一个HTTP服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="httpSseServers"
|
||||
textValue="httpSseServers"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
HTTP SSE服务器
|
||||
<Tooltip
|
||||
content="「由NapCat建立」一个HTTP SSE服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="httpClients"
|
||||
textValue="httpClients"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<HTTPClientIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
HTTP客户端
|
||||
<Tooltip
|
||||
content="「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端,通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的,NapCat会主动连接它。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="websocketServers"
|
||||
textValue="websocketServers"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<WebsocketIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
Websocket服务器
|
||||
<Tooltip
|
||||
content="「由NapCat建立」一个WebSocket服务器,你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址,你或者你的框架应该连接到这个地址。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="websocketClients"
|
||||
textValue="websocketClients"
|
||||
startContent={
|
||||
<div className="w-6 h-6">
|
||||
<PCIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
Websocket客户端
|
||||
<Tooltip
|
||||
content="「由框架或者你自己建立」的WebSocket,通常框架会「提供」一个ws地址,NapCat会主动连接它。"
|
||||
showArrow
|
||||
className="max-w-64"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="w-4 h-4 min-w-0"
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddButton
|
49
napcat.webui/src/components/button/save_buttons.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
onSubmit,
|
||||
reset,
|
||||
isSubmitting,
|
||||
refresh
|
||||
}) => (
|
||||
<div className="max-w-full mx-3 w-96 flex flex-col justify-center gap-3">
|
||||
<div className="flex items-center justify-center gap-2 mt-5">
|
||||
<Button
|
||||
color="default"
|
||||
onPress={() => {
|
||||
reset()
|
||||
toast.success('重置成功')
|
||||
}}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default SaveButtons
|
@@ -0,0 +1,254 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaMicrophone } from 'react-icons/fa6'
|
||||
import { IoMic } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const AudioInsert = () => {
|
||||
const [audioUrl, setAudioUrl] = useState<string>('')
|
||||
const audioInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showAudioSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'record',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const [recordingTime, setRecordingTime] = useState(0)
|
||||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
streamRef.current = stream
|
||||
const recorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = recorder
|
||||
recorder.start()
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
recorder.onstop = () => {
|
||||
if (audioChunksRef.current.length > 0) {
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: 'audio/wav'
|
||||
})
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(audioBlob)
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result as string
|
||||
setAudioPreview(base64Audio)
|
||||
setShowPreview(true)
|
||||
}
|
||||
audioChunksRef.current = []
|
||||
}
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
})
|
||||
recordingIntervalRef.current = setInterval(() => {
|
||||
setRecordingTime((prevTime) => prevTime + 1)
|
||||
}, 1000)
|
||||
} else {
|
||||
mediaRecorderRef.current?.stop()
|
||||
if (recordingIntervalRef.current) {
|
||||
clearInterval(recordingIntervalRef.current)
|
||||
recordingIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
const startRecording = () => {
|
||||
setAudioPreview(null)
|
||||
setShowPreview(false)
|
||||
setRecordingTime(0)
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
const handleShowPreview = () => {
|
||||
if (audioPreview) {
|
||||
showAudioSegment(audioPreview)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = time % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送音频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<IoMic className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传音频">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
audioInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入音频地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入音频地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={audioUrl}
|
||||
onChange={(e) => setAudioUrl(e.target.value)}
|
||||
placeholder="请输入音频地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(audioUrl)) {
|
||||
toast.error('请输入正确的音频地址')
|
||||
return
|
||||
}
|
||||
showAudioSegment(audioUrl)
|
||||
setAudioUrl('')
|
||||
}}
|
||||
>
|
||||
<FaMicrophone />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<Tooltip content="录制音频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<IoMic />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-col gap-2 p-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color={isRecording ? 'danger' : 'danger'}
|
||||
variant="flat"
|
||||
onPress={isRecording ? stopRecording : startRecording}
|
||||
>
|
||||
{isRecording ? '停止录制' : '开始录制'}
|
||||
</Button>
|
||||
{showPreview && audioPreview && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={handleShowPreview}
|
||||
>
|
||||
查看消息
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isRecording || audioPreview) && (
|
||||
<div className="flex gap-1 items-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full',
|
||||
isRecording
|
||||
? 'animate-pulse bg-danger-400'
|
||||
: 'bg-success-400'
|
||||
)}
|
||||
></span>
|
||||
<span>录制时长: {formatTime(recordingTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && audioPreview && (
|
||||
<audio controls src={audioPreview} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
hidden
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showAudioSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioInsert
|
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { BsDice3Fill } from 'react-icons/bs'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const DiceInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送骰子">
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'dice'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<BsDice3Fill className="text-lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiceInsert
|
@@ -0,0 +1,83 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { data, getUrl } from 'qface'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MdEmojiEmotions } from 'react-icons/md'
|
||||
|
||||
import { EmojiValue } from '../formats/emoji_blot'
|
||||
|
||||
const emojis = data.map((item) => {
|
||||
return {
|
||||
alt: item.QDes,
|
||||
src: getUrl(item.QSid),
|
||||
id: item.QSid
|
||||
} as EmojiValue
|
||||
})
|
||||
|
||||
export interface EmojiPickerProps {
|
||||
onInsertEmoji: (emoji: EmojiValue) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
|
||||
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([])
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setVisibleEmojis([]) // Reset visible emojis
|
||||
requestAnimationFrame(() => loadEmojis()) // Start loading emojis
|
||||
}
|
||||
}, [isPopoverOpen])
|
||||
|
||||
const loadEmojis = (index = 0, batchSize = 10) => {
|
||||
if (index < emojis.length) {
|
||||
setVisibleEmojis((prev) => [
|
||||
...prev,
|
||||
...emojis.slice(index, index + batchSize)
|
||||
])
|
||||
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize))
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Popover
|
||||
portalContainer={containerRef.current!}
|
||||
shouldCloseOnScroll={false}
|
||||
placement="right-start"
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
setIsPopoverOpen(v)
|
||||
}}
|
||||
>
|
||||
<Tooltip content="插入表情">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<MdEmojiEmotions className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="grid grid-cols-8 gap-1 flex-wrap justify-start items-start overflow-y-auto max-w-full max-h-96 p-2">
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<Button
|
||||
key={emoji.id}
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => onInsertEmoji(emoji)}
|
||||
>
|
||||
<Image src={emoji.src} alt={emoji.alt} className="w-6 h-6" />
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmojiPicker
|
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaFolder } from 'react-icons/fa6'
|
||||
import { LuFilePlus2 } from 'react-icons/lu'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const FileInsert = () => {
|
||||
const [fileUrl, setFileUrl] = useState<string>('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showFileSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送文件">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<FaFolder className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传文件">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
fileInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入文件地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入文件地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder="请输入文件地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(fileUrl)) {
|
||||
toast.error('请输入正确的文件地址')
|
||||
return
|
||||
}
|
||||
showFileSegment(fileUrl)
|
||||
setFileUrl('')
|
||||
}}
|
||||
>
|
||||
<LuFilePlus2 />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showFileSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInsert
|
@@ -0,0 +1,114 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
export interface ImageInsertProps {
|
||||
insertImage: (url: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
||||
const [imgUrl, setImgUrl] = useState<string>('')
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<Tooltip content="插入图片">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<MdImage className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传图片">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
imageInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入图片地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入图片地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={imgUrl}
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
placeholder="请输入图片地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(imgUrl)) {
|
||||
toast.error('请输入正确的图片地址')
|
||||
return
|
||||
}
|
||||
insertImage(imgUrl)
|
||||
setImgUrl('')
|
||||
}}
|
||||
>
|
||||
<MdAddPhotoAlternate />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={imageInputRef}
|
||||
hidden
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
insertImage(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInsert
|
@@ -0,0 +1,256 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Form } from '@heroui/form'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import type { SharedSelection } from '@heroui/system'
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import type { Key } from '@react-types/shared'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoMusicalNotes } from 'react-icons/io5'
|
||||
import { TbMusicPlus } from 'react-icons/tb'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type {
|
||||
CustomMusicSegment,
|
||||
MusicSegment,
|
||||
OB11Segment
|
||||
} from '@/types/onebot'
|
||||
|
||||
type MusicData = CustomMusicSegment['data'] | MusicSegment['data']
|
||||
|
||||
const MusicInsert = () => {
|
||||
const [musicId, setMusicId] = useState<string>('')
|
||||
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']))
|
||||
const [mode, setMode] = useState<Key>('default')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { control, handleSubmit, reset } = useForm<
|
||||
Omit<CustomMusicSegment['data'], 'type'>
|
||||
>({
|
||||
defaultValues: {
|
||||
url: '',
|
||||
audio: '',
|
||||
title: '',
|
||||
image: '',
|
||||
content: ''
|
||||
}
|
||||
})
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
const showMusicSegment = (data: MusicData) => {
|
||||
const messages: OB11Segment[] = []
|
||||
if (data.type === 'custom') {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data: {
|
||||
...data,
|
||||
type: 'custom'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data
|
||||
})
|
||||
}
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
|
||||
const onSubmit = (data: Omit<CustomMusicSegment['data'], 'type'>) => {
|
||||
showMusicSegment({
|
||||
type: 'custom',
|
||||
...data
|
||||
})
|
||||
reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="overflow-visible">
|
||||
<Popover
|
||||
placement="right-start"
|
||||
shouldCloseOnScroll={false}
|
||||
portalContainer={containerRef.current!}
|
||||
>
|
||||
<Tooltip content="发送音乐">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<IoMusicalNotes className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="gap-2 p-4">
|
||||
<Tabs
|
||||
placement="top"
|
||||
className="w-96"
|
||||
fullWidth
|
||||
selectedKey={mode}
|
||||
onSelectionChange={setMode}
|
||||
>
|
||||
<Tab title="主流平台" key="default" className="flex flex-col gap-2">
|
||||
<Select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="音乐平台"
|
||||
selectedKeys={musicType}
|
||||
label="音乐平台"
|
||||
placeholder="请选择音乐平台"
|
||||
items={[
|
||||
{
|
||||
name: 'QQ音乐',
|
||||
id: 'qq'
|
||||
},
|
||||
{
|
||||
name: '网易云音乐',
|
||||
id: '163'
|
||||
},
|
||||
{
|
||||
name: '虾米音乐',
|
||||
id: 'xm'
|
||||
}
|
||||
]}
|
||||
onSelectionChange={setMusicType}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Input
|
||||
value={musicId}
|
||||
onChange={(e) => setMusicId(e.target.value)}
|
||||
placeholder="请输入音乐ID"
|
||||
label="音乐ID"
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!musicId) {
|
||||
toast.error('请输入音乐ID')
|
||||
return
|
||||
}
|
||||
showMusicSegment({
|
||||
type: Array.from(
|
||||
musicType
|
||||
)[0] as MusicSegment['data']['type'],
|
||||
id: musicId
|
||||
})
|
||||
setMusicId('')
|
||||
}}
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建{Array.from(musicType)[0] === '163' ? '网易云' : 'QQ'}音乐
|
||||
</Button>
|
||||
</Tab>
|
||||
<Tab
|
||||
title="自定义音乐"
|
||||
key="custom"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-2"
|
||||
validationBehavior="native"
|
||||
>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音乐URL' : null
|
||||
}}
|
||||
size="sm"
|
||||
placeholder="请输入音乐URL"
|
||||
label="音乐URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="audio"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音频URL' : null
|
||||
}}
|
||||
size="sm"
|
||||
placeholder="请输入音频URL"
|
||||
label="音频URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
size="sm"
|
||||
errorMessage="请输入音乐标题"
|
||||
placeholder="请输入音乐标题"
|
||||
label="音乐标题"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="image"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size="sm"
|
||||
placeholder="请输入封面图片URL"
|
||||
label="封面图片URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="content"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size="sm"
|
||||
placeholder="请输入音乐描述"
|
||||
label="音乐描述"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
type="submit"
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建自定义音乐
|
||||
</Button>
|
||||
</Form>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MusicInsert
|
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useState } from 'react'
|
||||
import { BsChatQuoteFill } from 'react-icons/bs'
|
||||
import { MdAdd } from 'react-icons/md'
|
||||
|
||||
export interface ReplyInsertProps {
|
||||
insertReply: (messageId: string) => void
|
||||
}
|
||||
|
||||
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
||||
const [replyId, setReplyId] = useState<string>('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="回复消息">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<BsChatQuoteFill className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Input
|
||||
placeholder="输入消息 ID"
|
||||
value={replyId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (isNumberReg.test(value)) {
|
||||
setReplyId(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
insertReply(replyId)
|
||||
setReplyId('')
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplyInsert
|
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { LiaHandScissors } from 'react-icons/lia'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const RPSInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送猜拳">
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'rps'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<LiaHandScissors className="text-2xl" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default RPSInsert
|
@@ -0,0 +1,32 @@
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
|
||||
import { OB11Segment } from '@/types/onebot'
|
||||
|
||||
export interface ShowStructedMessageProps {
|
||||
messages: OB11Segment[]
|
||||
}
|
||||
|
||||
const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
|
||||
return (
|
||||
<Snippet
|
||||
hideSymbol
|
||||
tooltipProps={{
|
||||
content: '点击复制'
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0'
|
||||
}}
|
||||
className="bg-content1 h-96 overflow-y-scroll items-start"
|
||||
>
|
||||
{JSON.stringify(messages, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShowStructedMessage
|
@@ -0,0 +1,126 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoVideocam } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
import { TbVideoPlus } from 'react-icons/tb'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const VideoInsert = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('')
|
||||
const videoInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showVideoSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送视频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<IoVideocam className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传视频">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
videoInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入视频地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入视频地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="请输入视频地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(videoUrl)) {
|
||||
toast.error('请输入正确的视频地址')
|
||||
return
|
||||
}
|
||||
showVideoSegment(videoUrl)
|
||||
setVideoUrl('')
|
||||
}}
|
||||
>
|
||||
<TbVideoPlus />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={videoInputRef}
|
||||
hidden
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showVideoSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoInsert
|
41
napcat.webui/src/components/chat_input/formats/emoji_blot.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface EmojiValue {
|
||||
alt: string
|
||||
src: string
|
||||
id: string
|
||||
}
|
||||
class EmojiBlot extends Embed {
|
||||
static blotName: string = 'emoji'
|
||||
static tagName: string = 'img'
|
||||
static classNames: string[] = ['w-6', 'h-6']
|
||||
|
||||
static create(value: HTMLImageElement) {
|
||||
const node = super.create(value)
|
||||
node.setAttribute('alt', value.alt)
|
||||
node.setAttribute('src', value.src)
|
||||
node.setAttribute('data-id', value.id)
|
||||
node.classList.add(...EmojiBlot.classNames)
|
||||
return node
|
||||
}
|
||||
|
||||
static formats(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
static value(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiBlot
|
30
napcat.webui/src/components/chat_input/formats/image_blot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface ImageValue {
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
class ImageBlot extends Embed {
|
||||
static blotName = 'image'
|
||||
static tagName = 'img'
|
||||
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom']
|
||||
|
||||
static create(value: ImageValue) {
|
||||
let node = super.create()
|
||||
node.setAttribute('alt', value.alt)
|
||||
node.setAttribute('src', value.src)
|
||||
node.classList.add(...ImageBlot.classNames)
|
||||
return node
|
||||
}
|
||||
|
||||
static value(node: HTMLImageElement): ImageValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageBlot
|
43
napcat.webui/src/components/chat_input/formats/reply_blot.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const BlockEmbed = Quill.import('blots/block/embed') as any
|
||||
export interface ReplyBlockValue {
|
||||
messageId: string
|
||||
}
|
||||
class ReplyBlock extends BlockEmbed {
|
||||
static blotName = 'reply'
|
||||
static tagName = 'div'
|
||||
static classNames = [
|
||||
'p-2',
|
||||
'select-none',
|
||||
'bg-default-100',
|
||||
'rounded-md',
|
||||
'pointer-events-none'
|
||||
]
|
||||
|
||||
static create(value: ReplyBlockValue) {
|
||||
const node = super.create()
|
||||
node.setAttribute('data-message-id', value.messageId)
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
node.classList.add(...ReplyBlock.classNames)
|
||||
const innerDom = document.createElement('div')
|
||||
innerDom.classList.add('text-sm', 'text-default-500', 'relative')
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0')
|
||||
const svg = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>`
|
||||
svgContainer.innerHTML = svg
|
||||
innerDom.innerHTML = `消息ID:${value.messageId}`
|
||||
innerDom.appendChild(svgContainer)
|
||||
node.appendChild(innerDom)
|
||||
return node
|
||||
}
|
||||
|
||||
static value(node: HTMLElement): ReplyBlockValue {
|
||||
return {
|
||||
messageId: node.getAttribute('data-message-id') || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ReplyBlock
|
207
napcat.webui/src/components/chat_input/index.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Range } from 'quill'
|
||||
import 'quill/dist/quill.core.css'
|
||||
import { useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useCustomQuill } from '@/hooks/use_custom_quill'
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { quillToMessage } from '@/utils/onebot'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
import AudioInsert from './components/audio_insert'
|
||||
import DiceInsert from './components/dice_insert'
|
||||
import EmojiPicker from './components/emoji_picker'
|
||||
import FileInsert from './components/file_insert'
|
||||
import ImageInsert from './components/image_insert'
|
||||
import MusicInsert from './components/music_insert'
|
||||
import ReplyInsert from './components/reply_insert'
|
||||
import RPSInsert from './components/rps_insert'
|
||||
import VideoInsert from './components/video_insert'
|
||||
import EmojiBlot from './formats/emoji_blot'
|
||||
import type { EmojiValue } from './formats/emoji_blot'
|
||||
import ImageBlot from './formats/image_blot'
|
||||
import ReplyBlock from './formats/reply_blot'
|
||||
|
||||
const ChatInput = () => {
|
||||
const memorizedRange = useRef<Range | null>(null)
|
||||
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const formats: string[] = ['image', 'emoji', 'reply']
|
||||
const modules = {
|
||||
toolbar: '#toolbar'
|
||||
}
|
||||
const { quillRef, quill, Quill } = useCustomQuill({
|
||||
modules,
|
||||
formats,
|
||||
placeholder: '请输入消息'
|
||||
})
|
||||
|
||||
if (Quill && !quill) {
|
||||
Quill.register('formats/emoji', EmojiBlot)
|
||||
Quill.register('formats/image', ImageBlot, true)
|
||||
Quill.register('formats/reply', ReplyBlock)
|
||||
}
|
||||
|
||||
if (quill) {
|
||||
quill.on('selection-change', (range) => {
|
||||
if (range) {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
|
||||
if (
|
||||
typeof firstOp?.insert !== 'string' &&
|
||||
firstOp?.insert?.reply &&
|
||||
range.index === 0 &&
|
||||
range.length !== quill.getLength()
|
||||
) {
|
||||
quill.setSelection(1, Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('text-change', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('editor-change', (eventName: string) => {
|
||||
if (eventName === 'text-change') {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.root.addEventListener('compositionstart', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selection = quill?.getSelection()
|
||||
if (selection) memorizedRange.current = selection
|
||||
}
|
||||
}
|
||||
|
||||
const insertImage = (url: string) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'image', {
|
||||
src: url,
|
||||
alt: '图片'
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
function insertReplyBlock(messageId: string) {
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (!isNumberReg.test(messageId)) {
|
||||
toast.error('请输入正确的消息ID')
|
||||
return
|
||||
}
|
||||
const editorContent = quill?.getContents()
|
||||
const firstOp = editorContent?.ops[0]
|
||||
const currentSelection = quill?.getSelection()
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply
|
||||
) {
|
||||
const delta = quill?.getContents()
|
||||
if (delta) {
|
||||
delta.ops[0] = {
|
||||
insert: { reply: { messageId } }
|
||||
}
|
||||
quill?.setContents(delta, Quill.sources.USER)
|
||||
}
|
||||
} else {
|
||||
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER)
|
||||
}
|
||||
quill?.setSelection((currentSelection?.index || 0) + 1, 0)
|
||||
quill?.blur()
|
||||
}
|
||||
const onInsertEmoji = (emoji: EmojiValue) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'emoji', {
|
||||
alt: emoji.alt,
|
||||
src: emoji.src,
|
||||
id: emoji.id
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
|
||||
const getChatMessage = () => {
|
||||
const delta = quill?.getContents()
|
||||
const ops =
|
||||
delta?.ops?.filter((op) => {
|
||||
return op.insert !== '\n'
|
||||
}) ?? []
|
||||
const messages: OB11Segment[] = ops.map((op) => {
|
||||
return quillToMessage(op)
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={quillRef}
|
||||
className="border border-default-200 rounded-md !mb-2 !text-base !h-64"
|
||||
/>
|
||||
<div id="toolbar" className="!border-none flex gap-2">
|
||||
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
|
||||
<EmojiPicker
|
||||
onInsertEmoji={onInsertEmoji}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<ReplyInsert insertReply={insertReplyBlock} />
|
||||
<FileInsert />
|
||||
<AudioInsert />
|
||||
<VideoInsert />
|
||||
<MusicInsert />
|
||||
<DiceInsert />
|
||||
<RPSInsert />
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
const messages = getChatMessage()
|
||||
showStructuredMessage(messages)
|
||||
}}
|
||||
className="ml-auto"
|
||||
>
|
||||
获取JSON格式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInput
|
49
napcat.webui/src/components/chat_input/modal.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
|
||||
import ChatInput from '.'
|
||||
|
||||
export default function ChatInputModal() {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
||||
构造聊天消息
|
||||
</Button>
|
||||
<Modal
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
构造消息
|
||||
</ModalHeader>
|
||||
<ModalBody className="overflow-y-auto">
|
||||
<div className="overflow-y-auto">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" onPress={onClose} variant="flat">
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
61
napcat.webui/src/components/code_editor.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Editor, { OnMount } from '@monaco-editor/react'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import React from 'react'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import monaco from '@/monaco'
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
paths: {
|
||||
vs: '/webui/monaco-editor/min/vs'
|
||||
}
|
||||
})
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' }
|
||||
}
|
||||
})
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' }
|
||||
}
|
||||
})
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||
(props, ref) => {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(editor)
|
||||
} else {
|
||||
;(ref as React.RefObject<CodeEditorRef>).current = editor
|
||||
}
|
||||
}
|
||||
if (props.onMount) {
|
||||
props.onMount(editor, monaco)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
{...props}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={isDark ? 'vs-dark' : 'light'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CodeEditor
|
120
napcat.webui/src/components/display_card/common_card.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button'
|
||||
import { Switch } from '@heroui/switch'
|
||||
import { useState } from 'react'
|
||||
import { CgDebug } from 'react-icons/cg'
|
||||
import { FiEdit3 } from 'react-icons/fi'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
|
||||
import DisplayCardContainer from './container'
|
||||
|
||||
type NetworkType = OneBotConfig['network']
|
||||
|
||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||
label: string
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
render?: (
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
) => React.ReactNode
|
||||
}>
|
||||
|
||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
data: NetworkType[T][0]
|
||||
showType?: boolean
|
||||
typeLabel: string
|
||||
fields: NetworkDisplayCardFields<T>
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
data,
|
||||
showType,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
const handleEnable = () => {
|
||||
setEditing(true)
|
||||
onEnable().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setEditing(true)
|
||||
onDelete().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
const handleEnableDebug = () => {
|
||||
setEditing(true)
|
||||
onEnableDebug().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
action={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="shadow"
|
||||
>
|
||||
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={debug ? 'success' : 'default'}
|
||||
startContent={<CgDebug />}
|
||||
onPress={handleEnableDebug}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
startContent={<MdDeleteForever />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
/>
|
||||
}
|
||||
tag={showType && typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 ${
|
||||
field.label === 'URL' ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-default-400">{field.label}</span>
|
||||
{field.render ? (
|
||||
field.render(field.value)
|
||||
) : (
|
||||
<span>{field.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default NetworkDisplayCard
|
57
napcat.webui/src/components/display_card/container.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { title } from '../primitives'
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string
|
||||
tag?: React.ReactNode
|
||||
action: React.ReactNode
|
||||
enableSwitch: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export interface DisplayCardProps {
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
title: _title,
|
||||
action,
|
||||
tag,
|
||||
enableSwitch,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className={'pb-0 flex items-center'}>
|
||||
{tag && (
|
||||
<div className="text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'foreground',
|
||||
size: 'xs',
|
||||
shadow: true
|
||||
}),
|
||||
'truncate'
|
||||
)}
|
||||
>
|
||||
{_title}
|
||||
</h2>
|
||||
<div className="ml-auto">{enableSwitch}</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm">{children}</CardBody>
|
||||
<CardFooter>{action}</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisplayCardContainer
|
47
napcat.webui/src/components/display_card/http_client.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import NetworkDisplayCard from './common_card'
|
||||
import type { NetworkDisplayCardFields } from './common_card'
|
||||
|
||||
interface HTTPClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
|
||||
const { url, reportSelfMessage, messagePostFormat } = data
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel="HTTP客户端"
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPClientDisplayCard
|
57
napcat.webui/src/components/display_card/http_server.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import NetworkDisplayCard from './common_card'
|
||||
import type { NetworkDisplayCardFields } from './common_card'
|
||||
|
||||
interface HTTPServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel="HTTP服务器"
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPServerDisplayCard
|
59
napcat.webui/src/components/display_card/http_sse_server.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import NetworkDisplayCard from './common_card'
|
||||
import type { NetworkDisplayCardFields } from './common_card'
|
||||
|
||||
interface HTTPSSEServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel="HTTP服务器"
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPSSEServerDisplayCard
|
57
napcat.webui/src/components/display_card/ws_client.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import NetworkDisplayCard from './common_card'
|
||||
import type { NetworkDisplayCardFields } from './common_card'
|
||||
|
||||
interface WebsocketClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
|
||||
const {
|
||||
url,
|
||||
heartInterval,
|
||||
reconnectInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage
|
||||
} = data
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '重连间隔', value: `${reconnectInterval}ms` },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel="Websocket客户端"
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebsocketClientDisplayCard
|
67
napcat.webui/src/components/display_card/ws_server.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
import NetworkDisplayCard from './common_card'
|
||||
import type { NetworkDisplayCardFields } from './common_card'
|
||||
|
||||
interface WebsocketServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
|
||||
const {
|
||||
host,
|
||||
port,
|
||||
heartInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage,
|
||||
enableForcePushEvent
|
||||
} = data
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: '强制推送事件',
|
||||
value: enableForcePushEvent,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel="Websocket服务器"
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebsocketServerDisplayCard
|
58
napcat.webui/src/components/display_network_item.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { title } from '@/components/primitives'
|
||||
|
||||
export interface NetworkItemDisplayProps {
|
||||
count: number
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
count,
|
||||
label,
|
||||
size = 'md'
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||
size === 'md'
|
||||
? 'col-span-8 md:col-span-2 bg-danger-50 shadow-danger-100'
|
||||
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||
)}
|
||||
shadow="sm"
|
||||
>
|
||||
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'font-outfit flex-1',
|
||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
size
|
||||
})
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0',
|
||||
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
shadow: true,
|
||||
size: 'xxs'
|
||||
})
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default NetworkItemDisplay
|
109
napcat.webui/src/components/effect_card.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Card, CardProps } from '@heroui/card'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
|
||||
export interface HoverEffectCardProps extends CardProps {
|
||||
children: React.ReactNode
|
||||
maxXRotation?: number
|
||||
maxYRotation?: number
|
||||
lightClassName?: string
|
||||
lightStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
maxXRotation = 5,
|
||||
maxYRotation = 5,
|
||||
className,
|
||||
style,
|
||||
lightClassName,
|
||||
lightStyle
|
||||
} = props
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const lightRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [isShowLight, setIsShowLight] = React.useState(false)
|
||||
const [pos, setPos] = React.useState({
|
||||
left: 0,
|
||||
top: 0
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'relative overflow-hidden bg-opacity-50 backdrop-blur-lg',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
transform:
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
|
||||
...style
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.3s ease-out'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsShowLight(false)
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.5s'
|
||||
cardRef.current.style.transform =
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current) {
|
||||
setIsShowLight(true)
|
||||
|
||||
const { x, y } = cardRef.current.getBoundingClientRect()
|
||||
const { clientX, clientY } = e
|
||||
|
||||
const offsetX = clientX - x
|
||||
const offsetY = clientY - y
|
||||
|
||||
const lightWidth = lightStyle?.width?.toString() || '100'
|
||||
const lightHeight = lightStyle?.height?.toString() || '100'
|
||||
const lightWidthNum = parseInt(lightWidth)
|
||||
const lightHeightNum = parseInt(lightHeight)
|
||||
|
||||
const left = offsetX - lightWidthNum / 2
|
||||
const top = offsetY - lightHeightNum / 2
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top
|
||||
})
|
||||
|
||||
cardRef.current.style.transition = 'transform 0.1s'
|
||||
|
||||
const rangeX = 400 / 2
|
||||
const rangeY = 400 / 2
|
||||
|
||||
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation
|
||||
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation
|
||||
|
||||
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={lightRef}
|
||||
className={clsx(
|
||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
|
||||
lightClassName
|
||||
)}
|
||||
style={{
|
||||
...pos
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default HoverEffectCard
|
30
napcat.webui/src/components/error_fallback.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Code } from '@heroui/code'
|
||||
import { MdError } from 'react-icons/md'
|
||||
|
||||
export interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
function errorFallbackRender({
|
||||
error,
|
||||
resetErrorBoundary
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className="pt-32 flex flex-col justify-center items-center">
|
||||
<div className="flex items-center">
|
||||
<MdError className="mr-2" color="red" size={30} />
|
||||
<h1 className="text-2xl">出错了</h1>
|
||||
</div>
|
||||
<div className="my-6 flex flex-col justify-center items-center">
|
||||
<p className="mb-2">错误信息</p>
|
||||
<Code>{error.message}</Code>
|
||||
</div>
|
||||
<Button color="primary" size="md" onPress={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default errorFallbackRender
|
19
napcat.webui/src/components/github_info/icon_wrapper.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface IconWrapperProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconWrapper = ({ children, className }: IconWrapperProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'flex items-center rounded-small justify-center w-7 h-7'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default IconWrapper
|
10
napcat.webui/src/components/github_info/item_counter.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ChevronRightIcon } from '../icons'
|
||||
|
||||
const ItemCounter = ({ number }: { number: number }) => (
|
||||
<div className="flex items-center gap-1 text-default-400">
|
||||
<span className="text-small">{number}</span>
|
||||
<ChevronRightIcon className="text-xl" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ItemCounter
|
40
napcat.webui/src/components/github_info/release.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getReleaseTime } from '@/utils/time'
|
||||
|
||||
import type { GithubRelease as GithubReleaseType } from '@/types/github'
|
||||
|
||||
export interface GithubReleaseProps {
|
||||
releaseData: GithubReleaseType
|
||||
}
|
||||
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
|
||||
const { releaseData } = props
|
||||
const [releaseTime, setReleaseTime] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (releaseData) {
|
||||
const timer = setInterval(() => {
|
||||
const time = getReleaseTime(releaseData.published_at)
|
||||
|
||||
setReleaseTime(time)
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [releaseData])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Releases</span>
|
||||
<div className="px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200">
|
||||
<span className="text-tiny text-default-600">{releaseData.name}</span>
|
||||
<div className="flex gap-2 text-tiny">
|
||||
<span className="text-default-500">{releaseTime}</span>
|
||||
<span className="text-success">Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GithubRelease
|
76
napcat.webui/src/components/hitokoto.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import PageLoading from './page_loading'
|
||||
|
||||
export default function Hitokoto() {
|
||||
const {
|
||||
data: dataOri,
|
||||
error,
|
||||
loading,
|
||||
run
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000
|
||||
})
|
||||
const data = dataOri?.data
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('复制成功')
|
||||
} catch (error) {
|
||||
toast.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
{loading && <PageLoading />}
|
||||
{error ? (
|
||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-noto-serif">{data?.hitokoto}</div>
|
||||
<div className="text-right">
|
||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Tooltip content="刷新" placement="top">
|
||||
<Button
|
||||
onPress={run}
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="复制" placement="top">
|
||||
<Button
|
||||
onPress={onCopy}
|
||||
size="sm"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="success"
|
||||
variant="flat"
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1746
napcat.webui/src/components/icons.tsx
Normal file
56
napcat.webui/src/components/input/image_input.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Input } from '@heroui/input'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export interface ImageInputProps {
|
||||
onChange: (base64: string) => void
|
||||
value: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="w-5 h-5 flex-shrink-0">
|
||||
<Image
|
||||
src={value}
|
||||
alt={label}
|
||||
className="w-5 h-5 flex-shrink-0 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type="file"
|
||||
placeholder="选择图片"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string
|
||||
onChange(base64)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onChange('')
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}}
|
||||
color="danger"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInput
|
136
napcat.webui/src/components/log_com/history.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { colorizeLogLevel } from '@/utils/terminal'
|
||||
|
||||
import PageLoading from '../page_loading'
|
||||
import XTerm from '../xterm'
|
||||
import type { XTermRef } from '../xterm'
|
||||
import LogLevelSelect from './log_level_select'
|
||||
|
||||
export interface HistoryLogsProps {
|
||||
list: string[]
|
||||
onSelect: (name: string) => void
|
||||
selectedLog?: string
|
||||
refreshList: () => void
|
||||
refreshLog: () => void
|
||||
listLoading?: boolean
|
||||
logLoading?: boolean
|
||||
listError?: Error
|
||||
logContent?: string
|
||||
}
|
||||
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const {
|
||||
list,
|
||||
onSelect,
|
||||
selectedLog,
|
||||
refreshList,
|
||||
refreshLog,
|
||||
listLoading,
|
||||
logContent,
|
||||
listError,
|
||||
logLoading
|
||||
} = props
|
||||
const Xterm = useRef<XTermRef>(null)
|
||||
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
)
|
||||
|
||||
const logToColored = (log: string) => {
|
||||
const logs = log
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const colored = colorizeLogLevel(line)
|
||||
return colored
|
||||
})
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => log.content)
|
||||
.join('\r\n')
|
||||
return logs
|
||||
}
|
||||
|
||||
const onDownloadLog = () => {
|
||||
if (!logContent) {
|
||||
return
|
||||
}
|
||||
const blob = new Blob([logContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedLog}.log`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Xterm.current || !logContent) {
|
||||
return
|
||||
}
|
||||
Xterm.current.clear()
|
||||
const _logContent = logToColored(logContent)
|
||||
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ')
|
||||
}, [logContent, logLevel])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>历史日志 - NapCat WebUI</title>
|
||||
<Card className="max-w-full h-full bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className="flex-row justify-start gap-3">
|
||||
<Select
|
||||
label="选择日志"
|
||||
size="sm"
|
||||
isLoading={listLoading}
|
||||
errorMessage={listError?.message}
|
||||
classNames={{
|
||||
trigger:
|
||||
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60'
|
||||
}}
|
||||
placeholder="选择日志"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
onSelect(value)
|
||||
}}
|
||||
selectedKeys={[selectedLog || '']}
|
||||
items={list.map((name) => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button className="flex-shrink-0" onPress={onDownloadLog}>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList}>刷新列表</Button>
|
||||
<Button onPress={refreshLog}>刷新日志</Button>
|
||||
</CardHeader>
|
||||
<CardBody className="relative">
|
||||
<PageLoading loading={logLoading} />
|
||||
<XTerm className="w-full h-full" ref={Xterm} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryLogs
|
87
napcat.webui/src/components/log_com/log_level_select.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import { SharedSelection } from '@heroui/system'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
|
||||
import { LogLevel } from '@/const/enum'
|
||||
|
||||
export interface LogLevelSelectProps {
|
||||
selectedKeys: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
}
|
||||
const logLevelColor: {
|
||||
[key in LogLevel]:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
} = {
|
||||
[LogLevel.DEBUG]: 'default',
|
||||
[LogLevel.INFO]: 'primary',
|
||||
[LogLevel.WARN]: 'warning',
|
||||
[LogLevel.ERROR]: 'danger',
|
||||
[LogLevel.FATAL]: 'danger'
|
||||
}
|
||||
const LogLevelSelect = (props: LogLevelSelectProps) => {
|
||||
const { selectedKeys, onSelectionChange } = props
|
||||
return (
|
||||
<Select
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(selectedKeys) => {
|
||||
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
|
||||
selectedKeys = 'all'
|
||||
}
|
||||
onSelectionChange(selectedKeys)
|
||||
}}
|
||||
label="日志级别"
|
||||
selectionMode="multiple"
|
||||
aria-label="Log Level"
|
||||
classNames={{
|
||||
label: 'mb-2',
|
||||
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
|
||||
popoverContent: 'bg-opacity-50 backdrop-blur-sm'
|
||||
}}
|
||||
size="sm"
|
||||
items={[
|
||||
{ label: 'Debug', value: LogLevel.DEBUG },
|
||||
{ label: 'Info', value: LogLevel.INFO },
|
||||
{ label: 'Warn', value: LogLevel.WARN },
|
||||
{ label: 'Error', value: LogLevel.ERROR },
|
||||
{ label: 'Fatal', value: LogLevel.FATAL }
|
||||
]}
|
||||
renderValue={(value) => {
|
||||
if (value.length === 5) {
|
||||
return (
|
||||
<Chip size="sm" color="primary" variant="flat">
|
||||
全部
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{value.map((v) => (
|
||||
<Chip
|
||||
size="sm"
|
||||
key={v.key}
|
||||
color={logLevelColor[v.data?.value as LogLevel]}
|
||||
variant="flat"
|
||||
>
|
||||
{v.data?.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogLevelSelect
|
116
napcat.webui/src/components/log_com/realtime.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoDownloadOutline } from 'react-icons/io5'
|
||||
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal'
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager'
|
||||
|
||||
import type { XTermRef } from '../xterm'
|
||||
import XTerm from '../xterm'
|
||||
import LogLevelSelect from './log_level_select'
|
||||
|
||||
const RealTimeLogs = () => {
|
||||
const Xterm = useRef<XTermRef>(null)
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
)
|
||||
const [dataArr, setDataArr] = useState<Log[]>([])
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
const blob = new Blob([logContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'napcat.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const writeStream = () => {
|
||||
try {
|
||||
const _data = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.slice(-100)
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
Xterm.current?.clear()
|
||||
Xterm.current?.write(_data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
writeStream()
|
||||
}, [logLevel, dataArr])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
console.log('subscribeLogs')
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data]
|
||||
if (newData.length > 1000) {
|
||||
newData.splice(0, newData.length - 1000)
|
||||
}
|
||||
return newData
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
source.close()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const close = subscribeLogs()
|
||||
return () => {
|
||||
console.log('close')
|
||||
close?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className="text-lg" />}
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
<XTerm ref={Xterm} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RealTimeLogs
|
92
napcat.webui/src/components/modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Modal as NextUIModal,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
import React from 'react'
|
||||
|
||||
export interface ModalProps {
|
||||
content: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||
showCancel?: boolean
|
||||
dismissible?: boolean
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
const {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
size = 'md',
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel
|
||||
} = props
|
||||
const { onClose: onNativeClose } = useDisclosure()
|
||||
|
||||
return (
|
||||
<NextUIModal
|
||||
defaultOpen
|
||||
backdrop={backdrop}
|
||||
isDismissable={dismissible}
|
||||
onClose={() => {
|
||||
onClose?.()
|
||||
onNativeClose()
|
||||
}}
|
||||
size={size}
|
||||
classNames={{
|
||||
backdrop: 'z-[99999999]',
|
||||
wrapper: 'z-[999999999]'
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
<>
|
||||
{title && (
|
||||
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody>{content}</ModalBody>
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onCancel?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
onConfirm?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</NextUIModal>
|
||||
)
|
||||
})
|
||||
|
||||
Modal.displayName = 'Modal'
|
||||
|
||||
export default Modal
|
241
napcat.webui/src/components/napcat_repo_info.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Listbox, ListboxItem } from '@heroui/listbox'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { MdError } from 'react-icons/md'
|
||||
|
||||
import IconWrapper from '@/components/github_info/icon_wrapper'
|
||||
import ItemCounter from '@/components/github_info/item_counter'
|
||||
import GithubRelease from '@/components/github_info/release'
|
||||
import {
|
||||
BookIcon,
|
||||
BugIcon,
|
||||
PullRequestIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
WatchersIcon
|
||||
} from '@/components/icons'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { openUrl } from '@/utils/url'
|
||||
|
||||
import type {
|
||||
GirhubRepo,
|
||||
GithubContributor,
|
||||
GithubPullRequest,
|
||||
GithubRelease as GithubReleaseType
|
||||
} from '@/types/github'
|
||||
|
||||
function displayData(data: number, loading: boolean, error?: Error) {
|
||||
if (error) {
|
||||
return <MdError className="text-danger-400" />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spinner size="sm" />
|
||||
}
|
||||
|
||||
return <ItemCounter number={data} />
|
||||
}
|
||||
|
||||
export default function NapCatRepoInfo() {
|
||||
// repo info
|
||||
const {
|
||||
data: repoOriData,
|
||||
error: repoError,
|
||||
loading: repoLoading
|
||||
} = useRequest(() =>
|
||||
request.get<GirhubRepo>('https://api.github.com/repos/NapNeko/NapCatQQ')
|
||||
)
|
||||
|
||||
// release info
|
||||
const {
|
||||
data: releaseOriData,
|
||||
error: releaseError,
|
||||
loading: releaseLoading
|
||||
} = useRequest(() =>
|
||||
request.get<GithubReleaseType[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
)
|
||||
|
||||
// pr info
|
||||
const {
|
||||
data: prData,
|
||||
error: prError,
|
||||
loading: prLoading
|
||||
} = useRequest(() =>
|
||||
request.get<GithubPullRequest[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/pulls'
|
||||
)
|
||||
)
|
||||
|
||||
// contributors info
|
||||
const {
|
||||
data: contributorsData,
|
||||
error: contributorsError,
|
||||
loading: contributorsLoading
|
||||
} = useRequest(() =>
|
||||
request.get<GithubContributor[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/contributors'
|
||||
)
|
||||
)
|
||||
|
||||
const repoData = repoOriData?.data
|
||||
const releaseData = releaseOriData?.data?.[0]
|
||||
const prCount = prData?.data?.length || 0
|
||||
const contributorsCount = contributorsData?.data?.length || 0
|
||||
|
||||
const releaseCount = releaseOriData?.data?.length || 0
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
aria-label="NapCat Repo Info"
|
||||
className="p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 max-w-[300px] overflow-visible shadow-small rounded-medium bg-opacity-50 backdrop-blur-sm"
|
||||
itemClasses={{
|
||||
base: 'px-3 first:rounded-t-medium last:rounded-b-medium rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80'
|
||||
}}
|
||||
onAction={(key: React.Key) => {
|
||||
switch (key) {
|
||||
case 'releases':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/releases', true)
|
||||
break
|
||||
case 'contributors':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/graphs/contributors',
|
||||
true
|
||||
)
|
||||
break
|
||||
case 'license':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/blob/main/LICENSE',
|
||||
true
|
||||
)
|
||||
break
|
||||
case 'watchers':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/watchers', true)
|
||||
break
|
||||
case 'star':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/stargazers', true)
|
||||
break
|
||||
case 'issues':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/issues', true)
|
||||
break
|
||||
case 'pull_requests':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/pulls', true)
|
||||
break
|
||||
default:
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ', true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListboxItem
|
||||
key="star"
|
||||
endContent={displayData(
|
||||
repoData?.stargazers_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className="bg-success/10 text-success">
|
||||
<StarIcon className="text-lg" />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Star
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="issues"
|
||||
endContent={displayData(
|
||||
repoData?.open_issues_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className="bg-success/10 text-success">
|
||||
<BugIcon className="text-lg" />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Issues
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="pull_requests"
|
||||
endContent={displayData(prCount, prLoading, prError)}
|
||||
startContent={
|
||||
<IconWrapper className="bg-primary/10 text-primary">
|
||||
<PullRequestIcon className="text-lg" />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Pull Requests
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="releases"
|
||||
className="group h-auto py-3"
|
||||
endContent={
|
||||
releaseError ? (
|
||||
<MdError className="text-danger-400" />
|
||||
) : releaseLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<ItemCounter number={releaseCount} />
|
||||
)
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className="bg-primary/10 text-primary">
|
||||
<TagIcon className="text-lg" />
|
||||
</IconWrapper>
|
||||
}
|
||||
textValue="Releases"
|
||||
>
|
||||
{releaseData && <GithubRelease releaseData={releaseData} />}
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="contributors"
|
||||
endContent={displayData(
|
||||
contributorsCount,
|
||||
contributorsLoading,
|
||||
contributorsError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className="bg-warning/10 text-warning">
|
||||
<UsersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Contributors
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="watchers"
|
||||
endContent={displayData(
|
||||
repoData?.watchers_count ?? 0,
|
||||
repoLoading,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className="bg-default/50 text-foreground">
|
||||
<WatchersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Watchers
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key="license"
|
||||
endContent={
|
||||
<span className="text-small text-default-400">
|
||||
{repoData?.license?.name ?? 'unknown'}
|
||||
</span>
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className="bg-danger/10 text-danger dark:text-danger-500">
|
||||
<BookIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
License
|
||||
</ListboxItem>
|
||||
</Listbox>
|
||||
)
|
||||
}
|
172
napcat.webui/src/components/network_edit/generic_form.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { ModalBody, ModalFooter } from '@heroui/modal'
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import { ReactElement, useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import type {
|
||||
DefaultValues,
|
||||
Path,
|
||||
PathValue,
|
||||
SubmitHandler
|
||||
} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SwitchCard from '../switch_card'
|
||||
|
||||
export type FieldTypes = 'input' | 'select' | 'switch'
|
||||
|
||||
type NetworkConfigType = OneBotConfig['network']
|
||||
|
||||
export interface Field<T extends keyof OneBotConfig['network']> {
|
||||
name: keyof NetworkConfigType[T][0]
|
||||
label: string
|
||||
type: FieldTypes
|
||||
options?: Array<{ key: string; value: string }>
|
||||
placeholder?: string
|
||||
isRequired?: boolean
|
||||
isDisabled?: boolean
|
||||
description?: string
|
||||
colSpan?: 1 | 2
|
||||
}
|
||||
|
||||
export interface GenericFormProps<T extends keyof NetworkConfigType> {
|
||||
data?: NetworkConfigType[T][0]
|
||||
defaultValues: DefaultValues<NetworkConfigType[T][0]>
|
||||
onClose: () => void
|
||||
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>
|
||||
fields: Array<Field<T>>
|
||||
}
|
||||
|
||||
const GenericForm = <T extends keyof NetworkConfigType>({
|
||||
data,
|
||||
defaultValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
fields
|
||||
}: GenericFormProps<T>): ReactElement => {
|
||||
const { control, handleSubmit, formState, setValue, reset } = useForm<
|
||||
NetworkConfigType[T][0]
|
||||
>({
|
||||
defaultValues
|
||||
})
|
||||
|
||||
const submitAction: SubmitHandler<NetworkConfigType[T][0]> = async (data) => {
|
||||
await onSubmit(data)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const _onSubmit = handleSubmit(submitAction, (e) => {
|
||||
for (const error in e) {
|
||||
toast.error(e[error]?.message as string)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[]
|
||||
for (const key of keys) {
|
||||
const value = data[key] as PathValue<
|
||||
NetworkConfig[T][0],
|
||||
Path<NetworkConfig[T][0]>
|
||||
>
|
||||
setValue(key, value)
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}, [data, reset, setValue])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody>
|
||||
<div className="grid grid-cols-2 gap-y-4 gap-x-2 w-full">
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.name as string}
|
||||
className={field.colSpan === 1 ? 'col-span-1' : 'col-span-2'}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name as Path<NetworkConfig[T][0]>}
|
||||
rules={
|
||||
field.isRequired
|
||||
? {
|
||||
required: `请填写${field.label}`
|
||||
}
|
||||
: void 0
|
||||
}
|
||||
render={({ field: controllerField }) => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={controllerField.value as string}
|
||||
onChange={controllerField.onChange}
|
||||
onBlur={controllerField.onBlur}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
isDisabled={field.isDisabled}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
{...controllerField}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
selectedKeys={[controllerField.value as string]}
|
||||
value={controllerField.value.toString()}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.key} value={option.value}>
|
||||
{option.value}
|
||||
</SelectItem>
|
||||
)) || <></>}
|
||||
</Select>
|
||||
)
|
||||
case 'switch':
|
||||
return (
|
||||
<SwitchCard
|
||||
{...controllerField}
|
||||
value={controllerField.value as boolean}
|
||||
description={field.description}
|
||||
label={field.label}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={formState.isSubmitting}
|
||||
variant="light"
|
||||
onPress={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formState.isSubmitting}
|
||||
onPress={() => _onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenericForm
|
95
napcat.webui/src/components/network_edit/http_client.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import GenericForm from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPClientFormProps {
|
||||
data?: OneBotConfig['network']['httpClients'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpClients'][0]) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPClientFormType = OneBotConfig['network']['httpClients']
|
||||
|
||||
const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const defaultValues: HTTPClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'http://localhost:8080',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false
|
||||
}
|
||||
|
||||
const fields: Field<'httpClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' }
|
||||
],
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPClientForm
|
110
napcat.webui/src/components/network_edit/http_server.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import GenericForm from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
data?: OneBotConfig['network']['httpServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPServerFormType = OneBotConfig['network']['httpServers']
|
||||
|
||||
const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const defaultValues: HTTPServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false
|
||||
}
|
||||
|
||||
const fields: Field<'httpServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPServerForm
|
120
napcat.webui/src/components/network_edit/http_sse.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import GenericForm from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
data?: OneBotConfig['network']['httpSseServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers']
|
||||
|
||||
const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const defaultValues: HTTPServerSSEFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false,
|
||||
reportSelfMessage: false
|
||||
}
|
||||
|
||||
const fields: Field<'httpSseServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token'
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPServerSSEForm
|
123
napcat.webui/src/components/network_edit/modal.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Modal, ModalContent, ModalHeader } from '@heroui/modal'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
import HTTPClientForm from './http_client'
|
||||
import HTTPServerForm from './http_server'
|
||||
import HTTPServerSSEForm from './http_sse'
|
||||
import WebsocketClientForm from './ws_client'
|
||||
import WebsocketServerForm from './ws_server'
|
||||
|
||||
const modalTitle = {
|
||||
httpServers: 'HTTP Server',
|
||||
httpClients: 'HTTP Client',
|
||||
httpSseServers: 'HTTP SSE Server',
|
||||
websocketServers: 'Websocket Server',
|
||||
websocketClients: 'Websocket Client'
|
||||
}
|
||||
|
||||
export interface NetworkFormModalProps<
|
||||
T extends keyof OneBotConfig['network']
|
||||
> {
|
||||
isOpen: boolean
|
||||
field: T
|
||||
data?: OneBotConfig['network'][T][0]
|
||||
onOpenChange: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
const NetworkFormModal = <T extends keyof OneBotConfig['network']>(
|
||||
props: NetworkFormModalProps<T>
|
||||
) => {
|
||||
const { isOpen, onOpenChange, field, data } = props
|
||||
const { createNetworkConfig, updateNetworkConfig } = useConfig()
|
||||
const isCreate = !data
|
||||
|
||||
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, data)
|
||||
} else {
|
||||
await updateNetworkConfig(field, data)
|
||||
}
|
||||
toast.success('保存配置成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const renderFormComponent = (onClose: () => void) => {
|
||||
switch (field) {
|
||||
case 'httpServers':
|
||||
return (
|
||||
<HTTPServerForm
|
||||
data={data as OneBotConfig['network']['httpServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'httpClients':
|
||||
return (
|
||||
<HTTPClientForm
|
||||
data={data as OneBotConfig['network']['httpClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'websocketServers':
|
||||
return (
|
||||
<WebsocketServerForm
|
||||
data={data as OneBotConfig['network']['websocketServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'websocketClients':
|
||||
return (
|
||||
<WebsocketClientForm
|
||||
data={data as OneBotConfig['network']['websocketClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'httpSseServers':
|
||||
return (
|
||||
<HTTPServerSSEForm
|
||||
data={data as OneBotConfig['network']['httpSseServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isDismissable={false}
|
||||
isOpen={isOpen}
|
||||
size="lg"
|
||||
scrollBehavior="outside"
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
{modalTitle[field]}
|
||||
</ModalHeader>
|
||||
{renderFormComponent(onClose)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default NetworkFormModal
|
115
napcat.webui/src/components/network_edit/ws_client.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import GenericForm from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface WebsocketClientFormProps {
|
||||
data?: OneBotConfig['network']['websocketClients'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type WebsocketClientFormType = OneBotConfig['network']['websocketClients']
|
||||
|
||||
const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const defaultValues: WebsocketClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'ws://localhost:8082',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
reconnectInterval: 30000
|
||||
}
|
||||
|
||||
const fields: Field<'websocketClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' }
|
||||
],
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token'
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'reconnectInterval',
|
||||
label: '重连间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入重连间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebsocketClientForm
|
122
napcat.webui/src/components/network_edit/ws_server.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import GenericForm from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface WebsocketServerFormProps {
|
||||
data?: OneBotConfig['network']['websocketServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type WebsocketServerFormType = OneBotConfig['network']['websocketServers']
|
||||
|
||||
const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const defaultValues: WebsocketServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
reportSelfMessage: false,
|
||||
enableForcePushEvent: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000
|
||||
}
|
||||
|
||||
const fields: Field<'websocketServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' }
|
||||
],
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'enableForcePushEvent',
|
||||
label: '强制推送事件',
|
||||
type: 'switch',
|
||||
description: '是否强制推送事件',
|
||||
colSpan: 1
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token'
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebsocketServerForm
|
225
napcat.webui/src/components/onebot/api/debug.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoLink, IoSend } from 'react-icons/io5'
|
||||
import { PiCatDuotone } from 'react-icons/pi'
|
||||
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { parseAxiosResponse } from '@/utils/url'
|
||||
import { generateDefaultJson, parse } from '@/utils/zod'
|
||||
|
||||
import DisplayStruct from './display_struct'
|
||||
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath
|
||||
data: OneBotHttpApiContent
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props
|
||||
const url = new URL(window.location.origin).href
|
||||
const defaultHttpUrl = url.replace(':6099', ':3000')
|
||||
const [httpConfig, setHttpConfig] = useState({
|
||||
url: defaultHttpUrl,
|
||||
token: ''
|
||||
})
|
||||
const [requestBody, setRequestBody] = useState('{}')
|
||||
const [responseContent, setResponseContent] = useState('')
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const parsedRequest = parse(data.request)
|
||||
const parsedResponse = parse(data.response)
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return
|
||||
setIsFetching(true)
|
||||
const r = toast.loading('正在发送请求...')
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody)
|
||||
request
|
||||
.post(httpConfig.url + path, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res))
|
||||
toast.success('请求发送完成,请查看响应')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求发送失败:' + err.message)
|
||||
setResponseContent(parseAxiosResponse(err.response))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('请求体 JSON 格式错误')
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
setResponseContent('')
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold mb-4">
|
||||
<Snippet
|
||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
</h1>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
label="HTTP URL"
|
||||
placeholder="输入 HTTP URL"
|
||||
value={httpConfig.url}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="输入 Token"
|
||||
value={httpConfig.token}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, token: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color="danger"
|
||||
size="lg"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card shadow="sm" className="my-4 bg-opacity-50 backdrop-blur-md">
|
||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isCodeEditorOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
height: isCodeEditorOpen ? 'auto' : 0
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language="json"
|
||||
height="400px"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<ChatInputModal />
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
}
|
||||
>
|
||||
填充示例请求体
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card
|
||||
shadow="sm"
|
||||
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">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsResponseOpen(!isResponseOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isResponseOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
<Button
|
||||
color="success"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent)
|
||||
toast.success('响应内容已复制到剪贴板')
|
||||
}}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-y-auto text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isResponseOpen ? 1 : 0,
|
||||
height: isResponseOpen ? 300 : 0
|
||||
}}
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
{responseContent || (
|
||||
<div className="text-gray-400">暂无响应</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm">
|
||||
<h2 className="text-xl font-semibold mb-2">请求体结构</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiDebug
|