diff --git a/napcat.webui/.gitignore b/napcat.webui/.gitignore index a547bf36..5bbfcc57 100644 --- a/napcat.webui/.gitignore +++ b/napcat.webui/.gitignore @@ -22,3 +22,11 @@ dist-ssr *.njsproj *.sln *.sw? + +# NPM LOCK files +package-lock.json +yarn.lock +pnpm-lock.yaml + + +dist.zip \ No newline at end of file diff --git a/napcat.webui/.npmrc b/napcat.webui/.npmrc new file mode 100644 index 00000000..7cb61a22 --- /dev/null +++ b/napcat.webui/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@heroui/* \ No newline at end of file diff --git a/napcat.webui/.prettierignore b/napcat.webui/.prettierignore new file mode 100644 index 00000000..d44692da --- /dev/null +++ b/napcat.webui/.prettierignore @@ -0,0 +1,7 @@ +dist +*.md +*.html +yarn.lock +package-lock.json +node_modules +pnpm-lock.yaml \ No newline at end of file diff --git a/napcat.webui/.prettierrc b/napcat.webui/.prettierrc new file mode 100644 index 00000000..f890346c --- /dev/null +++ b/napcat.webui/.prettierrc @@ -0,0 +1,23 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "semi": false, + "trailingComma": "none", + "bracketSpacing": true, + "importOrder": [ + "", + "^@/const/(.*)$", + "^@/store/(.*)$", + "^@/components/(.*)$", + "^@/contexts/(.*)$", + "^@/hooks/(.*)$", + "^@/utils/(.*)$", + "^@/(.*)$", + "^[./]" + ], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "plugins": ["@trivago/prettier-plugin-sort-imports"] +} diff --git a/napcat.webui/.vscode/extensions.json b/napcat.webui/.vscode/extensions.json deleted file mode 100644 index a7cea0b0..00000000 --- a/napcat.webui/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/napcat.webui/LICENSE b/napcat.webui/LICENSE new file mode 100644 index 00000000..725286ac --- /dev/null +++ b/napcat.webui/LICENSE @@ -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. \ No newline at end of file diff --git a/napcat.webui/README.md b/napcat.webui/README.md index 33895ab2..be8c2758 100644 --- a/napcat.webui/README.md +++ b/napcat.webui/README.md @@ -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 ` - - + + + + + + + NapCat WebUI + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/napcat.webui/package.json b/napcat.webui/package.json index 9080a245..1649d9dc 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -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" + } } } diff --git a/napcat.webui/postcss.config.js b/napcat.webui/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/napcat.webui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/napcat.webui/public/favicon.ico b/napcat.webui/public/favicon.ico new file mode 100644 index 00000000..4d67aff6 Binary files /dev/null and b/napcat.webui/public/favicon.ico differ diff --git a/napcat.webui/public/logo.png b/napcat.webui/public/logo.png deleted file mode 100644 index 0eef6b84..00000000 Binary files a/napcat.webui/public/logo.png and /dev/null differ diff --git a/napcat.webui/public/logo_webui.png b/napcat.webui/public/logo_webui.png deleted file mode 100644 index 03aa7f5b..00000000 Binary files a/napcat.webui/public/logo_webui.png and /dev/null differ diff --git a/napcat.webui/public/vercel.json b/napcat.webui/public/vercel.json new file mode 100644 index 00000000..1db5d80e --- /dev/null +++ b/napcat.webui/public/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/" } + ] +} \ No newline at end of file diff --git a/napcat.webui/public/vite.svg b/napcat.webui/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/napcat.webui/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/napcat.webui/src/App.tsx b/napcat.webui/src/App.tsx new file mode 100644 index 00000000..968ff6fe --- /dev/null +++ b/napcat.webui/src/App.tsx @@ -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 ( + + + + + + }> + + + + + + + + ) +} + +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 ( + + } path="/*" /> + } path="/qq_login" /> + } path="/web_login" /> + + ) +} + +export default App diff --git a/napcat.webui/src/App.vue b/napcat.webui/src/App.vue deleted file mode 100644 index 431f07c5..00000000 --- a/napcat.webui/src/App.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - diff --git a/napcat.webui/src/assets/0xProtoNerdFont-Italic.ttf b/napcat.webui/src/assets/0xProtoNerdFont-Italic.ttf deleted file mode 100644 index 6c0529fb..00000000 Binary files a/napcat.webui/src/assets/0xProtoNerdFont-Italic.ttf and /dev/null differ diff --git a/napcat.webui/src/assets/Sotheby.ttf b/napcat.webui/src/assets/Sotheby.ttf deleted file mode 100644 index bb9630f2..00000000 Binary files a/napcat.webui/src/assets/Sotheby.ttf and /dev/null differ diff --git a/napcat.webui/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png b/napcat.webui/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png new file mode 100644 index 00000000..8297f060 Binary files /dev/null and b/napcat.webui/src/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png differ diff --git a/napcat.webui/src/assets/images/bkg-color.png b/napcat.webui/src/assets/images/bkg-color.png new file mode 100644 index 00000000..66ef420c Binary files /dev/null and b/napcat.webui/src/assets/images/bkg-color.png differ diff --git a/napcat.webui/src/assets/images/logo.png b/napcat.webui/src/assets/images/logo.png new file mode 100644 index 00000000..7ab58e2c Binary files /dev/null and b/napcat.webui/src/assets/images/logo.png differ diff --git a/napcat.webui/src/assets/logo.png b/napcat.webui/src/assets/logo.png deleted file mode 100644 index 0eef6b84..00000000 Binary files a/napcat.webui/src/assets/logo.png and /dev/null differ diff --git a/napcat.webui/src/assets/logo_webui.png b/napcat.webui/src/assets/logo_webui.png deleted file mode 100644 index 03aa7f5b..00000000 Binary files a/napcat.webui/src/assets/logo_webui.png and /dev/null differ diff --git a/napcat.webui/src/assets/vue.svg b/napcat.webui/src/assets/vue.svg deleted file mode 100644 index 770e9d33..00000000 --- a/napcat.webui/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/napcat.webui/src/backend/githubApi.ts b/napcat.webui/src/backend/githubApi.ts deleted file mode 100644 index d8f6d205..00000000 --- a/napcat.webui/src/backend/githubApi.ts +++ /dev/null @@ -1,66 +0,0 @@ -export class githubApiManager { - public async GetBaseData(): Promise { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/napcat.webui/src/backend/log.ts b/napcat.webui/src/backend/log.ts deleted file mode 100644 index 32fdaac2..00000000 --- a/napcat.webui/src/backend/log.ts +++ /dev/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 { - 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 { - 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 { - 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); - } - } -} diff --git a/napcat.webui/src/backend/shell.ts b/napcat.webui/src/backend/shell.ts deleted file mode 100644 index 941f55fc..00000000 --- a/napcat.webui/src/backend/shell.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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: '接口异常' }; - } -} diff --git a/napcat.webui/src/components/Dashboard.vue b/napcat.webui/src/components/Dashboard.vue deleted file mode 100644 index b46315e5..00000000 --- a/napcat.webui/src/components/Dashboard.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - diff --git a/napcat.webui/src/components/QQLogin.vue b/napcat.webui/src/components/QQLogin.vue deleted file mode 100644 index 04c09531..00000000 --- a/napcat.webui/src/components/QQLogin.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - - - \ No newline at end of file diff --git a/napcat.webui/src/components/WebUiLogin.vue b/napcat.webui/src/components/WebUiLogin.vue deleted file mode 100644 index bbcf3728..00000000 --- a/napcat.webui/src/components/WebUiLogin.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - - - diff --git a/napcat.webui/src/components/audio_player.tsx b/napcat.webui/src/components/audio_player.tsx new file mode 100644 index 00000000..72d81374 --- /dev/null +++ b/napcat.webui/src/components/audio_player.tsx @@ -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 { + 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(null) + const cardRef = useRef(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) => { + const audio = event.target as HTMLAudioElement + setCurrentTime(audio.currentTime) + onTimeUpdate?.(event) + } + + const handleLoadedData = (event: React.SyntheticEvent) => { + const audio = event.target as HTMLAudioElement + setDuration(audio.duration) + onLoadedData?.(event) + } + + const handlePlay = (e: React.SyntheticEvent) => { + setIsPlaying(true) + setStorageAutoPlay(true) + onPlay?.(e) + } + + const handlePause = (e: React.SyntheticEvent) => { + 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 ( +
+
+ ) +} diff --git a/napcat.webui/src/components/button/add_button.tsx b/napcat.webui/src/components/button/add_button.tsx new file mode 100644 index 00000000..02d32033 --- /dev/null +++ b/napcat.webui/src/components/button/add_button.tsx @@ -0,0 +1,180 @@ +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 = (props) => { + const { onOpen } = props + + return ( + + + + + { + onOpen(key as keyof OneBotConfig['network']) + }} + > + +
+
+ +
+
新建网络配置
+
+
+ + + + } + > +
+ HTTP服务器 + + + +
+
+ + + + } + > +
+ HTTP客户端 + + + +
+
+ + + + } + > +
+ Websocket服务器 + + + +
+
+ + + + } + > +
+ Websocket客户端 + + + +
+
+
+
+ ) +} + +export default AddButton diff --git a/napcat.webui/src/components/button/save_buttons.tsx b/napcat.webui/src/components/button/save_buttons.tsx new file mode 100644 index 00000000..c9c62c54 --- /dev/null +++ b/napcat.webui/src/components/button/save_buttons.tsx @@ -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 = ({ + onSubmit, + reset, + isSubmitting, + refresh +}) => ( +
+
+ + + +
+
+) + +export default SaveButtons diff --git a/napcat.webui/src/components/chat_input/components/audio_insert.tsx b/napcat.webui/src/components/chat_input/components/audio_insert.tsx new file mode 100644 index 00000000..5e26587d --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/audio_insert.tsx @@ -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('') + const audioInputRef = useRef(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(null) + const audioChunksRef = useRef([]) + const [audioPreview, setAudioPreview] = useState(null) + const [showPreview, setShowPreview] = useState(false) + const streamRef = useRef(null) + const [recordingTime, setRecordingTime] = useState(0) + const recordingIntervalRef = useRef(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 ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setAudioUrl(e.target.value)} + placeholder="请输入音频地址" + /> + + +
+ + +
+ + + +
+
+ +
+ + {showPreview && audioPreview && ( + + )} +
+ {(isRecording || audioPreview) && ( +
+ + 录制时长: {formatTime(recordingTime)} +
+ )} + {showPreview && audioPreview && ( +
+
+
+
+ + { + 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 diff --git a/napcat.webui/src/components/chat_input/components/dice_insert.tsx b/napcat.webui/src/components/chat_input/components/dice_insert.tsx new file mode 100644 index 00000000..6aa9ff94 --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/dice_insert.tsx @@ -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 ( + + + + ) +} + +export default DiceInsert diff --git a/napcat.webui/src/components/chat_input/components/emoji_picker.tsx b/napcat.webui/src/components/chat_input/components/emoji_picker.tsx new file mode 100644 index 00000000..273c7b8e --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/emoji_picker.tsx @@ -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([]) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const containerRef = useRef(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 ( +
+ { + onOpenChange(v) + setIsPopoverOpen(v) + }} + > + +
+ + + +
+
+ + {visibleEmojis.map((emoji) => ( + + ))} + +
+
+ ) +} + +export default EmojiPicker diff --git a/napcat.webui/src/components/chat_input/components/file_insert.tsx b/napcat.webui/src/components/chat_input/components/file_insert.tsx new file mode 100644 index 00000000..13cf122b --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/file_insert.tsx @@ -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('') + const fileInputRef = useRef(null) + const showStructuredMessage = useShowStructuredMessage() + const showFileSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'file', + data: { + file: file + } + } + ] + showStructuredMessage(messages) + } + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setFileUrl(e.target.value)} + placeholder="请输入文件地址" + /> + + +
+
+
+ + { + 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 diff --git a/napcat.webui/src/components/chat_input/components/image_insert.tsx b/napcat.webui/src/components/chat_input/components/image_insert.tsx new file mode 100644 index 00000000..98a87f9a --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/image_insert.tsx @@ -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('') + const imageInputRef = useRef(null) + + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setImgUrl(e.target.value)} + placeholder="请输入图片地址" + /> + + +
+
+
+ + { + 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 diff --git a/napcat.webui/src/components/chat_input/components/music_insert.tsx b/napcat.webui/src/components/chat_input/components/music_insert.tsx new file mode 100644 index 00000000..f13b5488 --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/music_insert.tsx @@ -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('') + const [musicType, setMusicType] = useState(new Set(['163'])) + const [mode, setMode] = useState('default') + const containerRef = useRef(null) + const { control, handleSubmit, reset } = useForm< + Omit + >({ + 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) => { + showMusicSegment({ + type: 'custom', + ...data + }) + reset() + } + + return ( +
+ + +
+ + + +
+
+ + + + + setMusicId(e.target.value)} + placeholder="请输入音乐ID" + label="音乐ID" + /> + + + +
+ ( + { + return !isURI(v) ? '请输入正确的音乐URL' : null + }} + size="sm" + placeholder="请输入音乐URL" + label="音乐URL" + /> + )} + /> + ( + { + return !isURI(v) ? '请输入正确的音频URL' : null + }} + size="sm" + placeholder="请输入音频URL" + label="音频URL" + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + +
+
+
+
+
+ ) +} + +export default MusicInsert diff --git a/napcat.webui/src/components/chat_input/components/reply_insert.tsx b/napcat.webui/src/components/chat_input/components/reply_insert.tsx new file mode 100644 index 00000000..aaa7d4a2 --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/reply_insert.tsx @@ -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('') + + return ( + <> + + +
+ + + +
+
+ + { + const value = e.target.value + const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/ + if (isNumberReg.test(value)) { + setReplyId(value) + } + }} + /> + + +
+ + ) +} + +export default ReplyInsert diff --git a/napcat.webui/src/components/chat_input/components/rps_insert.tsx b/napcat.webui/src/components/chat_input/components/rps_insert.tsx new file mode 100644 index 00000000..5eb834ee --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/rps_insert.tsx @@ -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 ( + + + + ) +} + +export default RPSInsert diff --git a/napcat.webui/src/components/chat_input/components/show_structed_message.tsx b/napcat.webui/src/components/chat_input/components/show_structed_message.tsx new file mode 100644 index 00000000..b4153dc5 --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/show_structed_message.tsx @@ -0,0 +1,32 @@ +import { Snippet } from '@heroui/snippet' + +import { OB11Segment } from '@/types/onebot' + +export interface ShowStructedMessageProps { + messages: OB11Segment[] +} + +const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => { + return ( + + {JSON.stringify(messages, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + ) +} + +export default ShowStructedMessage diff --git a/napcat.webui/src/components/chat_input/components/video_insert.tsx b/napcat.webui/src/components/chat_input/components/video_insert.tsx new file mode 100644 index 00000000..54398b67 --- /dev/null +++ b/napcat.webui/src/components/chat_input/components/video_insert.tsx @@ -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('') + const videoInputRef = useRef(null) + const showStructuredMessage = useShowStructuredMessage() + const showVideoSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'video', + data: { + file: file + } + } + ] + showStructuredMessage(messages) + } + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setVideoUrl(e.target.value)} + placeholder="请输入视频地址" + /> + + +
+
+
+ + { + 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 diff --git a/napcat.webui/src/components/chat_input/formats/emoji_blot.ts b/napcat.webui/src/components/chat_input/formats/emoji_blot.ts new file mode 100644 index 00000000..47d12364 --- /dev/null +++ b/napcat.webui/src/components/chat_input/formats/emoji_blot.ts @@ -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 diff --git a/napcat.webui/src/components/chat_input/formats/image_blot.ts b/napcat.webui/src/components/chat_input/formats/image_blot.ts new file mode 100644 index 00000000..182727ad --- /dev/null +++ b/napcat.webui/src/components/chat_input/formats/image_blot.ts @@ -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 diff --git a/napcat.webui/src/components/chat_input/formats/reply_blot.ts b/napcat.webui/src/components/chat_input/formats/reply_blot.ts new file mode 100644 index 00000000..8cf15f1d --- /dev/null +++ b/napcat.webui/src/components/chat_input/formats/reply_blot.ts @@ -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 = ` ` + 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 diff --git a/napcat.webui/src/components/chat_input/index.tsx b/napcat.webui/src/components/chat_input/index.tsx new file mode 100644 index 00000000..09784d83 --- /dev/null +++ b/napcat.webui/src/components/chat_input/index.tsx @@ -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(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 ( +
+
+
+ + + + + + + + + + +
+
+ ) +} + +export default ChatInput diff --git a/napcat.webui/src/components/chat_input/modal.tsx b/napcat.webui/src/components/chat_input/modal.tsx new file mode 100644 index 00000000..810da6ef --- /dev/null +++ b/napcat.webui/src/components/chat_input/modal.tsx @@ -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 ( + <> + + + + {(onClose) => ( + <> + + 构造消息 + + +
+ +
+
+ + + + + )} +
+
+ + ) +} diff --git a/napcat.webui/src/components/code_editor.tsx b/napcat.webui/src/components/code_editor.tsx new file mode 100644 index 00000000..9acb9f79 --- /dev/null +++ b/napcat.webui/src/components/code_editor.tsx @@ -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 { + test?: string +} + +export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor + +const CodeEditor = React.forwardRef( + (props, ref) => { + const { isDark } = useTheme() + + const handleEditorDidMount: OnMount = (editor, monaco) => { + if (ref) { + if (typeof ref === 'function') { + ref(editor) + } else { + ;(ref as React.RefObject).current = editor + } + } + if (props.onMount) { + props.onMount(editor, monaco) + } + } + + return ( + + ) + } +) + +export default CodeEditor diff --git a/napcat.webui/src/components/display_card/common_card.tsx b/napcat.webui/src/components/display_card/common_card.tsx new file mode 100644 index 00000000..860d781c --- /dev/null +++ b/napcat.webui/src/components/display_card/common_card.tsx @@ -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 = 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 { + data: NetworkType[T][0] + showType?: boolean + typeLabel: string + fields: NetworkDisplayCardFields + onEdit: () => void + onEnable: () => Promise + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const NetworkDisplayCard = ({ + data, + showType, + typeLabel, + fields, + onEdit, + onEnable, + onDelete, + onEnableDebug +}: NetworkDisplayCardProps) => { + 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 ( + + + + + + + } + enableSwitch={ + + } + tag={showType && typeLabel} + title={name} + > +
+ {fields.map((field, index) => ( +
+ {field.label} + {field.render ? ( + field.render(field.value) + ) : ( + {field.value} + )} +
+ ))} +
+
+ ) +} + +export default NetworkDisplayCard diff --git a/napcat.webui/src/components/display_card/container.tsx b/napcat.webui/src/components/display_card/container.tsx new file mode 100644 index 00000000..047ba6cc --- /dev/null +++ b/napcat.webui/src/components/display_card/container.tsx @@ -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 + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const DisplayCardContainer: React.FC = ({ + title: _title, + action, + tag, + enableSwitch, + children +}) => { + return ( + + + {tag && ( +
+ {tag} +
+ )} +

+ {_title} +

+
{enableSwitch}
+
+ {children} + {action} +
+ ) +} + +export default DisplayCardContainer diff --git a/napcat.webui/src/components/display_card/http_client.tsx b/napcat.webui/src/components/display_card/http_client.tsx new file mode 100644 index 00000000..24f56f89 --- /dev/null +++ b/napcat.webui/src/components/display_card/http_client.tsx @@ -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 + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const HTTPClientDisplayCard: React.FC = (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) => ( + + {value ? '是' : '否'} + + ) + } + ] + + return ( + + ) +} + +export default HTTPClientDisplayCard diff --git a/napcat.webui/src/components/display_card/http_server.tsx b/napcat.webui/src/components/display_card/http_server.tsx new file mode 100644 index 00000000..259d498a --- /dev/null +++ b/napcat.webui/src/components/display_card/http_server.tsx @@ -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 + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const HTTPServerDisplayCard: React.FC = (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) => ( + + {value ? '已启用' : '未启用'} + + ) + }, + { + label: 'WS', + value: enableWebsocket, + render: (value) => ( + + {value ? '已启用' : '未启用'} + + ) + } + ] + + return ( + + ) +} + +export default HTTPServerDisplayCard diff --git a/napcat.webui/src/components/display_card/ws_client.tsx b/napcat.webui/src/components/display_card/ws_client.tsx new file mode 100644 index 00000000..e91c52d2 --- /dev/null +++ b/napcat.webui/src/components/display_card/ws_client.tsx @@ -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 + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const WebsocketClientDisplayCard: React.FC = ( + 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) => ( + + {value ? '是' : '否'} + + ) + } + ] + + return ( + + ) +} + +export default WebsocketClientDisplayCard diff --git a/napcat.webui/src/components/display_card/ws_server.tsx b/napcat.webui/src/components/display_card/ws_server.tsx new file mode 100644 index 00000000..b973987d --- /dev/null +++ b/napcat.webui/src/components/display_card/ws_server.tsx @@ -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 + onDelete: () => Promise + onEnableDebug: () => Promise +} + +const WebsocketServerDisplayCard: React.FC = ( + 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) => ( + + {value ? '是' : '否'} + + ) + }, + { + label: '强制推送事件', + value: enableForcePushEvent, + render: (value) => ( + + {value ? '是' : '否'} + + ) + } + ] + + return ( + + ) +} + +export default WebsocketServerDisplayCard diff --git a/napcat.webui/src/components/display_network_item.tsx b/napcat.webui/src/components/display_network_item.tsx new file mode 100644 index 00000000..87473951 --- /dev/null +++ b/napcat.webui/src/components/display_network_item.tsx @@ -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 = ({ + count, + label, + size = 'md' +}) => { + return ( + + +
+ {count} +
+
+ {label} +
+
+
+ ) +} + +export default NetworkItemDisplay diff --git a/napcat.webui/src/components/effect_card.tsx b/napcat.webui/src/components/effect_card.tsx new file mode 100644 index 00000000..f61ec97b --- /dev/null +++ b/napcat.webui/src/components/effect_card.tsx @@ -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 = (props) => { + const { + children, + maxXRotation = 5, + maxYRotation = 5, + className, + style, + lightClassName, + lightStyle + } = props + const cardRef = React.useRef(null) + const lightRef = React.useRef(null) + const [isShowLight, setIsShowLight] = React.useState(false) + const [pos, setPos] = React.useState({ + left: 0, + top: 0 + }) + + return ( + { + 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) => { + 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)` + } + }} + > +
+ {children} + + ) +} + +export default HoverEffectCard diff --git a/napcat.webui/src/components/error_fallback.tsx b/napcat.webui/src/components/error_fallback.tsx new file mode 100644 index 00000000..a96c9c76 --- /dev/null +++ b/napcat.webui/src/components/error_fallback.tsx @@ -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 ( +
+
+ +

出错了

+
+
+

错误信息

+ {error.message} +
+ +
+ ) +} + +export default errorFallbackRender diff --git a/napcat.webui/src/components/github_info/icon_wrapper.tsx b/napcat.webui/src/components/github_info/icon_wrapper.tsx new file mode 100644 index 00000000..2b5652bc --- /dev/null +++ b/napcat.webui/src/components/github_info/icon_wrapper.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx' + +export interface IconWrapperProps { + children?: React.ReactNode + className?: string +} + +const IconWrapper = ({ children, className }: IconWrapperProps) => ( +
+ {children} +
+) + +export default IconWrapper diff --git a/napcat.webui/src/components/github_info/item_counter.tsx b/napcat.webui/src/components/github_info/item_counter.tsx new file mode 100644 index 00000000..bde66e44 --- /dev/null +++ b/napcat.webui/src/components/github_info/item_counter.tsx @@ -0,0 +1,10 @@ +import { ChevronRightIcon } from '../icons' + +const ItemCounter = ({ number }: { number: number }) => ( +
+ {number} + +
+) + +export default ItemCounter diff --git a/napcat.webui/src/components/github_info/release.tsx b/napcat.webui/src/components/github_info/release.tsx new file mode 100644 index 00000000..5c1a41a5 --- /dev/null +++ b/napcat.webui/src/components/github_info/release.tsx @@ -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 = (props) => { + const { releaseData } = props + const [releaseTime, setReleaseTime] = useState(null) + + useEffect(() => { + if (releaseData) { + const timer = setInterval(() => { + const time = getReleaseTime(releaseData.published_at) + + setReleaseTime(time) + }, 1000) + + return () => clearInterval(timer) + } + }, [releaseData]) + + return ( +
+ Releases +
+ {releaseData.name} +
+ {releaseTime} + Latest +
+
+
+ ) +} + +export default GithubRelease diff --git a/napcat.webui/src/components/hitokoto.tsx b/napcat.webui/src/components/hitokoto.tsx new file mode 100644 index 00000000..ad506b2b --- /dev/null +++ b/napcat.webui/src/components/hitokoto.tsx @@ -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('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 ( +
+
+ {loading && } + {error ? ( +
一言加载失败:{error.message}
+ ) : ( + <> +
{data?.hitokoto}
+
+ —— {data?.from}{' '} + {data?.from_who} +
+ + )} +
+
+ + + + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/icons.tsx b/napcat.webui/src/components/icons.tsx new file mode 100644 index 00000000..59f24b2e --- /dev/null +++ b/napcat.webui/src/components/icons.tsx @@ -0,0 +1,1746 @@ +import * as React from 'react' + +import logo from '@/assets/images/logo.png' +import { IconImageProps, IconSvgProps } from '@/types' + +export const Logo: React.FC = ({ + size = 36, + height, + ...props +}) => ( + // + // + // + Napcat +) + +export const DiscordIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ) +} + +export const TwitterIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ) +} + +export const GithubIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ) +} + +export const MoonFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +) + +export const SunFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +) + +export const HeartFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +) + +export const SearchIcon = (props: IconSvgProps) => ( + +) + +export const NextUILogo: React.FC = (props) => { + const { width, height = 40 } = props + + return ( + + + + + + ) +} + +export const RouteIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + +) + +export const InfoIcon = (props: IconSvgProps) => ( + + + + + + + + + +) + +export const SignalTowerIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + +) + +export const SettingsIcon = (props: IconSvgProps) => ( + + + + + + + + + +) + +export const TerminalIcon = (props: IconSvgProps) => ( + + + + + + + + + + +) + +export const PlusIcon = (props: IconSvgProps) => ( + + + + + + + + + +) + +export const HTTPClientIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const HTTPServerIcon = (props: IconSvgProps) => ( + + + + + + + + + +) + +export const WebsocketIcon = (props: IconSvgProps) => ( + + + + + + + + + +) + +export const PCIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const BugIcon = (props: IconSvgProps) => ( + + + +) + +export const PullRequestIcon = (props: IconSvgProps) => ( + + + +) + +export const ChatIcon = (props: IconSvgProps) => ( + + + + +) + +export const PlayCircleIcon = (props: IconSvgProps) => ( + + + + +) + +export const VolumeHighIcon = (props: IconSvgProps) => { + return ( + + ) +} + +export const VolumeLowIcon = (props: IconSvgProps) => { + return ( + + ) +} + +export const LayoutIcon = (props: IconSvgProps) => ( + + + +) + +export const TagIcon = (props: IconSvgProps) => ( + + + + +) + +export const UsersIcon = (props: IconSvgProps) => ( + + + + +) + +export const WatchersIcon = (props: IconSvgProps) => ( + + + +) + +export const BookIcon = (props: IconSvgProps) => ( + + + + +) + +export const ChevronRightIcon = (props: IconSvgProps) => ( + +) + +export const StarIcon = (props: IconSvgProps) => ( + + + +) + +export const BugIcon2 = (props: IconSvgProps) => ( + +) + +export const WebUIIcon = (props: IconSvgProps) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const BietiaopIcon = (props: IconSvgProps) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) diff --git a/napcat.webui/src/components/input/image_input.tsx b/napcat.webui/src/components/input/image_input.tsx new file mode 100644 index 00000000..0e5bb75e --- /dev/null +++ b/napcat.webui/src/components/input/image_input.tsx @@ -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 = ({ onChange, value, label }) => { + const inputRef = useRef(null) + return ( +
+
+ {label} +
+ { + 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) + } + }} + /> + +
+ ) +} + +export default ImageInput diff --git a/napcat.webui/src/components/log_com/history.tsx b/napcat.webui/src/components/log_com/history.tsx new file mode 100644 index 00000000..40407379 --- /dev/null +++ b/napcat.webui/src/components/log_com/history.tsx @@ -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 = (props) => { + const { + list, + onSelect, + selectedLog, + refreshList, + refreshLog, + listLoading, + logContent, + listError, + logLoading + } = props + const Xterm = useRef(null) + + const [logLevel, setLogLevel] = useState( + 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 ( + <> + 历史日志 - NapCat WebUI + + + + + + + + + + + + + + + ) +} + +export default HistoryLogs diff --git a/napcat.webui/src/components/log_com/log_level_select.tsx b/napcat.webui/src/components/log_com/log_level_select.tsx new file mode 100644 index 00000000..1b78d3f9 --- /dev/null +++ b/napcat.webui/src/components/log_com/log_level_select.tsx @@ -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 ( + + ) +} + +export default LogLevelSelect diff --git a/napcat.webui/src/components/log_com/realtime.tsx b/napcat.webui/src/components/log_com/realtime.tsx new file mode 100644 index 00000000..7c14a827 --- /dev/null +++ b/napcat.webui/src/components/log_com/realtime.tsx @@ -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(null) + const [logLevel, setLogLevel] = useState( + new Set(['info', 'warn', 'error']) + ) + const [dataArr, setDataArr] = useState([]) + + 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 ( + <> + 实时日志 - NapCat WebUI +
+ + +
+
+ +
+ + ) +} + +export default RealTimeLogs diff --git a/napcat.webui/src/components/modal.tsx b/napcat.webui/src/components/modal.tsx new file mode 100644 index 00000000..38a871e8 --- /dev/null +++ b/napcat.webui/src/components/modal.tsx @@ -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['size'] + onClose?: () => void + onConfirm?: () => void + onCancel?: () => void + backdrop?: 'opaque' | 'blur' | 'transparent' + showCancel?: boolean + dismissible?: boolean +} + +const Modal: React.FC = React.memo((props) => { + const { + backdrop = 'blur', + title, + content, + size = 'md', + showCancel = true, + dismissible, + onClose, + onConfirm, + onCancel + } = props + const { onClose: onNativeClose } = useDisclosure() + + return ( + { + onClose?.() + onNativeClose() + }} + size={size} + classNames={{ + backdrop: 'z-[99999999]', + wrapper: 'z-[999999999]' + }} + > + + {(nativeClose) => ( + <> + {title && ( + {title} + )} + {content} + + {showCancel && ( + + )} + + + + )} + + + ) +}) + +Modal.displayName = 'Modal' + +export default Modal diff --git a/napcat.webui/src/components/napcat_repo_info.tsx b/napcat.webui/src/components/napcat_repo_info.tsx new file mode 100644 index 00000000..940d1472 --- /dev/null +++ b/napcat.webui/src/components/napcat_repo_info.tsx @@ -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 + } + + if (loading) { + return + } + + return +} + +export default function NapCatRepoInfo() { + // repo info + const { + data: repoOriData, + error: repoError, + loading: repoLoading + } = useRequest(() => + request.get('https://api.github.com/repos/NapNeko/NapCatQQ') + ) + + // release info + const { + data: releaseOriData, + error: releaseError, + loading: releaseLoading + } = useRequest(() => + request.get( + 'https://api.github.com/repos/NapNeko/NapCatQQ/releases' + ) + ) + + // pr info + const { + data: prData, + error: prError, + loading: prLoading + } = useRequest(() => + request.get( + 'https://api.github.com/repos/NapNeko/NapCatQQ/pulls' + ) + ) + + // contributors info + const { + data: contributorsData, + error: contributorsError, + loading: contributorsLoading + } = useRequest(() => + request.get( + '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 ( + { + 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) + } + }} + > + + + + } + > + Star + + + + + } + > + Issues + + + + + } + > + Pull Requests + + + ) : releaseLoading ? ( + + ) : ( + + ) + } + startContent={ + + + + } + textValue="Releases" + > + {releaseData && } + + + + + } + > + Contributors + + + + + } + > + Watchers + + + {repoData?.license?.name ?? 'unknown'} + + } + startContent={ + + + + } + > + License + + + ) +} diff --git a/napcat.webui/src/components/network_edit/generic_form.tsx b/napcat.webui/src/components/network_edit/generic_form.tsx new file mode 100644 index 00000000..e8dcae92 --- /dev/null +++ b/napcat.webui/src/components/network_edit/generic_form.tsx @@ -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 { + 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 { + data?: NetworkConfigType[T][0] + defaultValues: DefaultValues + onClose: () => void + onSubmit: (data: NetworkConfigType[T][0]) => Promise + fields: Array> +} + +const GenericForm = ({ + data, + defaultValues, + onClose, + onSubmit, + fields +}: GenericFormProps): ReactElement => { + const { control, handleSubmit, formState, setValue, reset } = useForm< + NetworkConfigType[T][0] + >({ + defaultValues + }) + + const submitAction: SubmitHandler = 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[] + for (const key of keys) { + const value = data[key] as PathValue< + NetworkConfig[T][0], + Path + > + setValue(key, value) + } + } else { + reset() + } + }, [data, reset, setValue]) + + return ( + <> + +
+ {fields.map((field) => ( +
+ } + rules={ + field.isRequired + ? { + required: `请填写${field.label}` + } + : void 0 + } + render={({ field: controllerField }) => { + switch (field.type) { + case 'input': + return ( + + ) + case 'select': + return ( + + ) + case 'switch': + return ( + + ) + default: + return <> + } + }} + /> +
+ ))} +
+
+ + + + + + ) +} + +export default GenericForm diff --git a/napcat.webui/src/components/network_edit/http_client.tsx b/napcat.webui/src/components/network_edit/http_client.tsx new file mode 100644 index 00000000..dfbb402a --- /dev/null +++ b/napcat.webui/src/components/network_edit/http_client.tsx @@ -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 +} + +type HTTPClientFormType = OneBotConfig['network']['httpClients'] + +const HTTPClientForm: React.FC = ({ + 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 ( + + ) +} + +export default HTTPClientForm diff --git a/napcat.webui/src/components/network_edit/http_server.tsx b/napcat.webui/src/components/network_edit/http_server.tsx new file mode 100644 index 00000000..f1a9670f --- /dev/null +++ b/napcat.webui/src/components/network_edit/http_server.tsx @@ -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 +} + +type HTTPServerFormType = OneBotConfig['network']['httpServers'] + +const HTTPServerForm: React.FC = ({ + 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 ( + + ) +} + +export default HTTPServerForm diff --git a/napcat.webui/src/components/network_edit/modal.tsx b/napcat.webui/src/components/network_edit/modal.tsx new file mode 100644 index 00000000..b451f043 --- /dev/null +++ b/napcat.webui/src/components/network_edit/modal.tsx @@ -0,0 +1,113 @@ +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 WebsocketClientForm from './ws_client' +import WebsocketServerForm from './ws_server' + +const modalTitle = { + httpServers: 'HTTP Server', + httpClients: 'HTTP Client', + 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 = ( + props: NetworkFormModalProps +) => { + 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 ( + + ) + case 'httpClients': + return ( + + ) + case 'websocketServers': + return ( + + ) + case 'websocketClients': + return ( + + ) + default: + return null + } + } + + return ( + + + {(onClose) => ( + <> + + {modalTitle[field]} + + {renderFormComponent(onClose)} + + )} + + + ) +} + +export default NetworkFormModal diff --git a/napcat.webui/src/components/network_edit/ws_client.tsx b/napcat.webui/src/components/network_edit/ws_client.tsx new file mode 100644 index 00000000..da67b8e4 --- /dev/null +++ b/napcat.webui/src/components/network_edit/ws_client.tsx @@ -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 +} + +type WebsocketClientFormType = OneBotConfig['network']['websocketClients'] + +const WebsocketClientForm: React.FC = ({ + 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 ( + + ) +} + +export default WebsocketClientForm diff --git a/napcat.webui/src/components/network_edit/ws_server.tsx b/napcat.webui/src/components/network_edit/ws_server.tsx new file mode 100644 index 00000000..c508c561 --- /dev/null +++ b/napcat.webui/src/components/network_edit/ws_server.tsx @@ -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 +} + +type WebsocketServerFormType = OneBotConfig['network']['websocketServers'] + +const WebsocketServerForm: React.FC = ({ + 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 ( + + ) +} + +export default WebsocketServerForm diff --git a/napcat.webui/src/components/onebot/api/debug.tsx b/napcat.webui/src/components/onebot/api/debug.tsx new file mode 100644 index 00000000..17d2ba51 --- /dev/null +++ b/napcat.webui/src/components/onebot/api/debug.tsx @@ -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 = (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 ( +
+

+ + {data.description} +

+

+ } + > + {path} + +

+
+ + setHttpConfig({ ...httpConfig, url: e.target.value }) + } + /> + + setHttpConfig({ ...httpConfig, token: e.target.value }) + } + /> + +
+ + + 请求体 + + + + + setRequestBody(value ?? '')} + language="json" + height="400px" + /> + +
+ + +
+
+
+
+ + + + 响应 + + + + + +
+              
+                {responseContent || (
+                  
暂无响应
+ )} +
+
+
+
+
+
+

请求体结构

+ +

响应体结构

+ +
+
+ ) +} + +export default OneBotApiDebug diff --git a/napcat.webui/src/components/onebot/api/display_struct.tsx b/napcat.webui/src/components/onebot/api/display_struct.tsx new file mode 100644 index 00000000..86d9b6d9 --- /dev/null +++ b/napcat.webui/src/components/onebot/api/display_struct.tsx @@ -0,0 +1,202 @@ +import { Chip } from '@heroui/chip' +import { Tooltip } from '@heroui/tooltip' +import { motion } from 'motion/react' +import React, { useState } from 'react' +import toast from 'react-hot-toast' +import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb' + +import type { LiteralValue, ParsedSchema } from '@/utils/zod' + +interface DisplayStructProps { + schema: ParsedSchema | ParsedSchema[] +} + +const SchemaType = ({ + type, + value +}: { + type: string + value?: LiteralValue +}) => { + let name = type + switch (type) { + case 'union': + name = '联合类型' + break + case 'value': + name = '固定值' + break + } + let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' = + 'primary' + switch (type) { + case 'enum': + chipColor = 'warning' + break + case 'union': + chipColor = 'secondary' + break + case 'array': + chipColor = 'danger' + break + case 'object': + chipColor = 'success' + break + } + + return ( + + {name} + {type === 'value' && ( + + {value} + + )} + + ) +} + +const SchemaLabel: React.FC<{ + schema: ParsedSchema +}> = ({ schema }) => ( + <> + {Array.isArray(schema.type) ? ( + schema.type.map((type) => ( + + )) + ) : ( + + )} + {schema.optional && ( + + 可选 + + )} + {schema.description && ( + {schema.description} + )} + +) + +const SchemaContainer: React.FC<{ + schema: ParsedSchema + children: React.ReactNode +}> = ({ schema, children }) => { + const [expanded, setExpanded] = useState(false) + + const toggleExpand = () => setExpanded(!expanded) + + return ( +
+
+ + + + + { + e.stopPropagation() + navigator.clipboard.writeText(schema.name || '') + toast.success('已复制') + }} + > + {schema.name} + + + +
+ +
+ {children} +
+
+ ) +} + +const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => { + if (schema.type === 'object') { + return ( + + {schema.children && schema.children.length > 0 ? ( + schema.children.map((child, i) => ( + + )) + ) : ( +
{`{}`}
+ )} +
+ ) + } + + if (schema.type === 'array' || schema.type === 'union') { + return ( + + {schema.children?.map((child, i) => ( + + ))} + + ) + } + + if (schema.type === 'enum' && Array.isArray(schema.enum)) { + return ( + +
+ {schema.enum?.map((value, i) => ( + + {value?.toString()} + + ))} +
+
+ ) + } + + return ( +
+ + { + e.stopPropagation() + navigator.clipboard.writeText(schema.name || '') + toast.success('已复制') + }} + > + {schema.name} + + + +
+ ) +} + +const DisplayStruct: React.FC = ({ schema }) => { + return ( +
+ {Array.isArray(schema) ? ( + schema.map((s, i) => ) + ) : ( + + )} +
+ ) +} + +export default DisplayStruct diff --git a/napcat.webui/src/components/onebot/api/nav_list.tsx b/napcat.webui/src/components/onebot/api/nav_list.tsx new file mode 100644 index 00000000..ba742a39 --- /dev/null +++ b/napcat.webui/src/components/onebot/api/nav_list.tsx @@ -0,0 +1,87 @@ +import { Card, CardBody } from '@heroui/card' +import { Input } from '@heroui/input' +import clsx from 'clsx' +import { motion } from 'motion/react' +import { useState } from 'react' + +import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api' + +export interface OneBotApiNavListProps { + data: OneBotHttpApi + selectedApi: OneBotHttpApiPath + onSelect: (apiName: OneBotHttpApiPath) => void + openSideBar: boolean +} + +const OneBotApiNavList: React.FC = (props) => { + const { data, selectedApi, onSelect, openSideBar } = props + const [searchValue, setSearchValue] = useState('') + return ( + +
+ setSearchValue(e.target.value)} + isClearable + onClear={() => setSearchValue('')} + /> + {Object.entries(data).map(([apiName, api]) => ( + onSelect(apiName as OneBotHttpApiPath)} + > + +

{api.description}

+
+ {apiName} +
+
+
+ ))} +
+
+ ) +} + +export default OneBotApiNavList diff --git a/napcat.webui/src/components/onebot/display_card/message.tsx b/napcat.webui/src/components/onebot/display_card/message.tsx new file mode 100644 index 00000000..a85686f6 --- /dev/null +++ b/napcat.webui/src/components/onebot/display_card/message.tsx @@ -0,0 +1,122 @@ +import { Avatar } from '@heroui/avatar' +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover' +import clsx from 'clsx' + +import { isOB11GroupMessage } from '@/utils/onebot' + +import type { + OB11GroupMessage, + OB11Message, + OB11PrivateMessage +} from '@/types/onebot' + +import { renderMessageContent } from '../render_message' + +export interface OneBotMessageProps { + data: OB11Message +} + +export interface OneBotMessageGroupProps { + data: OB11GroupMessage +} + +export interface OneBotMessagePrivateProps { + data: OB11PrivateMessage +} + +const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => { + return ( +
+
+
+ {isOB11GroupMessage(data) && data.sender.card && ( + {data.sender.card} + )} + + {data.sender.nickname} + +
+
({data.sender.user_id})
+
消息ID: {data.message_id}
+
+ + +
+
+ 点击查看完整消息 +
+ {Array.isArray(data.message) + ? renderMessageContent(data.message, true) + : data.raw_message} +
+
+ +
+ {Array.isArray(data.message) + ? renderMessageContent(data.message) + : data.raw_message} +
+
+
+
+ ) +} + +const OneBotMessageGroup: React.FC = ({ data }) => { + return ( +
+
+ +
群 {data.group_id}
+
+
+ + +
+
+ ) +} + +const OneBotMessagePrivate: React.FC = ({ + data +}) => { + return ( +
+ + +
+ ) +} + +const OneBotMessage: React.FC = ({ data }) => { + if (data.message_type === 'group') { + return + } else if (data.message_type === 'private') { + return + } else { + return
未知消息类型
+ } +} + +export default OneBotMessage diff --git a/napcat.webui/src/components/onebot/display_card/meta.tsx b/napcat.webui/src/components/onebot/display_card/meta.tsx new file mode 100644 index 00000000..9585456b --- /dev/null +++ b/napcat.webui/src/components/onebot/display_card/meta.tsx @@ -0,0 +1,60 @@ +import { Chip } from '@heroui/chip' + +import { getLifecycleColor, getLifecycleName } from '@/utils/onebot' + +import type { + OB11Meta, + OneBot11Heartbeat, + OneBot11Lifecycle +} from '@/types/onebot' + +export interface OneBotDisplayMetaProps { + data: OB11Meta +} + +export interface OneBotDisplayMetaHeartbeatProps { + data: OneBot11Heartbeat +} + +export interface OneBotDisplayMetaLifecycleProps { + data: OneBot11Lifecycle +} + +const OneBotDisplayMetaHeartbeat: React.FC = ({ + data +}) => { + return ( +
+ 心跳 + 间隔 {data.status.interval}ms +
+ ) +} + +const OneBotDisplayMetaLifecycle: React.FC = ({ + data +}) => { + return ( +
+ 生命周期 + + {getLifecycleName(data.sub_type)} + +
+ ) +} + +const OneBotDisplayMeta: React.FC = ({ data }) => { + return ( +
+ {data.meta_event_type === 'lifecycle' && ( + + )} + {data.meta_event_type === 'heartbeat' && ( + + )} +
+ ) +} + +export default OneBotDisplayMeta diff --git a/napcat.webui/src/components/onebot/display_card/notice.tsx b/napcat.webui/src/components/onebot/display_card/notice.tsx new file mode 100644 index 00000000..9949530f --- /dev/null +++ b/napcat.webui/src/components/onebot/display_card/notice.tsx @@ -0,0 +1,292 @@ +import { Chip } from '@heroui/chip' + +import { getNoticeTypeName } from '@/utils/onebot' + +import { + OB11Notice, + OB11NoticeType, + OneBot11FriendAdd, + OneBot11FriendRecall, + OneBot11GroupAdmin, + OneBot11GroupBan, + OneBot11GroupCard, + OneBot11GroupDecrease, + OneBot11GroupEssence, + OneBot11GroupIncrease, + OneBot11GroupMessageReaction, + OneBot11GroupRecall, + OneBot11GroupUpload, + OneBot11Honor, + OneBot11LuckyKing, + OneBot11Poke +} from '@/types/onebot' + +export interface OneBotNoticeProps { + data: OB11Notice +} + +export interface NoticeProps { + data: T +} + +const GroupUploadNotice: React.FC> = ({ + data +}) => { + const { group_id, user_id, file } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
文件名: {file.name}
+
文件大小: {file.size} 字节
+ + ) +} + +const GroupAdminNotice: React.FC> = ({ + data +}) => { + const { group_id, user_id, sub_type } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
变动类型: {sub_type === 'set' ? '设置管理员' : '取消管理员'}
+ + ) +} + +const GroupDecreaseNotice: React.FC> = ({ + data +}) => { + const { group_id, operator_id, user_id, sub_type } = data + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
原因: {sub_type}
+ + ) +} + +const GroupIncreaseNotice: React.FC> = ({ + data +}) => { + const { group_id, operator_id, user_id, sub_type } = data + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
增加类型: {sub_type}
+ + ) +} + +const GroupBanNotice: React.FC> = ({ data }) => { + const { group_id, operator_id, user_id, sub_type, duration } = data + return ( + <> +
群号: {group_id}
+
操作者ID: {operator_id}
+
用户ID: {user_id}
+
禁言类型: {sub_type}
+
禁言时长: {duration} 秒
+ + ) +} + +const FriendAddNotice: React.FC> = ({ + data +}) => { + const { user_id } = data + return ( + <> +
用户ID: {user_id}
+ + ) +} + +const GroupRecallNotice: React.FC> = ({ + data +}) => { + const { group_id, user_id, operator_id, message_id } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
操作者ID: {operator_id}
+
消息ID: {message_id}
+ + ) +} + +const FriendRecallNotice: React.FC> = ({ + data +}) => { + const { user_id, message_id } = data + return ( + <> +
用户ID: {user_id}
+
消息ID: {message_id}
+ + ) +} + +const PokeNotice: React.FC> = ({ data }) => { + const { group_id, user_id, target_id } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
目标ID: {target_id}
+ + ) +} + +const LuckyKingNotice: React.FC> = ({ + data +}) => { + const { group_id, user_id, target_id } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
目标ID: {target_id}
+ + ) +} + +const HonorNotice: React.FC> = ({ data }) => { + const { group_id, user_id, honor_type } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
荣誉类型: {honor_type}
+ + ) +} + +const GroupMessageReactionNotice: React.FC< + NoticeProps +> = ({ data }) => { + const { group_id, user_id, message_id, likes } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
消息ID: {message_id}
+
+ 表情回应: + {likes + .map((like) => `表情ID: ${like.emoji_id}, 数量: ${like.count}`) + .join(', ')} +
+ + ) +} + +const GroupEssenceNotice: React.FC> = ({ + data +}) => { + const { group_id, message_id, sender_id, operator_id, sub_type } = data + return ( + <> +
群号: {group_id}
+
消息ID: {message_id}
+
发送者ID: {sender_id}
+
操作者ID: {operator_id}
+
操作类型: {sub_type}
+ + ) +} + +const GroupCardNotice: React.FC> = ({ + data +}) => { + const { group_id, user_id, card_new, card_old } = data + return ( + <> +
群号: {group_id}
+
用户ID: {user_id}
+
新名片: {card_new}
+
旧名片: {card_old}
+ + ) +} + +const OneBotNotice: React.FC = ({ data }) => { + let NoticeComponent: React.ReactNode + switch (data.notice_type) { + case OB11NoticeType.GroupUpload: + NoticeComponent = + break + case OB11NoticeType.GroupAdmin: + NoticeComponent = + break + case OB11NoticeType.GroupDecrease: + NoticeComponent = + break + case OB11NoticeType.GroupIncrease: + NoticeComponent = ( + + ) + break + case OB11NoticeType.GroupBan: + NoticeComponent = + break + case OB11NoticeType.FriendAdd: + NoticeComponent = + break + case OB11NoticeType.GroupRecall: + NoticeComponent = + break + case OB11NoticeType.FriendRecall: + NoticeComponent = ( + + ) + break + case OB11NoticeType.Notify: + switch (data.sub_type) { + case 'poke': + NoticeComponent = + break + case 'lucky_king': + NoticeComponent = + break + case 'honor': + NoticeComponent = + break + } + break + case OB11NoticeType.GroupMsgEmojiLike: + NoticeComponent = ( + + ) + break + case OB11NoticeType.GroupEssence: + NoticeComponent = ( + + ) + break + case OB11NoticeType.GroupCard: + NoticeComponent = + break + } + + return ( +
+ + 通知 + + {getNoticeTypeName(data.notice_type)} + {NoticeComponent} +
+ ) +} + +export default OneBotNotice diff --git a/napcat.webui/src/components/onebot/display_card/render.tsx b/napcat.webui/src/components/onebot/display_card/render.tsx new file mode 100644 index 00000000..70e5c571 --- /dev/null +++ b/napcat.webui/src/components/onebot/display_card/render.tsx @@ -0,0 +1,151 @@ +import { Button } from '@heroui/button' +import { Card, CardBody, CardHeader } from '@heroui/card' +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover' +import { Snippet } from '@heroui/snippet' +import { motion } from 'motion/react' +import { IoCode } from 'react-icons/io5' + +import OneBotDisplayMeta from '@/components/onebot/display_card/meta' + +import { getEventName, isOB11Event } from '@/utils/onebot' +import { timestampToDateString } from '@/utils/time' + +import type { + AllOB11WsResponse, + OB11AllEvent, + OB11Request +} from '@/types/onebot' + +import OneBotMessage from './message' +import OneBotNotice from './notice' +import OneBotDisplayResponse from './response' + +const itemVariants = { + hidden: { opacity: 0, scale: 0.8, y: 50 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { type: 'spring', stiffness: 300, damping: 20 } + } +} + +function RequestComponent({ data: _ }: { data: OB11Request }) { + return
Request消息,暂未适配
+} + +export interface OneBotItemRenderProps { + data: AllOB11WsResponse[] + index: number + style: React.CSSProperties +} + +export const getItemSize = (event: OB11AllEvent['post_type']) => { + if (event === 'meta_event') { + return 100 + } + if (event === 'message') { + return 180 + } + if (event === 'request') { + return 100 + } + if (event === 'notice') { + return 100 + } + if (event === 'message_sent') { + return 250 + } + return 100 +} + +const renderDetail = (data: AllOB11WsResponse) => { + if (isOB11Event(data)) { + switch (data.post_type) { + case 'meta_event': + return + case 'message': + return + case 'request': + return + case 'notice': + return + case 'message_sent': + return + default: + return
未知类型的消息
+ } + } + return +} + +const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => { + const msg = data[index] + const isEvent = isOB11Event(msg) + return ( +
+ + + +
+ {isEvent ? getEventName(msg.post_type) : '请求响应'} +
+
+ {isEvent && timestampToDateString(msg.time)} +
+
+ + + + + + + {JSON.stringify(msg, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + + +
+
+ {renderDetail(msg)} +
+
+
+ ) +} + +export default OneBotItemRender diff --git a/napcat.webui/src/components/onebot/display_card/response.tsx b/napcat.webui/src/components/onebot/display_card/response.tsx new file mode 100644 index 00000000..2fc80df8 --- /dev/null +++ b/napcat.webui/src/components/onebot/display_card/response.tsx @@ -0,0 +1,75 @@ +import { Button } from '@heroui/button' +import { Chip } from '@heroui/chip' +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover' +import { Snippet } from '@heroui/snippet' + +import { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot' + +import { RequestResponse } from '@/types/onebot' + +export interface OneBotDisplayResponseProps { + data: RequestResponse +} + +const OneBotDisplayResponse: React.FC = ({ + data +}) => { + return ( +
+ + {getResponseStatusText(data.status)} + + {data.data && ( + + + + + + + {JSON.stringify(data.data, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + + + )} + {data.message && ( + + + 返回消息 + + {data.message} + + )} +
+ ) +} + +export default OneBotDisplayResponse diff --git a/napcat.webui/src/components/onebot/filter_message_type.tsx b/napcat.webui/src/components/onebot/filter_message_type.tsx new file mode 100644 index 00000000..6c08160f --- /dev/null +++ b/napcat.webui/src/components/onebot/filter_message_type.tsx @@ -0,0 +1,58 @@ +import { Select, SelectItem } from '@heroui/select' +import { SharedSelection } from '@heroui/system' +import type { Selection } from '@react-types/shared' + +export interface FilterMessageTypeProps { + filterTypes: Selection + onSelectionChange: (keys: SharedSelection) => void +} +const items = [ + { label: '元事件', value: 'meta_event' }, + { label: '消息', value: 'message' }, + { label: '请求', value: 'request' }, + { label: '通知', value: 'notice' }, + { label: '消息发送', value: 'message_sent' } +] +const FilterMessageType: React.FC = (props) => { + const { filterTypes, onSelectionChange } = props + return ( + + ) +} + +export const renderFilterMessageType = ( + filterTypes: Selection, + onSelectionChange: (keys: SharedSelection) => void +) => { + return ( + + ) +} + +export default FilterMessageType diff --git a/napcat.webui/src/components/onebot/message_list.tsx b/napcat.webui/src/components/onebot/message_list.tsx new file mode 100644 index 00000000..016915c7 --- /dev/null +++ b/napcat.webui/src/components/onebot/message_list.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react' +import { VariableSizeList } from 'react-window' + +import OneBotItemRender, { + getItemSize +} from '@/components/onebot/display_card/render' + +import { isOB11Event } from '@/utils/onebot' + +import type { AllOB11WsResponse } from '@/types/onebot' + +export interface OneBotMessageListProps { + messages: AllOB11WsResponse[] +} + +const OneBotMessageList: React.FC = (props) => { + const { messages } = props + const containerRef = useRef(null) + const listRef = useRef(null) + const [containerHeight, setContainerHeight] = useState(400) + + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + if (containerRef.current) { + setContainerHeight(containerRef.current.offsetHeight) + } + }) + + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => { + resizeObserver.disconnect() + } + }, []) + + useEffect(() => { + if (listRef.current) { + listRef.current.resetAfterIndex(0, true) + } + }, [messages]) + + return ( +
+ { + const msg = messages[idx] + if (isOB11Event(msg)) { + const size = getItemSize(msg.post_type) + return size + } else { + return 100 + } + }} + height={containerHeight} + itemData={messages} + itemKey={(index) => messages.length - index - 1} + > + {OneBotItemRender} + +
+ ) +} + +export default OneBotMessageList diff --git a/napcat.webui/src/components/onebot/render_message.tsx b/napcat.webui/src/components/onebot/render_message.tsx new file mode 100644 index 00000000..6cbb0879 --- /dev/null +++ b/napcat.webui/src/components/onebot/render_message.tsx @@ -0,0 +1,164 @@ +import { Image } from '@heroui/image' +import qface from 'qface' +import { FaReply } from 'react-icons/fa6' + +import { OB11Segment } from '@/types/onebot' + +export const renderMessageContent = ( + segments: OB11Segment[], + small = false +): React.ReactElement[] => { + return segments.map((segment, index) => { + switch (segment.type) { + case 'text': + return {segment.data.text} + case 'face': + return ( + {`face-${segment.data.id}`} + ) + case 'image': + return ( + image + ) + case 'record': + return ( +