Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
edc14763f0 Improve fix: only suppress card change events for forward messages
Changed the condition to specifically check for forward message elements (multiForwardMsgElement or arkElement) before suppressing empty card name changes. This ensures legitimate card name clearing (setting to empty) still works correctly while preventing false positives from forward messages.

Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:43:50 +00:00
copilot-swe-agent[bot]
6a6a0e0539 Fix spurious group_card events for forward messages
Prevent false group card change events when sending forward messages with comments. The issue occurred because forward messages sometimes have empty sendMemberName fields, triggering incorrect "name → empty" card change events.

Solution: Skip card change detection when the new card name is empty but the old card name is not empty, as this indicates unreliable data (e.g., from forward messages).

Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:26:10 +00:00
copilot-swe-agent[bot]
b930eea84f Initial plan 2025-10-02 01:16:56 +00:00
686 changed files with 38227 additions and 39776 deletions

View File

@@ -15,10 +15,10 @@ charset = utf-8
# 4 space indentation
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
indent_style = space
indent_size = 2
indent_size = 4
[*.bat]
charset = latin1
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.

1
.gitignore vendored
View File

@@ -14,4 +14,3 @@ devconfig/*
*.db
checkVersion.sh
bun.lockb
tests/run/

10
.prettierrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 120,
"endOfLine": "auto"
}

30
.vscode/settings.json vendored
View File

@@ -3,35 +3,15 @@
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
".env.universal": ".env.*",
"vite.config.ts": "vite*.ts",
"README.md": "CODE_OF_CONDUCT.md, RELEASES.md, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md",
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
},
"css.customData": [
".vscode/tailwindcss.json"
],
"editor.detectIndentation": false,
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.formatOnPaste": true,
"editor.formatOnSaveMode": "file",
"editor.formatOnPaste": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
"source.fixAll.eslint": "never"
},
"files.autoSave": "onFocusChange",
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceAfterConstructor": true,
"javascript.format.insertSpaceAfterConstructor": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.disableAutomaticTypeAcquisition": true,
}
}

View File

@@ -3,6 +3,8 @@
# NapCat
_Modern protocol-side framework implemented based on NTQQ._
> 云起兮风生,心向远方兮路未曾至.
@@ -12,7 +14,6 @@ _Modern protocol-side framework implemented based on NTQQ._
---
## New Feature
在 v4.8.115+ 版本开始
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
@@ -22,21 +23,19 @@ _Modern protocol-side framework implemented based on NTQQ._
- [2] 采用字符串可以解决扩展到int64的问题同时也可以解决部分语言如JavaScript对大整数支持不佳的问题增加极少成本。
## Welcome
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## Feature
- **Easy to Use**
+ **Easy to Use**
- 作为初学者能够轻松使用.
- **Quick and Efficient**
+ **Quick and Efficient**
- 在低内存操作系统长时运行.
- **Rich API Interface**
+ **Rich API Interface**
- 完整实现了大部分标准接口.
- **Stable and Reliable**
+ **Stable and Reliable**
- 持续稳定的开发与维护.
## Quick Start
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
@@ -44,7 +43,6 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
@@ -66,22 +64,20 @@ _Modern protocol-side framework implemented based on NTQQ._
## Thanks
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
+ [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
- 不过最最重要的 还是需要感谢屏幕前的你哦~
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
---
## License
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
1. 第三方库代码或修改部分遵循其原始开源许可.
2. 本项目获取部分项目授权而不受部分约束
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).

View File

@@ -1,52 +1,32 @@
import neostandard from 'neostandard';
import eslint from '@eslint/js';
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
import globals from "globals";
/** 尾随逗号 */
const commaDangle = val => {
if (val?.rules?.['@stylistic/comma-dangle']?.[0] === 'warn') {
const rule = val?.rules?.['@stylistic/comma-dangle']?.[1];
Object.keys(rule).forEach(key => {
rule[key] = 'always-multiline';
});
val.rules['@stylistic/comma-dangle'][1] = rule;
}
/** 三元表达式 */
if (val?.rules?.['@stylistic/indent']) {
val.rules['@stylistic/indent'][2] = {
...val.rules?.['@stylistic/indent']?.[2],
flatTernaryExpressions: true,
offsetTernaryExpressions: false,
};
}
/** 支持下划线 - 禁用 camelcase 规则 */
if (val?.rules?.camelcase) {
val.rules.camelcase = 'off';
}
/** 未使用的变量强制报错 */
if (val?.rules?.['@typescript-eslint/no-unused-vars']) {
val.rules['@typescript-eslint/no-unused-vars'] = ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}];
}
return val;
};
/** 忽略的文件 */
const ignores = [
'node_modules',
'**/dist/**',
'launcher',
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
NodeJS: 'readonly', // 添加 NodeJS 全局变量
},
},
files: ['**/*.{ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules,
'quotes': ['error', 'single'], // 使用单引号
'semi': ['error', 'always'], // 强制使用分号
'indent': ['error', 4], // 使用 4 空格缩进
},
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
},
];
const options = neostandard({
ts: true,
ignores,
semi: true, // 强制使用分号
}).map(commaDangle);
export default options;
export default [eslint.configs.recommended, ...customTsFlatConfig];

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" (
echo provided QQ path is invalid

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" (
echo provided QQ path is invalid

View File

@@ -1,9 +1,9 @@
{
"name": "qq-chat",
"verHash": "2c9d3f6c",
"version": "9.9.22-40990",
"linuxVersion": "3.2.20-40990",
"linuxVerHash": "ec800879",
"verHash": "cc326038",
"version": "9.9.21-39038",
"linuxVersion": "3.2.19-39038",
"linuxVerHash": "c773cdf7",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js"
},
"main": "./loadNapCat.js",
"buildVersion": "40990",
"buildVersion": "39038",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.9.27",
"version": "4.8.116",
"icon": "./logo.png",
"authors": [
{

View File

@@ -0,0 +1,7 @@
dist
*.md
*.html
yarn.lock
package-lock.json
node_modules
pnpm-lock.yaml

23
napcat.webui/.prettierrc Normal file
View File

@@ -0,0 +1,23 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"semi": false,
"trailingComma": "none",
"bracketSpacing": true,
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^@/const/(.*)$",
"^@/store/(.*)$",
"^@/components/(.*)$",
"^@/contexts/(.*)$",
"^@/hooks/(.*)$",
"^@/utils/(.*)$",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -1,2 +1,91 @@
import eslintConfig from '../eslint.config.mjs';
export default eslintConfig;
import eslint_js from '@eslint/js'
import tsEslintPlugin from '@typescript-eslint/eslint-plugin'
import tsEslintParser from '@typescript-eslint/parser'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import reactPlugin from 'eslint-plugin-react'
import reactHooksPlugin from 'eslint-plugin-react-hooks'
import globals from 'globals'
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module'
},
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules
},
plugins: {
'@typescript-eslint': tsEslintPlugin
}
}
]
export default [
eslint_js.configs.recommended,
eslintPluginPrettierRecommended,
...customTsFlatConfig,
{
name: 'global config',
languageOptions: {
globals: {
...globals.es2022,
...globals.browser,
...globals.node
},
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false
}
},
rules: {
'prettier/prettier': 'error',
'no-unused-vars': 'off',
'no-undef': 'off',
//关闭不能再promise中使用ansyc
'no-async-promise-executor': 'off',
//关闭不能再常量中使用??
'no-constant-binary-expression': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-unused-vars': 'off',
//禁止失去精度的字面数字
'@typescript-eslint/no-loss-of-precision': 'off',
//禁止使用any
'@typescript-eslint/no-explicit-any': 'error'
}
},
{
ignores: ['**/node_modules', '**/dist', '**/output']
},
{
name: 'react-eslint',
files: ['src/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin
},
languageOptions: {
...reactPlugin.configs.recommended.languageOptions
},
rules: {
...reactPlugin.configs.recommended.rules,
'react/react-in-jsx-scope': 'off'
},
settings: {
react: {
// 需要显示安装 react
version: 'detect'
}
}
},
{
languageOptions: { globals: { ...globals.browser, ...globals.node } }
},
eslintConfigPrettier
]

View File

@@ -86,6 +86,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/crypto-js": "^4.2.2",
@@ -96,14 +97,20 @@
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3",
"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.5.1",
"prettier": "^3.4.2",
"typescript": "^5.7.3",

View File

@@ -1,33 +1,33 @@
import { Suspense, lazy, useEffect } from 'react';
import { Provider } from 'react-redux';
import { Route, Routes, useNavigate } from 'react-router-dom';
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 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 DialogProvider from '@/contexts/dialog'
import AudioProvider from '@/contexts/songs'
import useAuth from '@/hooks/auth';
import useAuth from '@/hooks/auth'
import store from '@/store';
import store from '@/store'
const WebLoginPage = lazy(() => import('@/pages/web_login'));
const IndexPage = lazy(() => import('@/pages/index'));
const QQLoginPage = lazy(() => import('@/pages/qq_login'));
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'));
const AboutPage = lazy(() => import('@/pages/dashboard/about'));
const ConfigPage = lazy(() => import('@/pages/dashboard/config'));
const DebugPage = lazy(() => import('@/pages/dashboard/debug'));
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'));
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'));
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const WebLoginPage = lazy(() => import('@/pages/web_login'))
const IndexPage = lazy(() => import('@/pages/index'))
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
function App () {
function App() {
return (
<DialogProvider>
<Provider store={store}>
@@ -42,49 +42,49 @@ function App () {
</AudioProvider>
</Provider>
</DialogProvider>
);
)
}
function AuthChecker ({ children }: { children: React.ReactNode }) {
const { isAuth } = useAuth();
const navigate = useNavigate();
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';
const search = new URLSearchParams(window.location.search)
const token = search.get('token')
let url = '/web_login'
if (token) {
url += `?token=${token}`;
url += `?token=${token}`
}
navigate(url, { replace: true });
navigate(url, { replace: true })
}
}, [isAuth, navigate]);
}, [isAuth, navigate])
return <>{children}</>;
return <>{children}</>
}
function AppRoutes () {
function AppRoutes() {
return (
<Routes>
<Route path='/' element={<IndexPage />}>
<Route path="/" element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<Route path='network' element={<NetworkPage />} />
<Route path='config' element={<ConfigPage />} />
<Route path='logs' element={<LogsPage />} />
<Route path='debug' element={<DebugPage />}>
<Route path='ws' element={<WSDebug />} />
<Route path='http' element={<HttpDebug />} />
<Route path="network" element={<NetworkPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="debug" element={<DebugPage />}>
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} />
<Route path='about' element={<AboutPage />} />
<Route path="file_manager" element={<FileManagerPage />} />
<Route path="terminal" element={<TerminalPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />
<Route path='/web_login' element={<WebLoginPage />} />
<Route path="/qq_login" element={<QQLoginPage />} />
<Route path="/web_login" element={<WebLoginPage />} />
</Routes>
);
)
}
export default App;
export default App

View File

@@ -1,6 +1,6 @@
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import React from 'react';
import { ColorResult, SketchPicker } from 'react-color';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import React from 'react'
import { ColorResult, SketchPicker } from 'react-color'
// 假定 heroui 提供的 Popover组件
@@ -11,14 +11,14 @@ interface ColorPickerProps {
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
const handleChange = (colorResult: ColorResult) => {
onChange(colorResult);
};
onChange(colorResult)
}
return (
<Popover triggerScaleOnOpen={false}>
<PopoverTrigger>
<div
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
className="w-36 h-8 rounded-md cursor-pointer border border-content4"
style={{ background: color }}
/>
</PopoverTrigger>
@@ -26,11 +26,11 @@ const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
<SketchPicker
color={color}
onChange={handleChange}
className='!bg-transparent !shadow-none'
className="!bg-transparent !shadow-none"
/>
</PopoverContent>
</Popover>
);
};
)
}
export default ColorPicker;
export default ColorPicker

View File

@@ -1,30 +1,30 @@
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 { 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';
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';
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 { PlayMode } from '@/const/enum'
import key from '@/const/key'
import { VolumeHighIcon, VolumeLowIcon } from './icons';
import { VolumeHighIcon, VolumeLowIcon } from './icons'
export interface AudioPlayerProps
extends React.AudioHTMLAttributes<HTMLAudioElement> {
@@ -39,7 +39,7 @@ export interface AudioPlayerProps
mode?: PlayMode
}
export default function AudioPlayer (props: AudioPlayerProps) {
export default function AudioPlayer(props: AudioPlayerProps) {
const {
src,
pressNext,
@@ -56,116 +56,116 @@ export default function AudioPlayer (props: AudioPlayerProps) {
autoPlay,
mode = PlayMode.Loop,
...rest
} = props;
} = props
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [volume, setVolume] = useState(100)
const [isCollapsed, setIsCollapsed] = useLocalStorage(
key.isCollapsedMusicPlayer,
false
);
const audioRef = useRef<HTMLAudioElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const startY = useRef(0);
const startX = useRef(0);
const [translateY, setTranslateY] = useState(0);
const [translateX, setTranslateX] = useState(0);
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
const isMediumUp = useMediaQuery({ minWidth: 768 });
const shouldAdd = useRef(false);
const currentProgress = (currentTime / duration) * 100;
)
const audioRef = useRef<HTMLAudioElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const startY = useRef(0)
const startX = useRef(0)
const [translateY, setTranslateY] = useState(0)
const [translateX, setTranslateX] = useState(0)
const isSmallScreen = useMediaQuery({ maxWidth: 767 })
const isMediumUp = useMediaQuery({ minWidth: 768 })
const shouldAdd = useRef(false)
const currentProgress = (currentTime / duration) * 100
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
key.autoPlay,
true
);
)
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setCurrentTime(audio.currentTime);
onTimeUpdate?.(event);
};
const audio = event.target as HTMLAudioElement
setCurrentTime(audio.currentTime)
onTimeUpdate?.(event)
}
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setDuration(audio.duration);
onLoadedData?.(event);
};
const audio = event.target as HTMLAudioElement
setDuration(audio.duration)
onLoadedData?.(event)
}
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(true);
setStorageAutoPlay(true);
onPlay?.(e);
};
setIsPlaying(true)
setStorageAutoPlay(true)
onPlay?.(e)
}
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(false);
onPause?.(e);
};
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 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);
};
setVolume(value)
}
useEffect(() => {
const audio = audioRef.current;
const audio = audioRef.current
if (audio) {
audio.volume = volume / 100;
audio.volume = volume / 100
}
}, [volume]);
}, [volume])
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY;
startX.current = e.touches[0].clientX;
};
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;
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);
shouldAdd.current = _shouldAdd
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY)
} else {
setTranslateX(deltaX);
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);
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);
setIsCollapsed(true)
} else if (_translateY < -100) {
setIsCollapsed(false);
setIsCollapsed(false)
}
setTranslateY(0);
setTranslateY(0)
} else {
if (translateX > 100) {
setIsCollapsed(true);
setIsCollapsed(true)
} else if (translateX < -100) {
setIsCollapsed(false);
setIsCollapsed(false)
}
setTranslateX(0);
setTranslateX(0)
}
};
}
const dragTranslate = isSmallScreen
? translateY
@@ -173,16 +173,16 @@ export default function AudioPlayer (props: AudioPlayerProps) {
: ''
: translateX
? `translateX(${translateX}px)`
: '';
: ''
const collapsedTranslate = isCollapsed
? isSmallScreen
? 'translateY(90%)'
: 'translateX(96%)'
: '';
: ''
const translateStyle = dragTranslate || collapsedTranslate;
const translateStyle = dragTranslate || collapsedTranslate
if (!src) return null;
if (!src) return null
return (
<div
@@ -192,7 +192,7 @@ export default function AudioPlayer (props: AudioPlayerProps) {
isCollapsed && 'md:hover:!translate-x-80'
)}
style={{
transform: translateStyle,
transform: translateStyle
}}
>
<audio
@@ -216,10 +216,10 @@ export default function AudioPlayer (props: AudioPlayerProps) {
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
)}
classNames={{
body: 'p-0',
body: 'p-0'
}}
shadow='sm'
radius='none'
shadow="sm"
radius="none"
>
{isMediumUp && (
<Button
@@ -230,9 +230,9 @@ export default function AudioPlayer (props: AudioPlayerProps) {
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant='solid'
color='primary'
size='sm'
variant="solid"
color="primary"
size="sm"
onPress={() => setIsCollapsed(!isCollapsed)}
>
<FaRegHandPointRight />
@@ -241,65 +241,65 @@ export default function AudioPlayer (props: AudioPlayerProps) {
{isSmallScreen && (
<CardHeader
data-header
className='flex-row justify-center pt-4'
className="flex-row justify-center pt-4"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
<div className="w-24 h-2 rounded-full bg-content2-foreground shadow-sm"></div>
</CardHeader>
)}
<CardBody>
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
<div className="grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0">
<div className="relative col-span-6 md:col-span-4 flex justify-center">
<Image
alt='Album cover'
className='object-cover'
alt="Album cover"
className="object-cover"
classNames={{
wrapper: 'w-36 aspect-square md:w-24 flex',
img: 'block w-full h-full',
img: 'block w-full h-full'
}}
shadow='md'
shadow="md"
src={cover}
width='100%'
width="100%"
/>
</div>
<div className='flex flex-col col-span-6 md:col-span-8'>
<div className='flex flex-col gap-0'>
<h1 className='font-medium truncate'>{title}</h1>
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
<div className="flex flex-col col-span-6 md:col-span-8">
<div className="flex flex-col gap-0">
<h1 className="font-medium truncate">{title}</h1>
<p className="text-xs text-foreground/80 truncate">{artist}</p>
</div>
<div className='flex flex-col'>
<div className="flex flex-col">
<Slider
aria-label='Music progress'
aria-label="Music progress"
classNames={{
track: 'bg-default-500/30 border-none',
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
filler: 'rounded-full',
filler: 'rounded-full'
}}
color='foreground'
color="foreground"
value={currentProgress || 0}
defaultValue={0}
size='sm'
size="sm"
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
const audio = audioRef.current;
value = Array.isArray(value) ? value[0] : value
const audio = audioRef.current
if (audio) {
audio.currentTime = (value / 100) * duration;
audio.currentTime = (value / 100) * duration
}
}}
/>
<div className='flex justify-between h-3'>
<p className='text-xs'>
<div className="flex justify-between h-3">
<p className="text-xs">
{Math.floor(currentTime / 60)}:
{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')}
</p>
<p className='text-xs text-foreground/50'>
<p className="text-xs text-foreground/50">
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
@@ -308,7 +308,7 @@ export default function AudioPlayer (props: AudioPlayerProps) {
</div>
</div>
<div className='flex w-full items-center justify-center'>
<div className="flex w-full items-center justify-center">
<Tooltip
content={
mode === PlayMode.Loop
@@ -320,30 +320,30 @@ export default function AudioPlayer (props: AudioPlayerProps) {
>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
radius='full'
variant='light'
size='md'
className="data-[hover]:bg-foreground/10 text-lg md:text-medium"
radius="full"
variant="light"
size="md"
onPress={changeMode}
>
{mode === PlayMode.Loop && (
<FaRepeat className='text-foreground/80' />
<FaRepeat className="text-foreground/80" />
)}
{mode === PlayMode.Random && (
<FaShuffle className='text-foreground/80' />
<FaShuffle className="text-foreground/80" />
)}
{mode === PlayMode.Single && (
<TbRepeatOnce className='text-foreground/80 text-xl' />
<TbRepeatOnce className="text-foreground/80 text-xl" />
)}
</Button>
</Tooltip>
<Tooltip content='上一首'>
<Tooltip content="上一首">
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
radius="full"
variant="light"
size="md"
onPress={pressPrevious}
>
<BiSolidSkipPreviousCircle />
@@ -352,66 +352,66 @@ export default function AudioPlayer (props: AudioPlayerProps) {
<Tooltip content={isPlaying ? '暂停' : '播放'}>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
radius='full'
variant='light'
size='lg'
className="data-[hover]:bg-foreground/10 text-3xl md:text-3xl"
radius="full"
variant="light"
size="lg"
onPress={() => {
if (isPlaying) {
audioRef.current?.pause();
setStorageAutoPlay(false);
audioRef.current?.pause()
setStorageAutoPlay(false)
} else {
audioRef.current?.play();
audioRef.current?.play()
}
}}
>
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
{isPlaying ? <FaPause /> : <FaPlay className="ml-1" />}
</Button>
</Tooltip>
<Tooltip content='下一首'>
<Tooltip content="下一首">
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
radius="full"
variant="light"
size="md"
onPress={pressNext}
>
<BiSolidSkipNextCircle />
</Button>
</Tooltip>
<Popover
placement='top'
placement="top"
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
content: 'bg-opacity-30 backdrop-blur-md'
}}
>
<PopoverTrigger>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
radius='full'
variant='light'
size='md'
className="data-[hover]:bg-foreground/10 text-xl md:text-xl"
radius="full"
variant="light"
size="md"
>
<VolumeHighIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<Slider
orientation='vertical'
orientation="vertical"
showTooltip
aria-label='Volume'
className='h-40'
color='primary'
aria-label="Volume"
className="h-40"
color="primary"
defaultValue={volume}
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
volumeChange(value);
value = Array.isArray(value) ? value[0] : value
volumeChange(value)
}}
startContent={<VolumeHighIcon className='text-2xl' />}
size='sm'
endContent={<VolumeLowIcon className='text-2xl' />}
startContent={<VolumeHighIcon className="text-2xl" />}
size="sm"
endContent={<VolumeLowIcon className="text-2xl" />}
/>
</PopoverContent>
</Popover>
@@ -421,5 +421,5 @@ export default function AudioPlayer (props: AudioPlayerProps) {
</CardBody>
</Card>
</div>
);
)
}

View File

@@ -1,87 +1,87 @@
import { Button } from '@heroui/button';
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';
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';
WebsocketIcon
} from '../icons'
export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void
}
const AddButton: React.FC<AddButtonProps> = (props) => {
const { onOpen } = props;
const { onOpen } = props
return (
<Dropdown
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
content: 'bg-opacity-30 backdrop-blur-md'
}}
placement='right'
placement="right"
>
<DropdownTrigger>
<Button
color='primary'
startContent={<IoAddCircleOutline className='text-2xl' />}
color="primary"
startContent={<IoAddCircleOutline className="text-2xl" />}
>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label='Create Network Config'
color='primary'
variant='flat'
aria-label="Create Network Config"
color="primary"
variant="flat"
onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']);
onOpen(key as keyof OneBotConfig['network'])
}}
>
<DropdownItem
key='title'
key="title"
isReadOnly
className='cursor-default hover:!bg-transparent'
textValue='title'
className="cursor-default hover:!bg-transparent"
textValue="title"
>
<div className='flex items-center gap-2 justify-center'>
<div className='w-5 h-5 -ml-3'>
<div className="flex items-center gap-2 justify-center">
<div className="w-5 h-5 -ml-3">
<PlusIcon />
</div>
<div className='text-primary-400'></div>
<div className="text-primary-400"></div>
</div>
</DropdownItem>
<DropdownItem
key='httpServers'
textValue='httpServers'
key="httpServers"
textValue="httpServers"
startContent={
<div className='w-6 h-6'>
<div className="w-6 h-6">
<HTTPServerIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
HTTP服务器
<Tooltip
content='「由NapCat建立」一个HTTP服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。'
content="「由NapCat建立」一个HTTP服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。"
showArrow
className='max-w-64'
className="max-w-64"
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
radius="full"
size="sm"
variant="light"
className="w-4 h-4 min-w-0"
>
<FaRegCircleQuestion />
</Button>
@@ -89,27 +89,27 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</div>
</DropdownItem>
<DropdownItem
key='httpSseServers'
textValue='httpSseServers'
key="httpSseServers"
textValue="httpSseServers"
startContent={
<div className='w-6 h-6'>
<div className="w-6 h-6">
<HTTPServerIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
HTTP SSE服务器
<Tooltip
content='「由NapCat建立」一个HTTP SSE服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。'
content="「由NapCat建立」一个HTTP SSE服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。"
showArrow
className='max-w-64'
className="max-w-64"
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
radius="full"
size="sm"
variant="light"
className="w-4 h-4 min-w-0"
>
<FaRegCircleQuestion />
</Button>
@@ -117,27 +117,27 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</div>
</DropdownItem>
<DropdownItem
key='httpClients'
textValue='httpClients'
key="httpClients"
textValue="httpClients"
startContent={
<div className='w-6 h-6'>
<div className="w-6 h-6">
<HTTPClientIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
HTTP客户端
<Tooltip
content='「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的NapCat会主动连接它。'
content="「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的NapCat会主动连接它。"
showArrow
className='max-w-64'
className="max-w-64"
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
radius="full"
size="sm"
variant="light"
className="w-4 h-4 min-w-0"
>
<FaRegCircleQuestion />
</Button>
@@ -145,27 +145,27 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</div>
</DropdownItem>
<DropdownItem
key='websocketServers'
textValue='websocketServers'
key="websocketServers"
textValue="websocketServers"
startContent={
<div className='w-6 h-6'>
<div className="w-6 h-6">
<WebsocketIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
Websocket服务器
<Tooltip
content='「由NapCat建立」一个WebSocket服务器你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址你或者你的框架应该连接到这个地址。'
content="「由NapCat建立」一个WebSocket服务器你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址你或者你的框架应该连接到这个地址。"
showArrow
className='max-w-64'
className="max-w-64"
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
radius="full"
size="sm"
variant="light"
className="w-4 h-4 min-w-0"
>
<FaRegCircleQuestion />
</Button>
@@ -173,27 +173,27 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</div>
</DropdownItem>
<DropdownItem
key='websocketClients'
textValue='websocketClients'
key="websocketClients"
textValue="websocketClients"
startContent={
<div className='w-6 h-6'>
<div className="w-6 h-6">
<PCIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
Websocket客户端
<Tooltip
content='「由框架或者你自己建立」的WebSocket通常框架会「提供」一个ws地址NapCat会主动连接它。'
content="「由框架或者你自己建立」的WebSocket通常框架会「提供」一个ws地址NapCat会主动连接它。"
showArrow
className='max-w-64'
className="max-w-64"
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
radius="full"
size="sm"
variant="light"
className="w-4 h-4 min-w-0"
>
<FaRegCircleQuestion />
</Button>
@@ -202,7 +202,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
};
)
}
export default AddButton;
export default AddButton

View File

@@ -1,7 +1,7 @@
import { Button } from '@heroui/button';
import clsx from 'clsx';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { Button } from '@heroui/button'
import clsx from 'clsx'
import toast from 'react-hot-toast'
import { IoMdRefresh } from 'react-icons/io'
export interface SaveButtonsProps {
onSubmit: () => void
@@ -16,7 +16,7 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
reset,
isSubmitting,
refresh,
className,
className
}) => (
<div
className={clsx(
@@ -24,18 +24,18 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
className
)}
>
<div className='flex items-center justify-center gap-2 mt-5'>
<div className="flex items-center justify-center gap-2 mt-5">
<Button
color='default'
color="default"
onPress={() => {
reset();
toast.success('重置成功');
reset()
toast.success('重置成功')
}}
>
</Button>
<Button
color='primary'
color="primary"
isLoading={isSubmitting}
onPress={() => onSubmit()}
>
@@ -44,9 +44,9 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && (
<Button
isIconOnly
color='secondary'
radius='full'
variant='flat'
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
@@ -54,6 +54,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
)}
</div>
</div>
);
)
export default SaveButtons;
export default SaveButtons

View File

@@ -1,170 +1,170 @@
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 { 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 useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
import { isURI } from '@/utils/url';
import { isURI } from '@/utils/url'
import type { OB11Segment } from '@/types/onebot';
import type { OB11Segment } from '@/types/onebot'
const AudioInsert = () => {
const [audioUrl, setAudioUrl] = useState<string>('');
const audioInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const [audioUrl, setAudioUrl] = useState<string>('')
const audioInputRef = useRef<HTMLInputElement>(null)
const showStructuredMessage = useShowStructuredMessage()
const showAudioSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'record',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
file: file
}
}
]
showStructuredMessage(messages)
}
const [isRecording, setIsRecording] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const [audioPreview, setAudioPreview] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [isRecording, setIsRecording] = useState(false)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const [audioPreview, setAudioPreview] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const streamRef = useRef<MediaStream | null>(null)
const [recordingTime, setRecordingTime] = useState(0)
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isRecording) {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
recorder.start();
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);
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);
type: 'audio/wav'
})
const reader = new FileReader()
reader.readAsDataURL(audioBlob)
reader.onloadend = () => {
const base64Audio = reader.result as string;
setAudioPreview(base64Audio);
setShowPreview(true);
};
audioChunksRef.current = [];
const base64Audio = reader.result as string
setAudioPreview(base64Audio)
setShowPreview(true)
}
audioChunksRef.current = []
}
stream.getTracks().forEach((track) => track.stop());
};
});
stream.getTracks().forEach((track) => track.stop())
}
})
recordingIntervalRef.current = setInterval(() => {
setRecordingTime((prevTime) => prevTime + 1);
}, 1000);
setRecordingTime((prevTime) => prevTime + 1)
}, 1000)
} else {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current?.stop()
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
recordingIntervalRef.current = null;
clearInterval(recordingIntervalRef.current)
recordingIntervalRef.current = null
}
}
}, [isRecording]);
}, [isRecording])
const startRecording = () => {
setAudioPreview(null);
setShowPreview(false);
setRecordingTime(0);
setIsRecording(true);
};
setAudioPreview(null)
setShowPreview(false)
setRecordingTime(0)
setIsRecording(true)
}
const stopRecording = () => {
setIsRecording(false);
};
setIsRecording(false)
}
const handleShowPreview = () => {
if (audioPreview) {
showAudioSegment(audioPreview);
showAudioSegment(audioPreview)
}
};
}
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const minutes = Math.floor(time / 60)
const seconds = time % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
return (
<>
<Popover>
<Tooltip content='发送音频'>
<div className='max-w-fit'>
<Tooltip content="发送音频">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoMic className='text-xl' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoMic className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传音频'>
<PopoverContent className="flex-row gap-2 p-4">
<Tooltip content="上传音频">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
onPress={() => {
audioInputRef?.current?.click();
audioInputRef?.current?.click()
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入音频地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入音频地址'>
<Tooltip content="输入音频地址">
<div className="max-w-fit">
<PopoverTrigger tooltip="输入音频地址">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<PopoverContent className="flex-row gap-1 p-2">
<Input
value={audioUrl}
onChange={(e) => setAudioUrl(e.target.value)}
placeholder='请输入音频地址'
placeholder="请输入音频地址"
/>
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
if (!isURI(audioUrl)) {
toast.error('请输入正确的音频地址');
return;
toast.error('请输入正确的音频地址')
return
}
showAudioSegment(audioUrl);
setAudioUrl('');
showAudioSegment(audioUrl)
setAudioUrl('')
}}
>
<FaMicrophone />
@@ -172,34 +172,34 @@ const AudioInsert = () => {
</PopoverContent>
</Popover>
<Popover>
<Tooltip content='录制音频'>
<div className='max-w-fit'>
<Tooltip content="录制音频">
<div className="max-w-fit">
<PopoverTrigger>
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
>
<IoMic />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-col gap-2 p-4'>
<div className='flex gap-2'>
<PopoverContent className="flex-col gap-2 p-4">
<div className="flex gap-2">
<Button
color={isRecording ? 'primary' : 'primary'}
variant='flat'
variant="flat"
onPress={isRecording ? stopRecording : startRecording}
>
{isRecording ? '停止录制' : '开始录制'}
</Button>
{showPreview && audioPreview && (
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={handleShowPreview}
>
@@ -207,7 +207,7 @@ const AudioInsert = () => {
)}
</div>
{(isRecording || audioPreview) && (
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
<span
className={clsx(
'w-4 h-4 rounded-full',
@@ -215,7 +215,7 @@ const AudioInsert = () => {
? 'animate-pulse bg-primary-400'
: 'bg-success-400'
)}
/>
></span>
<span>: {formatTime(recordingTime)}</span>
</div>
)}
@@ -228,27 +228,27 @@ const AudioInsert = () => {
</Popover>
<input
type='file'
type="file"
ref={audioInputRef}
hidden
accept='audio/*'
className='hidden'
accept="audio/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (!file) {
return;
return
}
const reader = new FileReader();
reader.readAsDataURL(file);
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event) => {
const dataURL = event.target?.result;
showAudioSegment(dataURL as string);
e.target.value = '';
};
const dataURL = event.target?.result
showAudioSegment(dataURL as string)
e.target.value = ''
}
}}
/>
</>
);
};
)
}
export default AudioInsert;
export default AudioInsert

View File

@@ -1,31 +1,31 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { BsDice3Fill } from 'react-icons/bs';
import { Button } from '@heroui/button'
import { Tooltip } from '@heroui/tooltip'
import { BsDice3Fill } from 'react-icons/bs'
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
const DiceInsert = () => {
const showStructuredMessage = useShowStructuredMessage();
const showStructuredMessage = useShowStructuredMessage()
return (
<Tooltip content='发送骰子'>
<Tooltip content="发送骰子">
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
showStructuredMessage([
{
type: 'dice',
},
]);
type: 'dice'
}
])
}}
>
<BsDice3Fill className='text-lg' />
<BsDice3Fill className="text-lg" />
</Button>
</Tooltip>
);
};
)
}
export default DiceInsert;
export default DiceInsert

View File

@@ -1,20 +1,20 @@
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 { 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';
import { EmojiValue } from '../formats/emoji_blot'
const emojis = data.map((item) => {
return {
alt: item.QDes,
src: getUrl(item.QSid),
id: item.QSid,
} as EmojiValue;
});
id: item.QSid
} as EmojiValue
})
export interface EmojiPickerProps {
onInsertEmoji: (emoji: EmojiValue) => void
@@ -22,62 +22,62 @@ export interface EmojiPickerProps {
}
const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([])
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isPopoverOpen) {
setVisibleEmojis([]); // Reset visible emojis
requestAnimationFrame(() => loadEmojis()); // Start loading emojis
setVisibleEmojis([]) // Reset visible emojis
requestAnimationFrame(() => loadEmojis()) // Start loading emojis
}
}, [isPopoverOpen]);
}, [isPopoverOpen])
const loadEmojis = (index = 0, batchSize = 10) => {
if (index < emojis.length) {
setVisibleEmojis((prev) => [
...prev,
...emojis.slice(index, index + batchSize),
]);
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize));
...emojis.slice(index, index + batchSize)
])
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize))
}
};
}
return (
<div ref={containerRef}>
<Popover
portalContainer={containerRef.current!}
shouldCloseOnScroll={false}
placement='right-start'
placement="right-start"
onOpenChange={(v) => {
onOpenChange(v);
setIsPopoverOpen(v);
onOpenChange(v)
setIsPopoverOpen(v)
}}
>
<Tooltip content='插入表情'>
<div className='max-w-fit'>
<Tooltip content="插入表情">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<MdEmojiEmotions className='text-xl' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<MdEmojiEmotions className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='grid grid-cols-8 gap-1 flex-wrap justify-start items-start overflow-y-auto max-w-full max-h-96 p-2'>
<PopoverContent className="grid grid-cols-8 gap-1 flex-wrap justify-start items-start overflow-y-auto max-w-full max-h-96 p-2">
{visibleEmojis.map((emoji) => (
<Button
key={emoji.id}
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => onInsertEmoji(emoji)}
>
<Image src={emoji.src} alt={emoji.alt} className='w-6 h-6' />
<Image src={emoji.src} alt={emoji.alt} className="w-6 h-6" />
</Button>
))}
</PopoverContent>
</Popover>
</div>
);
};
)
}
export default EmojiPicker;
export default EmojiPicker

View File

@@ -1,95 +1,95 @@
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 { 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 useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
import { isURI } from '@/utils/url';
import { isURI } from '@/utils/url'
import type { OB11Segment } from '@/types/onebot';
import type { OB11Segment } from '@/types/onebot'
const FileInsert = () => {
const [fileUrl, setFileUrl] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const [fileUrl, setFileUrl] = useState<string>('')
const fileInputRef = useRef<HTMLInputElement>(null)
const showStructuredMessage = useShowStructuredMessage()
const showFileSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'file',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
file: file
}
}
]
showStructuredMessage(messages)
}
return (
<>
<Popover>
<Tooltip content='发送文件'>
<div className='max-w-fit'>
<Tooltip content="发送文件">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<FaFolder className='text-lg' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<FaFolder className="text-lg" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传文件'>
<PopoverContent className="flex-row gap-2 p-4">
<Tooltip content="上传文件">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
onPress={() => {
fileInputRef?.current?.click();
fileInputRef?.current?.click()
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入文件地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入文件地址'>
<Tooltip content="输入文件地址">
<div className="max-w-fit">
<PopoverTrigger tooltip="输入文件地址">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<PopoverContent className="flex-row gap-1 p-2">
<Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
placeholder='请输入文件地址'
placeholder="请输入文件地址"
/>
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
if (!isURI(fileUrl)) {
toast.error('请输入正确的文件地址');
return;
toast.error('请输入正确的文件地址')
return
}
showFileSegment(fileUrl);
setFileUrl('');
showFileSegment(fileUrl)
setFileUrl('')
}}
>
<LuFilePlus2 />
@@ -100,26 +100,26 @@ const FileInsert = () => {
</Popover>
<input
type='file'
type="file"
ref={fileInputRef}
hidden
className='hidden'
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (!file) {
return;
return
}
const reader = new FileReader();
reader.readAsDataURL(file);
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event) => {
const dataURL = event.target?.result;
showFileSegment(dataURL as string);
e.target.value = '';
};
const dataURL = event.target?.result
showFileSegment(dataURL as string)
e.target.value = ''
}
}}
/>
</>
);
};
)
}
export default FileInsert;
export default FileInsert

View File

@@ -1,12 +1,12 @@
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 { 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';
import { isURI } from '@/utils/url'
export interface ImageInsertProps {
insertImage: (url: string) => void
@@ -14,70 +14,70 @@ export interface ImageInsertProps {
}
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
const [imgUrl, setImgUrl] = useState<string>('');
const imageInputRef = useRef<HTMLInputElement>(null);
const [imgUrl, setImgUrl] = useState<string>('')
const imageInputRef = useRef<HTMLInputElement>(null)
return (
<>
<Popover onOpenChange={onOpenChange}>
<Tooltip content='插入图片'>
<div className='max-w-fit'>
<Tooltip content="插入图片">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<MdImage className='text-xl' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<MdImage className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传图片'>
<PopoverContent className="flex-row gap-2 p-4">
<Tooltip content="上传图片">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
onPress={() => {
imageInputRef?.current?.click();
imageInputRef?.current?.click()
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入图片地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入图片地址'>
<Tooltip content="输入图片地址">
<div className="max-w-fit">
<PopoverTrigger tooltip="输入图片地址">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<PopoverContent className="flex-row gap-1 p-2">
<Input
value={imgUrl}
onChange={(e) => setImgUrl(e.target.value)}
placeholder='请输入图片地址'
placeholder="请输入图片地址"
/>
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
if (!isURI(imgUrl)) {
toast.error('请输入正确的图片地址');
return;
toast.error('请输入正确的图片地址')
return
}
insertImage(imgUrl);
setImgUrl('');
insertImage(imgUrl)
setImgUrl('')
}}
>
<MdAddPhotoAlternate />
@@ -88,27 +88,27 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
</Popover>
<input
type='file'
type="file"
ref={imageInputRef}
hidden
accept='image/*'
className='hidden'
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (!file) {
return;
return
}
const reader = new FileReader();
reader.readAsDataURL(file);
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event) => {
const dataURL = event.target?.result;
insertImage(dataURL as string);
e.target.value = '';
};
const dataURL = event.target?.result
insertImage(dataURL as string)
e.target.value = ''
}
}}
/>
</>
);
};
)
}
export default ImageInsert;
export default ImageInsert

View File

@@ -1,35 +1,35 @@
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 { 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 useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
import { isURI } from '@/utils/url';
import { isURI } from '@/utils/url'
import type {
CustomMusicSegment,
MusicSegment,
OB11Segment,
} from '@/types/onebot';
OB11Segment
} from '@/types/onebot'
type MusicData = CustomMusicSegment['data'] | MusicSegment['data'];
type MusicData = CustomMusicSegment['data'] | MusicSegment['data']
const MusicInsert = () => {
const [musicId, setMusicId] = useState<string>('');
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']));
const [mode, setMode] = useState<Key>('default');
const containerRef = useRef<HTMLDivElement>(null);
const [musicId, setMusicId] = useState<string>('')
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']))
const [mode, setMode] = useState<Key>('default')
const containerRef = useRef<HTMLDivElement>(null)
const { control, handleSubmit, reset } = useForm<
Omit<CustomMusicSegment['data'], 'type'>
>({
@@ -38,84 +38,84 @@ const MusicInsert = () => {
audio: '',
title: '',
image: '',
content: '',
},
});
const showStructuredMessage = useShowStructuredMessage();
content: ''
}
})
const showStructuredMessage = useShowStructuredMessage()
const showMusicSegment = (data: MusicData) => {
const messages: OB11Segment[] = [];
const messages: OB11Segment[] = []
if (data.type === 'custom') {
messages.push({
type: 'music',
data: {
...data,
type: 'custom',
},
});
type: 'custom'
}
})
} else {
messages.push({
type: 'music',
data,
});
data
})
}
showStructuredMessage(messages);
};
showStructuredMessage(messages)
}
const onSubmit = (data: Omit<CustomMusicSegment['data'], 'type'>) => {
showMusicSegment({
type: 'custom',
...data,
});
reset();
};
...data
})
reset()
}
return (
<div ref={containerRef} className='overflow-visible'>
<div ref={containerRef} className="overflow-visible">
<Popover
placement='right-start'
placement="right-start"
shouldCloseOnScroll={false}
portalContainer={containerRef.current!}
>
<Tooltip content='发送音乐'>
<div className='max-w-fit'>
<Tooltip content="发送音乐">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoMusicalNotes className='text-xl' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoMusicalNotes className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='gap-2 p-4'>
<PopoverContent className="gap-2 p-4">
<Tabs
placement='top'
className='w-96'
placement="top"
className="w-96"
fullWidth
selectedKey={mode}
onSelectionChange={(key) => {
if (key !== null) setMode(key);
if (key !== null) setMode(key)
}}
>
<Tab title='主流平台' key='default' className='flex flex-col gap-2'>
<Tab title="主流平台" key="default" className="flex flex-col gap-2">
<Select
onClick={(e) => e.stopPropagation()}
aria-label='音乐平台'
aria-label="音乐平台"
selectedKeys={musicType}
label='音乐平台'
placeholder='请选择音乐平台'
label="音乐平台"
placeholder="请选择音乐平台"
items={[
{
name: 'QQ音乐',
id: 'qq',
id: 'qq'
},
{
name: '网易云音乐',
id: '163',
id: '163'
},
{
name: '虾米音乐',
id: 'xm',
},
id: 'xm'
}
]}
onSelectionChange={setMusicType}
>
@@ -128,27 +128,27 @@ const MusicInsert = () => {
<Input
value={musicId}
onChange={(e) => setMusicId(e.target.value)}
placeholder='请输入音乐ID'
label='音乐ID'
placeholder="请输入音乐ID"
label="音乐ID"
/>
<Button
fullWidth
size='lg'
color='primary'
variant='flat'
radius='full'
size="lg"
color="primary"
variant="flat"
radius="full"
onPress={() => {
if (!musicId) {
toast.error('请输入音乐ID');
return;
toast.error('请输入音乐ID')
return
}
showMusicSegment({
type: Array.from(
musicType
)[0] as MusicSegment['data']['type'],
id: musicId,
});
setMusicId('');
id: musicId
})
setMusicId('')
}}
startContent={<TbMusicPlus />}
>
@@ -156,92 +156,92 @@ const MusicInsert = () => {
</Button>
</Tab>
<Tab
title='自定义音乐'
key='custom'
className='flex flex-col gap-2'
title="自定义音乐"
key="custom"
className="flex flex-col gap-2"
>
<Form
onSubmit={handleSubmit(onSubmit)}
className='flex flex-col gap-2'
validationBehavior='native'
className="flex flex-col gap-2"
validationBehavior="native"
>
<Controller
name='url'
name="url"
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
validate={(v) => {
return !isURI(v) ? '请输入正确的音乐URL' : null;
return !isURI(v) ? '请输入正确的音乐URL' : null
}}
size='sm'
placeholder='请输入音乐URL'
label='音乐URL'
size="sm"
placeholder="请输入音乐URL"
label="音乐URL"
/>
)}
/>
<Controller
name='audio'
name="audio"
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
validate={(v) => {
return !isURI(v) ? '请输入正确的音频URL' : null;
return !isURI(v) ? '请输入正确的音频URL' : null
}}
size='sm'
placeholder='请输入音频URL'
label='音频URL'
size="sm"
placeholder="请输入音频URL"
label="音频URL"
/>
)}
/>
<Controller
name='title'
name="title"
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
size='sm'
errorMessage='请输入音乐标题'
placeholder='请输入音乐标题'
label='音乐标题'
size="sm"
errorMessage="请输入音乐标题"
placeholder="请输入音乐标题"
label="音乐标题"
/>
)}
/>
<Controller
name='image'
name="image"
control={control}
render={({ field }) => (
<Input
{...field}
size='sm'
placeholder='请输入封面图片URL'
label='封面图片URL'
size="sm"
placeholder="请输入封面图片URL"
label="封面图片URL"
/>
)}
/>
<Controller
name='content'
name="content"
control={control}
render={({ field }) => (
<Input
{...field}
size='sm'
placeholder='请输入音乐描述'
label='音乐描述'
size="sm"
placeholder="请输入音乐描述"
label="音乐描述"
/>
)}
/>
<Button
fullWidth
size='lg'
color='primary'
variant='flat'
radius='full'
type='submit'
size="lg"
color="primary"
variant="flat"
radius="full"
type="submit"
startContent={<TbMusicPlus />}
>
@@ -252,7 +252,7 @@ const MusicInsert = () => {
</PopoverContent>
</Popover>
</div>
);
};
)
}
export default MusicInsert;
export default MusicInsert

View File

@@ -1,50 +1,50 @@
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';
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import { Tooltip } from '@heroui/tooltip'
import { useState } from 'react'
import { BsChatQuoteFill } from 'react-icons/bs'
import { MdAdd } from 'react-icons/md'
export interface ReplyInsertProps {
insertReply: (messageId: string) => void
}
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
const [replyId, setReplyId] = useState<string>('');
const [replyId, setReplyId] = useState<string>('')
return (
<>
<Popover>
<Tooltip content='回复消息'>
<div className='max-w-fit'>
<Tooltip content="回复消息">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<BsChatQuoteFill className='text-lg' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<BsChatQuoteFill className="text-lg" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<PopoverContent className="flex-row gap-2 p-4">
<Input
placeholder='输入消息 ID'
placeholder="输入消息 ID"
value={replyId}
onChange={(e) => {
const value = e.target.value;
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
const value = e.target.value
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
if (isNumberReg.test(value)) {
setReplyId(value);
setReplyId(value)
}
}}
/>
<Button
color='primary'
variant='flat'
radius='full'
color="primary"
variant="flat"
radius="full"
isIconOnly
onPress={() => {
insertReply(replyId);
setReplyId('');
insertReply(replyId)
setReplyId('')
}}
>
<MdAdd />
@@ -52,7 +52,7 @@ const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
</PopoverContent>
</Popover>
</>
);
};
)
}
export default ReplyInsert;
export default ReplyInsert

View File

@@ -1,31 +1,31 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { LiaHandScissors } from 'react-icons/lia';
import { Button } from '@heroui/button'
import { Tooltip } from '@heroui/tooltip'
import { LiaHandScissors } from 'react-icons/lia'
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
const RPSInsert = () => {
const showStructuredMessage = useShowStructuredMessage();
const showStructuredMessage = useShowStructuredMessage()
return (
<Tooltip content='发送猜拳'>
<Tooltip content="发送猜拳">
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
showStructuredMessage([
{
type: 'rps',
},
]);
type: 'rps'
}
])
}}
>
<LiaHandScissors className='text-2xl' />
<LiaHandScissors className="text-2xl" />
</Button>
</Tooltip>
);
};
)
}
export default RPSInsert;
export default RPSInsert

View File

@@ -1,6 +1,6 @@
import { Snippet } from '@heroui/snippet';
import { Snippet } from '@heroui/snippet'
import { OB11Segment } from '@/types/onebot';
import { OB11Segment } from '@/types/onebot'
export interface ShowStructedMessageProps {
messages: OB11Segment[]
@@ -11,22 +11,22 @@ const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
<Snippet
hideSymbol
tooltipProps={{
content: '点击复制',
content: '点击复制'
}}
classNames={{
copyButton: 'self-start sticky top-0 right-0',
copyButton: 'self-start sticky top-0 right-0'
}}
className='bg-content1 h-96 overflow-y-scroll items-start'
className="bg-content1 h-96 overflow-y-scroll items-start"
>
{JSON.stringify(messages, null, 2)
.split('\n')
.map((line, i) => (
<span key={i} className='whitespace-pre-wrap break-all'>
<span key={i} className="whitespace-pre-wrap break-all">
{line}
</span>
))}
</Snippet>
);
};
)
}
export default ShowStructedMessage;
export default ShowStructedMessage

View File

@@ -1,95 +1,95 @@
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 { 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 useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
import { isURI } from '@/utils/url';
import { isURI } from '@/utils/url'
import type { OB11Segment } from '@/types/onebot';
import type { OB11Segment } from '@/types/onebot'
const VideoInsert = () => {
const [videoUrl, setVideoUrl] = useState<string>('');
const videoInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const [videoUrl, setVideoUrl] = useState<string>('')
const videoInputRef = useRef<HTMLInputElement>(null)
const showStructuredMessage = useShowStructuredMessage()
const showVideoSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'video',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
file: file
}
}
]
showStructuredMessage(messages)
}
return (
<>
<Popover>
<Tooltip content='发送视频'>
<div className='max-w-fit'>
<Tooltip content="发送视频">
<div className="max-w-fit">
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoVideocam className='text-xl' />
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoVideocam className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传视频'>
<PopoverContent className="flex-row gap-2 p-4">
<Tooltip content="上传视频">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
onPress={() => {
videoInputRef?.current?.click();
videoInputRef?.current?.click()
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入视频地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入视频地址'>
<Tooltip content="输入视频地址">
<div className="max-w-fit">
<PopoverTrigger tooltip="输入视频地址">
<Button
className='text-lg'
color='primary'
className="text-lg"
color="primary"
isIconOnly
variant='flat'
radius='full'
variant="flat"
radius="full"
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<PopoverContent className="flex-row gap-1 p-2">
<Input
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder='请输入视频地址'
placeholder="请输入视频地址"
/>
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
isIconOnly
radius='full'
radius="full"
onPress={() => {
if (!isURI(videoUrl)) {
toast.error('请输入正确的视频地址');
return;
toast.error('请输入正确的视频地址')
return
}
showVideoSegment(videoUrl);
setVideoUrl('');
showVideoSegment(videoUrl)
setVideoUrl('')
}}
>
<TbVideoPlus />
@@ -100,27 +100,27 @@ const VideoInsert = () => {
</Popover>
<input
type='file'
type="file"
ref={videoInputRef}
hidden
accept='video/*'
className='hidden'
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (!file) {
return;
return
}
const reader = new FileReader();
reader.readAsDataURL(file);
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event) => {
const dataURL = event.target?.result;
showVideoSegment(dataURL as string);
e.target.value = '';
};
const dataURL = event.target?.result
showVideoSegment(dataURL as string)
e.target.value = ''
}
}}
/>
</>
);
};
)
}
export default VideoInsert;
export default VideoInsert

View File

@@ -1,4 +1,4 @@
import Quill from 'quill';
import Quill from 'quill'
// eslint-disable-next-line
const Embed = Quill.import('blots/embed') as any
@@ -8,34 +8,34 @@ export interface EmojiValue {
id: string
}
class EmojiBlot extends Embed {
static blotName: string = 'emoji';
static tagName: string = 'img';
static classNames: string[] = ['w-6', 'h-6'];
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 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 {
static formats(node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? '',
};
id: node.getAttribute('data-id') ?? ''
}
}
static value (node: HTMLImageElement): EmojiValue {
static value(node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? '',
};
id: node.getAttribute('data-id') ?? ''
}
}
}
export default EmojiBlot;
export default EmojiBlot

View File

@@ -1,4 +1,4 @@
import Quill from 'quill';
import Quill from 'quill'
// eslint-disable-next-line
const Embed = Quill.import('blots/embed') as any
@@ -7,24 +7,24 @@ export interface ImageValue {
src: string
}
class ImageBlot extends Embed {
static blotName = 'image';
static tagName = 'img';
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom'];
static blotName = 'image'
static tagName = 'img'
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom']
static create (value: ImageValue) {
const node = super.create();
node.setAttribute('alt', value.alt);
node.setAttribute('src', value.src);
node.classList.add(...ImageBlot.classNames);
return node;
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 {
static value(node: HTMLImageElement): ImageValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
};
src: node.getAttribute('src') ?? ''
}
}
}
export default ImageBlot;
export default ImageBlot

View File

@@ -1,4 +1,4 @@
import Quill from 'quill';
import Quill from 'quill'
// eslint-disable-next-line
const BlockEmbed = Quill.import('blots/block/embed') as any
@@ -6,38 +6,38 @@ export interface ReplyBlockValue {
messageId: string
}
class ReplyBlock extends BlockEmbed {
static blotName = 'reply';
static tagName = 'div';
static blotName = 'reply'
static tagName = 'div'
static classNames = [
'p-2',
'select-none',
'bg-default-100',
'rounded-md',
'pointer-events-none',
];
'pointer-events-none'
]
static create (value: ReplyBlockValue) {
const node = super.create();
node.setAttribute('data-message-id', value.messageId);
node.setAttribute('contenteditable', 'false');
node.classList.add(...ReplyBlock.classNames);
const innerDom = document.createElement('div');
innerDom.classList.add('text-sm', 'text-default-500', 'relative');
const svgContainer = document.createElement('div');
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0');
const svg = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>';
svgContainer.innerHTML = svg;
innerDom.innerHTML = `消息ID${value.messageId}`;
innerDom.appendChild(svgContainer);
node.appendChild(innerDom);
return node;
static create(value: ReplyBlockValue) {
const node = super.create()
node.setAttribute('data-message-id', value.messageId)
node.setAttribute('contenteditable', 'false')
node.classList.add(...ReplyBlock.classNames)
const innerDom = document.createElement('div')
innerDom.classList.add('text-sm', 'text-default-500', 'relative')
const svgContainer = document.createElement('div')
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0')
const svg = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>`
svgContainer.innerHTML = svg
innerDom.innerHTML = `消息ID${value.messageId}`
innerDom.appendChild(svgContainer)
node.appendChild(innerDom)
return node
}
static value (node: HTMLElement): ReplyBlockValue {
static value(node: HTMLElement): ReplyBlockValue {
return {
messageId: node.getAttribute('data-message-id') || '',
};
messageId: node.getAttribute('data-message-id') || ''
}
}
}
export default ReplyBlock;
export default ReplyBlock

View File

@@ -1,55 +1,55 @@
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 { 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 { useCustomQuill } from '@/hooks/use_custom_quill'
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
import { quillToMessage } from '@/utils/onebot';
import { quillToMessage } from '@/utils/onebot'
import type { OB11Segment } from '@/types/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';
import AudioInsert from './components/audio_insert'
import DiceInsert from './components/dice_insert'
import EmojiPicker from './components/emoji_picker'
import FileInsert from './components/file_insert'
import ImageInsert from './components/image_insert'
import MusicInsert from './components/music_insert'
import ReplyInsert from './components/reply_insert'
import RPSInsert from './components/rps_insert'
import VideoInsert from './components/video_insert'
import EmojiBlot from './formats/emoji_blot'
import type { EmojiValue } from './formats/emoji_blot'
import ImageBlot from './formats/image_blot'
import ReplyBlock from './formats/reply_blot'
const ChatInput = () => {
const memorizedRange = useRef<Range | null>(null);
const memorizedRange = useRef<Range | null>(null)
const showStructuredMessage = useShowStructuredMessage();
const formats: string[] = ['image', 'emoji', 'reply'];
const showStructuredMessage = useShowStructuredMessage()
const formats: string[] = ['image', 'emoji', 'reply']
const modules = {
toolbar: '#toolbar',
};
toolbar: '#toolbar'
}
const { quillRef, quill, Quill } = useCustomQuill({
modules,
formats,
placeholder: '请输入消息',
});
placeholder: '请输入消息'
})
if (Quill && !quill) {
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/image', ImageBlot, true);
Quill.register('formats/reply', ReplyBlock);
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];
const editorContent = quill.getContents()
const firstOp = editorContent.ops[0]
if (
typeof firstOp?.insert !== 'string' &&
@@ -57,126 +57,126 @@ const ChatInput = () => {
range.index === 0 &&
range.length !== quill.getLength()
) {
quill.setSelection(1, Quill.sources.SILENT);
quill.setSelection(1, Quill.sources.SILENT)
}
}
});
})
quill.on('text-change', () => {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
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.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];
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.insertText(1, '\n', Quill.sources.SILENT)
}
}
});
})
quill.root.addEventListener('compositionstart', () => {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
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.insertText(1, '\n', Quill.sources.SILENT)
}
});
})
}
const onOpenChange = (open: boolean) => {
if (open) {
const selection = quill?.getSelection();
if (selection) memorizedRange.current = selection;
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);
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*))$/;
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;
toast.error('请输入正确的消息ID')
return
}
const editorContent = quill?.getContents();
const firstOp = editorContent?.ops[0];
const currentSelection = quill?.getSelection();
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();
const delta = quill?.getContents()
if (delta) {
delta.ops[0] = {
insert: { reply: { messageId } },
};
quill?.setContents(delta, Quill.sources.USER);
insert: { reply: { messageId } }
}
quill?.setContents(delta, Quill.sources.USER)
}
} else {
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER);
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER)
}
quill?.setSelection((currentSelection?.index || 0) + 1, 0);
quill?.blur();
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);
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);
};
id: emoji.id
})
quill?.setSelection((selection?.index || 0) + 1, 0)
}
const getChatMessage = () => {
const delta = quill?.getContents();
const delta = quill?.getContents()
const ops =
delta?.ops?.filter((op) => {
return op.insert !== '\n';
}) ?? [];
return op.insert !== '\n'
}) ?? []
const messages: OB11Segment[] = ops.map((op) => {
return quillToMessage(op);
});
return messages;
};
return quillToMessage(op)
})
return messages
}
return (
<div>
<div
ref={quillRef}
className='border border-default-200 rounded-md !mb-2 !text-base !h-64'
className="border border-default-200 rounded-md !mb-2 !text-base !h-64"
/>
<div id='toolbar' className='!border-none flex gap-2'>
<div id="toolbar" className="!border-none flex gap-2">
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
<EmojiPicker
onInsertEmoji={onInsertEmoji}
@@ -190,18 +190,18 @@ const ChatInput = () => {
<DiceInsert />
<RPSInsert />
<Button
color='primary'
color="primary"
onPress={() => {
const messages = getChatMessage();
showStructuredMessage(messages);
const messages = getChatMessage()
showStructuredMessage(messages)
}}
className='ml-auto'
className="ml-auto"
>
JSON格式
</Button>
</div>
</div>
);
};
)
}
export default ChatInput;
export default ChatInput

View File

@@ -1,42 +1,42 @@
import { Button } from '@heroui/button';
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from '@heroui/modal';
useDisclosure
} from '@heroui/modal'
import ChatInput from '.';
import ChatInput from '.'
export default function ChatInputModal () {
const { isOpen, onOpen, onOpenChange } = useDisclosure();
export default function ChatInputModal() {
const { isOpen, onOpen, onOpenChange } = useDisclosure()
return (
<>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
</Button>
<Modal
size='4xl'
scrollBehavior='inside'
size="4xl"
scrollBehavior="inside"
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className='flex flex-col gap-1'>
<ModalHeader className="flex flex-col gap-1">
</ModalHeader>
<ModalBody className='overflow-y-auto'>
<div className='overflow-y-auto'>
<ModalBody className="overflow-y-auto">
<div className="overflow-y-auto">
<ChatInput />
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' onPress={onClose} variant='flat'>
<Button color="primary" onPress={onClose} variant="flat">
</Button>
</ModalFooter>
@@ -45,5 +45,5 @@ export default function ChatInputModal () {
</ModalContent>
</Modal>
</>
);
)
}

View File

@@ -1,46 +1,46 @@
import Editor, { OnMount, loader } from '@monaco-editor/react';
import Editor, { OnMount } from '@monaco-editor/react'
import { loader } from '@monaco-editor/react'
import React from 'react'
import React from 'react';
import { useTheme } from '@/hooks/use-theme'
import { useTheme } from '@/hooks/use-theme';
import monaco from '@/monaco';
import monaco from '@/monaco'
loader.config({
monaco,
paths: {
vs: '/webui/monaco-editor/min/vs',
},
});
vs: '/webui/monaco-editor/min/vs'
}
})
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
});
availableLanguages: { '*': 'zh-cn' }
}
})
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
}
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
(props, ref) => {
const { isDark } = useTheme();
const { isDark } = useTheme()
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (ref) {
if (typeof ref === 'function') {
ref(editor);
ref(editor)
} else {
(ref as React.RefObject<CodeEditorRef>).current = editor;
;(ref as React.RefObject<CodeEditorRef>).current = editor
}
}
if (props.onMount) {
props.onMount(editor, monaco);
props.onMount(editor, monaco)
}
};
}
return (
<Editor
@@ -48,8 +48,8 @@ const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
onMount={handleEditorDidMount}
theme={isDark ? 'vs-dark' : 'light'}
/>
);
)
}
);
)
export default CodeEditor;
export default CodeEditor

View File

@@ -1,13 +1,13 @@
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 { 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';
import DisplayCardContainer from './container'
type NetworkType = OneBotConfig['network'];
type NetworkType = OneBotConfig['network']
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string
@@ -15,7 +15,7 @@ export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode
}>;
}>
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]
@@ -36,25 +36,25 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
onEdit,
onEnable,
onDelete,
onEnableDebug,
onEnableDebug
}: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data;
const [editing, setEditing] = useState(false);
const { name, enable, debug } = data
const [editing, setEditing] = useState(false)
const handleEnable = () => {
setEditing(true);
onEnable().finally(() => setEditing(false));
};
setEditing(true)
onEnable().finally(() => setEditing(false))
}
const handleDelete = () => {
setEditing(true);
onDelete().finally(() => setEditing(false));
};
setEditing(true)
onDelete().finally(() => setEditing(false))
}
const handleEnableDebug = () => {
setEditing(true);
onEnableDebug().finally(() => setEditing(false));
};
setEditing(true)
onEnableDebug().finally(() => setEditing(false))
}
return (
<DisplayCardContainer
@@ -62,12 +62,12 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
<ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
radius="sm"
size="sm"
variant="flat"
>
<Button
color='warning'
color="warning"
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
>
@@ -76,14 +76,14 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
<Button
color={debug ? 'secondary' : 'success'}
variant='flat'
variant="flat"
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px',
minHeight: '16px'
}}
/>
}
@@ -92,8 +92,8 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
variant='flat'
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
variant="flat"
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
>
@@ -111,7 +111,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
tag={showType && typeLabel}
title={name}
>
<div className='grid grid-cols-2 gap-1'>
<div className="grid grid-cols-2 gap-1">
{fields.map((field, index) => (
<div
key={index}
@@ -119,19 +119,17 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
field.label === 'URL' ? 'col-span-2' : ''
}`}
>
<span className='text-default-400'>{field.label}</span>
{field.render
? (
field.render(field.value)
)
: (
<span>{field.value}</span>
)}
<span className="text-default-400">{field.label}</span>
{field.render ? (
field.render(field.value)
) : (
<span>{field.value}</span>
)}
</div>
))}
</div>
</DisplayCardContainer>
);
};
)
}
export default NetworkDisplayCard;
export default NetworkDisplayCard

View File

@@ -1,7 +1,7 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import clsx from 'clsx';
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
import clsx from 'clsx'
import { title } from '../primitives';
import { title } from '../primitives'
export interface ContainerProps {
title: string
@@ -24,13 +24,13 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
action,
tag,
enableSwitch,
children,
children
}) => {
return (
<Card className='bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='pb-0 flex items-center'>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardHeader className={'pb-0 flex items-center'}>
{tag && (
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
<div className="text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b">
{tag}
</div>
)}
@@ -39,19 +39,19 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
title({
color: 'foreground',
size: 'xs',
shadow: true,
shadow: true
}),
'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
<div className="ml-auto">{enableSwitch}</div>
</CardHeader>
<CardBody className='text-sm'>{children}</CardBody>
<CardBody className="text-sm">{children}</CardBody>
<CardFooter>{action}</CardFooter>
</Card>
);
};
)
}
export default DisplayCardContainer;
export default DisplayCardContainer

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0]
@@ -13,8 +13,8 @@ interface HTTPClientDisplayCardProps {
}
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { url, reportSelfMessage, messagePostFormat } = data;
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const { url, reportSelfMessage, messagePostFormat } = data
const fields: NetworkDisplayCardFields<'httpClients'> = [
{ label: 'URL', value: url },
@@ -23,25 +23,25 @@ const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'}
</Chip>
),
},
];
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP客户端'
typeLabel="HTTP客户端"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
)
}
export default HTTPClientDisplayCard;
export default HTTPClientDisplayCard

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0]
@@ -13,8 +13,8 @@ interface HTTPServerDisplayCardProps {
}
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data
const fields: NetworkDisplayCardFields<'httpServers'> = [
{ label: '主机', value: host },
@@ -24,34 +24,34 @@ const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
label: 'CORS',
value: enableCors,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '已启用' : '未启用'}
</Chip>
),
)
},
{
label: 'WS',
value: enableWebsocket,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '已启用' : '未启用'}
</Chip>
),
},
];
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP服务器'
typeLabel="HTTP服务器"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
)
}
export default HTTPServerDisplayCard;
export default HTTPServerDisplayCard

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0]
@@ -15,8 +15,8 @@ interface HTTPSSEServerDisplayCardProps {
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data
const fields: NetworkDisplayCardFields<'httpServers'> = [
{ label: '主机', value: host },
@@ -26,34 +26,34 @@ const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
label: 'CORS',
value: enableCors,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '已启用' : '未启用'}
</Chip>
),
)
},
{
label: 'WS',
value: enableWebsocket,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '已启用' : '未启用'}
</Chip>
),
},
];
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP服务器'
typeLabel="HTTP服务器"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
)
}
export default HTTPSSEServerDisplayCard;
export default HTTPSSEServerDisplayCard

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]
@@ -15,14 +15,14 @@ interface WebsocketClientDisplayCardProps {
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const {
url,
heartInterval,
reconnectInterval,
messagePostFormat,
reportSelfMessage,
} = data;
reportSelfMessage
} = data
const fields: NetworkDisplayCardFields<'websocketClients'> = [
{ label: 'URL', value: url },
@@ -33,25 +33,25 @@ const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'}
</Chip>
),
},
];
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='Websocket客户端'
typeLabel="Websocket客户端"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
)
}
export default WebsocketClientDisplayCard;
export default WebsocketClientDisplayCard

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0]
@@ -15,15 +15,15 @@ interface WebsocketServerDisplayCardProps {
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const {
host,
port,
heartInterval,
messagePostFormat,
reportSelfMessage,
enableForcePushEvent,
} = data;
enableForcePushEvent
} = data
const fields: NetworkDisplayCardFields<'websocketServers'> = [
{ label: '主机', value: host },
@@ -34,34 +34,34 @@ const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'}
</Chip>
),
)
},
{
label: '强制推送事件',
value: enableForcePushEvent,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'}
</Chip>
),
},
];
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='Websocket服务器'
typeLabel="Websocket服务器"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
)
}
export default WebsocketServerDisplayCard;
export default WebsocketServerDisplayCard

View File

@@ -1,7 +1,7 @@
import { Card, CardBody } from '@heroui/card';
import clsx from 'clsx';
import { Card, CardBody } from '@heroui/card'
import clsx from 'clsx'
import { title } from '@/components/primitives';
import { title } from '@/components/primitives'
export interface NetworkItemDisplayProps {
count: number
@@ -12,7 +12,7 @@ export interface NetworkItemDisplayProps {
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
count,
label,
size = 'md',
size = 'md'
}) => {
return (
<Card
@@ -22,16 +22,16 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)}
shadow='sm'
shadow="sm"
>
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
<CardBody className="items-center md:gap-1 p-1 md:p-2">
<div
className={clsx(
'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',
size,
size
})
)}
>
@@ -44,7 +44,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs',
size: 'xxs'
})
)}
>
@@ -52,7 +52,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
</div>
</CardBody>
</Card>
);
};
)
}
export default NetworkItemDisplay;
export default NetworkItemDisplay

View File

@@ -1,6 +1,6 @@
import { Card, CardProps } from '@heroui/card';
import clsx from 'clsx';
import React from 'react';
import { Card, CardProps } from '@heroui/card'
import clsx from 'clsx'
import React from 'react'
export interface HoverEffectCardProps extends CardProps {
children: React.ReactNode
@@ -18,15 +18,15 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
className,
style,
lightClassName,
lightStyle,
} = props;
const cardRef = React.useRef<HTMLDivElement | null>(null);
const lightRef = React.useRef<HTMLDivElement | null>(null);
const [isShowLight, setIsShowLight] = React.useState(false);
lightStyle
} = props
const cardRef = React.useRef<HTMLDivElement | null>(null)
const lightRef = React.useRef<HTMLDivElement | null>(null)
const [isShowLight, setIsShowLight] = React.useState(false)
const [pos, setPos] = React.useState({
left: 0,
top: 0,
});
top: 0
})
return (
<Card
@@ -40,53 +40,53 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
willChange: 'transform',
transform:
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
...style,
...style
}}
onMouseEnter={() => {
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.3s ease-out';
cardRef.current.style.transition = 'transform 0.3s ease-out'
}
}}
onMouseLeave={() => {
setIsShowLight(false);
setIsShowLight(false)
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.5s';
cardRef.current.style.transition = 'transform 0.5s'
cardRef.current.style.transform =
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'
}
}}
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
if (cardRef.current) {
setIsShowLight(true);
setIsShowLight(true)
const { x, y } = cardRef.current.getBoundingClientRect();
const { clientX, clientY } = e;
const { x, y } = cardRef.current.getBoundingClientRect()
const { clientX, clientY } = e
const offsetX = clientX - x;
const offsetY = clientY - y;
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 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;
const left = offsetX - lightWidthNum / 2
const top = offsetY - lightHeightNum / 2
setPos({
left,
top,
});
top
})
cardRef.current.style.transition = 'transform 0.1s';
cardRef.current.style.transition = 'transform 0.1s'
const rangeX = 400 / 2;
const rangeY = 400 / 2;
const rangeX = 400 / 2
const rangeY = 400 / 2
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation;
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation;
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)`;
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
}
}}
>
@@ -98,12 +98,12 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
lightClassName
)}
style={{
...pos,
...pos
}}
/>
{children}
</Card>
);
};
)
}
export default HoverEffectCard;
export default HoverEffectCard

View File

@@ -1,30 +1,30 @@
import { Button } from '@heroui/button';
import { Code } from '@heroui/code';
import { MdError } from 'react-icons/md';
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 ({
function errorFallbackRender({
error,
resetErrorBoundary,
resetErrorBoundary
}: ErrorFallbackProps) {
return (
<div className='pt-32 flex flex-col justify-center items-center'>
<div className='flex items-center'>
<MdError className='mr-2' color='red' size={30} />
<h1 className='text-2xl'></h1>
<div className="pt-32 flex flex-col justify-center items-center">
<div className="flex items-center">
<MdError className="mr-2" color="red" size={30} />
<h1 className="text-2xl"></h1>
</div>
<div className='my-6 flex flex-col justify-center items-center'>
<p className='mb-2'></p>
<div className="my-6 flex flex-col justify-center items-center">
<p className="mb-2"></p>
<Code>{error.message}</Code>
</div>
<Button color='primary' size='md' onPress={resetErrorBoundary}>
<Button color="primary" size="md" onPress={resetErrorBoundary}>
</Button>
</div>
);
)
}
export default errorFallbackRender;
export default errorFallbackRender

View File

@@ -11,8 +11,8 @@ import {
FaFileVideo,
FaFileWord,
FaFileZipper,
FaFolderClosed,
} from 'react-icons/fa6';
FaFolderClosed
} from 'react-icons/fa6'
export interface FileIconProps {
name?: string
@@ -20,12 +20,12 @@ export interface FileIconProps {
}
const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props;
const { name, isDirectory = false } = props
if (isDirectory) {
return <FaFolderClosed className='text-yellow-500' />;
return <FaFolderClosed className="text-yellow-500" />
}
const ext = name?.split('.').pop() || '';
const ext = name?.split('.').pop() || ''
if (ext) {
switch (ext.toLowerCase()) {
case 'jpg':
@@ -50,20 +50,20 @@ const FileIcon = (props: FileIconProps) => {
case 'fig':
case 'xd':
case 'svgz':
return <FaFileImage className='text-green-500' />;
return <FaFileImage className="text-green-500" />
case 'pdf':
return <FaFilePdf className='text-red-500' />;
return <FaFilePdf className="text-red-500" />
case 'doc':
case 'docx':
return <FaFileWord className='text-blue-500' />;
return <FaFileWord className="text-blue-500" />
case 'xls':
case 'xlsx':
return <FaFileExcel className='text-green-500' />;
return <FaFileExcel className="text-green-500" />
case 'csv':
return <FaFileCsv className='text-green-500' />;
return <FaFileCsv className="text-green-500" />
case 'ppt':
case 'pptx':
return <FaFilePowerpoint className='text-red-500' />;
return <FaFilePowerpoint className="text-red-500" />
case 'zip':
case 'rar':
case '7z':
@@ -79,18 +79,18 @@ const FileIcon = (props: FileIconProps) => {
case 'taz':
case 'tz':
case 'tzo':
return <FaFileZipper className='text-green-500' />;
return <FaFileZipper className="text-green-500" />
case 'txt':
return <FaFileLines className='text-gray-500' />;
return <FaFileLines className="text-gray-500" />
case 'mp3':
case 'wav':
case 'flac':
return <FaFileAudio className='text-green-500' />;
return <FaFileAudio className="text-green-500" />
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return <FaFileVideo className='text-red-500' />;
return <FaFileVideo className="text-red-500" />
case 'html':
case 'css':
case 'js':
@@ -154,13 +154,13 @@ const FileIcon = (props: FileIconProps) => {
case 'userosscache':
case 'sln.docstates':
case 'dll':
return <FaFileCode className='text-blue-500' />;
return <FaFileCode className="text-blue-500" />
default:
return <FaFile className='text-gray-500' />;
return <FaFile className="text-gray-500" />
}
}
return <FaFile className='text-gray-500' />;
};
return <FaFile className="text-gray-500" />
}
export default FileIcon;
export default FileIcon

View File

@@ -1,12 +1,12 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Input } from '@heroui/input';
import { Button, ButtonGroup } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
ModalHeader
} from '@heroui/modal'
interface CreateFileModalProps {
isOpen: boolean
@@ -18,22 +18,22 @@ interface CreateFileModalProps {
onCreate: () => void
}
export default function CreateFileModal ({
export default function CreateFileModal({
isOpen,
fileType,
newFileName,
onTypeChange,
onNameChange,
onClose,
onCreate,
onCreate
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<ButtonGroup color='primary'>
<div className="flex flex-col gap-4">
<ButtonGroup color="primary">
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
@@ -47,18 +47,18 @@ export default function CreateFileModal ({
</Button>
</ButtonGroup>
<Input label='名称' value={newFileName} onChange={onNameChange} />
<Input label="名称" value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color='primary' onPress={onCreate}>
<Button color="primary" onPress={onCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
)
}

View File

@@ -1,14 +1,14 @@
import { Button } from '@heroui/button';
import { Code } from '@heroui/code';
import { Button } from '@heroui/button'
import { Code } from '@heroui/code'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
ModalHeader
} from '@heroui/modal'
import CodeEditor from '@/components/code_editor';
import CodeEditor from '@/components/code_editor'
interface FileEditModalProps {
isOpen: boolean
@@ -18,61 +18,61 @@ interface FileEditModalProps {
onContentChange: (newContent?: string) => void
}
export default function FileEditModal ({
export default function FileEditModal({
isOpen,
file,
onClose,
onSave,
onContentChange,
onContentChange
}: FileEditModalProps) {
// 根据文件后缀返回对应语言
const getLanguage = (filePath: string) => {
if (filePath.endsWith('.js')) return 'javascript';
if (filePath.endsWith('.ts')) return 'typescript';
if (filePath.endsWith('.tsx')) return 'tsx';
if (filePath.endsWith('.jsx')) return 'jsx';
if (filePath.endsWith('.vue')) return 'vue';
if (filePath.endsWith('.svelte')) return 'svelte';
if (filePath.endsWith('.json')) return 'json';
if (filePath.endsWith('.html')) return 'html';
if (filePath.endsWith('.css')) return 'css';
if (filePath.endsWith('.scss')) return 'scss';
if (filePath.endsWith('.less')) return 'less';
if (filePath.endsWith('.md')) return 'markdown';
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
if (filePath.endsWith('.xml')) return 'xml';
if (filePath.endsWith('.sql')) return 'sql';
if (filePath.endsWith('.sh')) return 'shell';
if (filePath.endsWith('.bat')) return 'bat';
if (filePath.endsWith('.php')) return 'php';
if (filePath.endsWith('.java')) return 'java';
if (filePath.endsWith('.c')) return 'c';
if (filePath.endsWith('.cpp')) return 'cpp';
if (filePath.endsWith('.h')) return 'h';
if (filePath.endsWith('.hpp')) return 'hpp';
if (filePath.endsWith('.go')) return 'go';
if (filePath.endsWith('.py')) return 'python';
if (filePath.endsWith('.rb')) return 'ruby';
if (filePath.endsWith('.cs')) return 'csharp';
if (filePath.endsWith('.swift')) return 'swift';
if (filePath.endsWith('.vb')) return 'vb';
if (filePath.endsWith('.lua')) return 'lua';
if (filePath.endsWith('.pl')) return 'perl';
if (filePath.endsWith('.r')) return 'r';
return 'plaintext';
};
if (filePath.endsWith('.js')) return 'javascript'
if (filePath.endsWith('.ts')) return 'typescript'
if (filePath.endsWith('.tsx')) return 'tsx'
if (filePath.endsWith('.jsx')) return 'jsx'
if (filePath.endsWith('.vue')) return 'vue'
if (filePath.endsWith('.svelte')) return 'svelte'
if (filePath.endsWith('.json')) return 'json'
if (filePath.endsWith('.html')) return 'html'
if (filePath.endsWith('.css')) return 'css'
if (filePath.endsWith('.scss')) return 'scss'
if (filePath.endsWith('.less')) return 'less'
if (filePath.endsWith('.md')) return 'markdown'
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
if (filePath.endsWith('.xml')) return 'xml'
if (filePath.endsWith('.sql')) return 'sql'
if (filePath.endsWith('.sh')) return 'shell'
if (filePath.endsWith('.bat')) return 'bat'
if (filePath.endsWith('.php')) return 'php'
if (filePath.endsWith('.java')) return 'java'
if (filePath.endsWith('.c')) return 'c'
if (filePath.endsWith('.cpp')) return 'cpp'
if (filePath.endsWith('.h')) return 'h'
if (filePath.endsWith('.hpp')) return 'hpp'
if (filePath.endsWith('.go')) return 'go'
if (filePath.endsWith('.py')) return 'python'
if (filePath.endsWith('.rb')) return 'ruby'
if (filePath.endsWith('.cs')) return 'csharp'
if (filePath.endsWith('.swift')) return 'swift'
if (filePath.endsWith('.vb')) return 'vb'
if (filePath.endsWith('.lua')) return 'lua'
if (filePath.endsWith('.pl')) return 'perl'
if (filePath.endsWith('.r')) return 'r'
return 'plaintext'
}
return (
<Modal size='full' isOpen={isOpen} onClose={onClose}>
<Modal size="full" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
<span></span>
<Code className='text-xs'>{file?.path}</Code>
<Code className="text-xs">{file?.path}</Code>
</ModalHeader>
<ModalBody className='p-0'>
<div className='h-full'>
<ModalBody className="p-0">
<div className="h-full">
<CodeEditor
height='100%'
height="100%"
value={file?.content || ''}
onChange={onContentChange}
options={{ wordWrap: 'on' }}
@@ -81,14 +81,14 @@ export default function FileEditModal ({
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color='primary' onPress={onSave}>
<Button color="primary" onPress={onSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
)
}

View File

@@ -1,17 +1,17 @@
import { Button } from '@heroui/button';
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import path from 'path-browserify';
import { useEffect } from 'react';
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager';
import FileManager from '@/controllers/file_manager'
interface FilePreviewModalProps {
isOpen: boolean
@@ -19,74 +19,74 @@ interface FilePreviewModalProps {
onClose: () => void
}
export const videoExts = ['.mp4', '.webm'];
export const audioExts = ['.mp3', '.wav'];
export const videoExts = ['.mp4', '.webm']
export const audioExts = ['.mp3', '.wav']
export const supportedPreviewExts = [...videoExts, ...audioExts];
export const supportedPreviewExts = [...videoExts, ...audioExts]
export default function FilePreviewModal ({
export default function FilePreviewModal({
isOpen,
filePath,
onClose,
onClose
}: FilePreviewModalProps) {
const ext = path.extname(filePath).toLowerCase();
const ext = path.extname(filePath).toLowerCase()
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase();
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !supportedPreviewExts.includes(ext)) {
return;
return
}
run();
},
run()
}
}
);
)
useEffect(() => {
if (filePath) {
run();
run()
}
}, [filePath]);
}, [filePath])
let contentElement = null;
let contentElement = null
if (!supportedPreviewExts.includes(ext)) {
contentElement = <div></div>;
contentElement = <div></div>
} else if (error) {
contentElement = <div></div>;
contentElement = <div></div>
} else if (loading || !data) {
contentElement = (
<div className='flex justify-center items-center h-full'>
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
);
)
} else if (videoExts.includes(ext)) {
contentElement = <video src={data} controls className='max-w-full' />;
contentElement = <video src={data} controls className="max-w-full" />
} else if (audioExts.includes(ext)) {
contentElement = <audio src={data} controls className='w-full' />;
contentElement = <audio src={data} controls className="w-full" />
} else {
contentElement = (
<div className='flex justify-center items-center h-full'>
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
);
)
}
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className='flex justify-center items-center'>
<ModalBody className="flex justify-center items-center">
{contentElement}
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
)
}

View File

@@ -1,6 +1,6 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Pagination } from '@heroui/pagination';
import { Spinner } from '@heroui/spinner';
import { Button, ButtonGroup } from '@heroui/button'
import { Pagination } from '@heroui/pagination'
import { Spinner } from '@heroui/spinner'
import {
type Selection,
type SortDescriptor,
@@ -9,20 +9,20 @@ import {
TableCell,
TableColumn,
TableHeader,
TableRow,
} from '@heroui/table';
import path from 'path-browserify';
import { useCallback, useEffect, useState } from 'react';
import { BiRename } from 'react-icons/bi';
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi';
import { PhotoSlider } from 'react-photo-view';
TableRow
} from '@heroui/table'
import path from 'path-browserify'
import { useCallback, useEffect, useState } from 'react'
import { BiRename } from 'react-icons/bi'
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
import { PhotoSlider } from 'react-photo-view'
import FileIcon from '@/components/file_icon';
import FileIcon from '@/components/file_icon'
import type { FileInfo } from '@/controllers/file_manager';
import type { FileInfo } from '@/controllers/file_manager'
import { supportedPreviewExts } from './file_preview_modal';
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
import { supportedPreviewExts } from './file_preview_modal'
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
export interface FileTableProps {
files: FileInfo[]
@@ -42,9 +42,9 @@ export interface FileTableProps {
onDownload: (filePath: string) => void
}
const PAGE_SIZE = 20;
const PAGE_SIZE = 20
export default function FileTable ({
export default function FileTable({
files,
currentPath,
loading,
@@ -59,40 +59,39 @@ export default function FileTable ({
onMoveRequest,
onCopyPath,
onDelete,
onDownload,
onDownload
}: FileTableProps) {
const [page, setPage] = useState(1);
const pages = Math.ceil(files.length / PAGE_SIZE) || 1;
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const displayFiles = files.slice(start, end);
const [showImage, setShowImage] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([]);
const [page, setPage] = useState(1)
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
const start = (page - 1) * PAGE_SIZE
const end = start + PAGE_SIZE
const displayFiles = files.slice(start, end)
const [showImage, setShowImage] = useState(false)
const [previewIndex, setPreviewIndex] = useState(0)
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
const addPreviewImage = useCallback((image: PreviewImage) => {
setPreviewImages((prev) => {
const exists = prev.some((p) => p.key === image.key);
if (exists) return prev;
return [...prev, image];
});
}, []);
const exists = prev.some((p) => p.key === image.key)
if (exists) return prev
return [...prev, image]
})
}, [])
useEffect(() => {
setPreviewImages([]);
setPreviewIndex(0);
setShowImage(false);
setPage(1);
}, [currentPath]);
setPreviewImages([])
setPreviewIndex(0)
setShowImage(false)
}, [currentPath])
const onPreviewImage = (name: string, images: PreviewImage[]) => {
const index = images.findIndex((image) => image.key === name);
const index = images.findIndex((image) => image.key === name)
if (index === -1) {
return;
return
}
setPreviewIndex(index);
setShowImage(true);
};
setPreviewIndex(index)
setShowImage(true)
}
return (
<>
@@ -104,20 +103,20 @@ export default function FileTable ({
onIndexChange={setPreviewIndex}
/>
<Table
aria-label='文件列表'
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode='multiple'
selectionMode="multiple"
bottomContent={
<div className='flex w-full justify-center'>
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color='primary'
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
@@ -126,65 +125,64 @@ export default function FileTable ({
}
>
<TableHeader>
<TableColumn key='name' allowsSorting>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key='type' allowsSorting>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key='size' allowsSorting>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key='mtime' allowsSorting>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key='actions'></TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className='flex justify-center items-center h-full'>
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
>
{displayFiles.map((file: FileInfo) => {
const filePath = path.join(currentPath, file.name);
const ext = path.extname(file.name).toLowerCase();
const previewable = supportedPreviewExts.includes(ext);
const images = previewImages;
const filePath = path.join(currentPath, file.name)
const ext = path.extname(file.name).toLowerCase()
const previewable = supportedPreviewExts.includes(ext)
const images = previewImages
return (
<TableRow key={file.name}>
<TableCell>
{imageExts.includes(ext)
? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
)
: (
<Button
variant='light'
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)}
className='text-left justify-start'
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
{imageExts.includes(ext) ? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
) : (
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
>
{file.name}
</Button>
)}
className="text-left justify-start"
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
@@ -194,43 +192,43 @@ export default function FileTable ({
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size='sm'>
<ButtonGroup size="sm">
<Button
isIconOnly
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
@@ -238,10 +236,10 @@ export default function FileTable ({
</ButtonGroup>
</TableCell>
</TableRow>
);
)
})}
</TableBody>
</Table>
</>
);
)
}

View File

@@ -1,20 +1,20 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import path from 'path-browserify';
import { useEffect } from 'react';
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager';
import FileManager from '@/controllers/file_manager'
import FileIcon from '../file_icon';
import FileIcon from '../file_icon'
export interface PreviewImage {
key: string
src: string
alt: string
}
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
export interface ImageNameButtonProps {
name: string
@@ -23,11 +23,11 @@ export interface ImageNameButtonProps {
onAddPreview: (image: PreviewImage) => void
}
export default function ImageNameButton ({
export default function ImageNameButton({
name,
filePath,
onPreview,
onAddPreview,
onAddPreview
}: ImageNameButtonProps) {
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
@@ -35,58 +35,54 @@ export default function ImageNameButton ({
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase();
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !imageExts.includes(ext)) {
return;
return
}
run();
},
run()
}
}
);
)
useEffect(() => {
if (data) {
onAddPreview({
key: name,
src: data,
alt: name,
});
alt: name
})
}
}, [data, name, onAddPreview]);
}, [data, name, onAddPreview])
useEffect(() => {
if (filePath) {
run();
run()
}
}, []);
}, [])
return (
<Button
variant='light'
className='text-left justify-start'
variant="light"
className="text-left justify-start"
onPress={onPreview}
startContent={
error
? (
<FileIcon name={name} isDirectory={false} />
)
: loading || !data
? (
<Spinner size='sm' />
)
: (
<Image
src={data}
alt={name}
className='w-8 h-8 flex-shrink-0'
classNames={{
wrapper: 'w-8 h-8 flex-shrink-0',
}}
radius='sm'
/>
)
error ? (
<FileIcon name={name} isDirectory={false} />
) : loading || !data ? (
<Spinner size="sm" />
) : (
<Image
src={data}
alt={name}
className="w-8 h-8 flex-shrink-0"
classNames={{
wrapper: 'w-8 h-8 flex-shrink-0'
}}
radius="sm"
/>
)
}
>
{name}
</Button>
);
)
}

View File

@@ -1,92 +1,92 @@
import { Button } from '@heroui/button';
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import { Spinner } from '@heroui/spinner';
import clsx from 'clsx';
import path from 'path-browserify';
import { useState } from 'react';
import { IoAdd, IoRemove } from 'react-icons/io5';
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import clsx from 'clsx'
import path from 'path-browserify'
import { useState } from 'react'
import { IoAdd, IoRemove } from 'react-icons/io5'
import FileManager from '@/controllers/file_manager';
import FileManager from '@/controllers/file_manager'
interface MoveModalProps {
isOpen: boolean;
moveTargetPath: string;
selectionInfo: string;
onClose: () => void;
onMove: () => void;
onSelect: (dir: string) => void; // 新增回调
isOpen: boolean
moveTargetPath: string
selectionInfo: string
onClose: () => void
onMove: () => void
onSelect: (dir: string) => void // 新增回调
}
// 将 DirectoryTree 改为递归组件
// 新增 selectedPath 属性,用于标识当前选中的目录
function DirectoryTree ({
function DirectoryTree({
basePath,
onSelect,
selectedPath,
selectedPath
}: {
basePath: string;
onSelect: (dir: string) => void;
selectedPath?: string;
basePath: string
onSelect: (dir: string) => void
selectedPath?: string
}) {
const [dirs, setDirs] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
const [dirs, setDirs] = useState<string[]>([])
const [expanded, setExpanded] = useState(false)
// 新增loading状态
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false)
const fetchDirectories = async () => {
try {
// 直接使用 basePath 调用接口,移除 process.platform 判断
const list = await FileManager.listDirectories(basePath);
setDirs(list.map((item) => item.name));
} catch (_error) {
const list = await FileManager.listDirectories(basePath)
setDirs(list.map((item) => item.name))
} catch (error) {
// ...error handling...
}
};
}
const handleToggle = async () => {
if (!expanded) {
setExpanded(true);
setLoading(true);
await fetchDirectories();
setLoading(false);
setExpanded(true)
setLoading(true)
await fetchDirectories()
setLoading(false)
} else {
setExpanded(false);
setExpanded(false)
}
};
}
const handleClick = () => {
onSelect(basePath);
handleToggle();
};
onSelect(basePath)
handleToggle()
}
// 计算显示的名称
const getDisplayName = () => {
if (basePath === '/') return '/';
if (/^[A-Z]:$/i.test(basePath)) return basePath;
return path.basename(basePath);
};
if (basePath === '/') return '/'
if (/^[A-Z]:$/i.test(basePath)) return basePath
return path.basename(basePath)
}
// 更新 Button 的 variant 逻辑
const isSeleted = selectedPath === basePath;
const isSeleted = selectedPath === basePath
const variant = isSeleted
? 'solid'
: selectedPath && path.dirname(selectedPath) === basePath
? 'flat'
: 'light';
: 'light'
return (
<div className='ml-4'>
<div className="ml-4">
<Button
onPress={handleClick}
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
size='sm'
color='primary'
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
size="sm"
color="primary"
variant={variant}
startContent={
<div
@@ -103,68 +103,66 @@ function DirectoryTree ({
</Button>
{expanded && (
<div>
{loading
? (
<div className='flex py-1 px-8'>
<Spinner size='sm' color='primary' />
</div>
)
: (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName);
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})
)}
{loading ? (
<div className="flex py-1 px-8">
<Spinner size="sm" color="primary" />
</div>
) : (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName)
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
)
})
)}
</div>
)}
</div>
);
)
}
export default function MoveModal ({
export default function MoveModal({
isOpen,
moveTargetPath,
selectionInfo,
onClose,
onMove,
onSelect,
onSelect
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
<DirectoryTree
basePath='/'
basePath="/"
onSelect={onSelect}
selectedPath={moveTargetPath}
/>
</div>
<p className='text-sm text-default-500 mt-2'>
<p className="text-sm text-default-500 mt-2">
{moveTargetPath || '未选择'}
</p>
<p className='text-sm text-default-500'>{selectionInfo}</p>
<p className="text-sm text-default-500">{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color='primary' onPress={onMove}>
<Button color="primary" onPress={onMove}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
)
}

View File

@@ -1,12 +1,12 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
ModalHeader
} from '@heroui/modal'
interface RenameModalProps {
isOpen: boolean
@@ -16,29 +16,29 @@ interface RenameModalProps {
onRename: () => void
}
export default function RenameModal ({
export default function RenameModal({
isOpen,
newFileName,
onNameChange,
onClose,
onRename,
onRename
}: RenameModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input label='新名称' value={newFileName} onChange={onNameChange} />
<Input label="新名称" value={newFileName} onChange={onNameChange} />
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color='primary' onPress={onRename}>
<Button color="primary" onPress={onRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
)
}

View File

@@ -1,4 +1,4 @@
import clsx from 'clsx';
import clsx from 'clsx'
export interface IconWrapperProps {
children?: React.ReactNode
@@ -14,6 +14,6 @@ const IconWrapper = ({ children, className }: IconWrapperProps) => (
>
{children}
</div>
);
)
export default IconWrapper;
export default IconWrapper

View File

@@ -1,10 +1,10 @@
import { ChevronRightIcon } from '../icons';
import { ChevronRightIcon } from '../icons'
const ItemCounter = ({ number }: { number: number }) => (
<div className='flex items-center gap-1 text-default-400'>
<span className='text-small'>{number}</span>
<ChevronRightIcon className='text-xl' />
<div className="flex items-center gap-1 text-default-400">
<span className="text-small">{number}</span>
<ChevronRightIcon className="text-xl" />
</div>
);
)
export default ItemCounter;
export default ItemCounter

View File

@@ -1,40 +1,40 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
import { getReleaseTime } from '@/utils/time';
import { getReleaseTime } from '@/utils/time'
import type { GithubRelease as GithubReleaseType } from '@/types/github';
import type { GithubRelease as GithubReleaseType } from '@/types/github'
export interface GithubReleaseProps {
releaseData: GithubReleaseType
}
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
const { releaseData } = props;
const [releaseTime, setReleaseTime] = useState<string | null>(null);
const { releaseData } = props
const [releaseTime, setReleaseTime] = useState<string | null>(null)
useEffect(() => {
if (releaseData) {
const timer = setInterval(() => {
const time = getReleaseTime(releaseData.published_at);
const time = getReleaseTime(releaseData.published_at)
setReleaseTime(time);
}, 1000);
setReleaseTime(time)
}, 1000)
return () => clearInterval(timer);
return () => clearInterval(timer)
}
}, [releaseData]);
}, [releaseData])
return (
<div className='flex flex-col gap-1'>
<div className="flex flex-col gap-1">
<span>Releases</span>
<div className='px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200'>
<span className='text-tiny text-default-600'>{releaseData.name}</span>
<div className='flex gap-2 text-tiny'>
<span className='text-default-500'>{releaseTime}</span>
<span className='text-success'>Latest</span>
<div className="px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200">
<span className="text-tiny text-default-600">{releaseData.name}</span>
<div className="flex gap-2 text-tiny">
<span className="text-default-500">{releaseTime}</span>
<span className="text-success">Latest</span>
</div>
</div>
</div>
);
};
)
}
export default GithubRelease;
export default GithubRelease

View File

@@ -1,78 +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 { 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 { request } from '@/utils/request'
import PageLoading from './page_loading';
import PageLoading from './page_loading'
export default function Hitokoto () {
export default function Hitokoto() {
const {
data: dataOri,
error,
loading,
run,
run
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
pollingInterval: 10000,
throttleWait: 1000,
});
const data = dataOri?.data;
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('复制失败, 请手动复制');
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`
navigator.clipboard.writeText(text)
toast.success('复制成功')
} catch (error) {
toast.error('复制失败, 请手动复制')
}
};
}
return (
<div>
<div className='relative'>
<div className="relative">
{loading && <PageLoading />}
{error
? (
<div className='text-primary-400'>{error.message}</div>
)
: (
<>
<div>{data?.hitokoto}</div>
<div className='text-right'>
<span className='text-default-400'>{data?.from}</span>{' '}
{data?.from_who}
</div>
</>
)}
{error ? (
<div className="text-primary-400">{error.message}</div>
) : (
<>
<div>{data?.hitokoto}</div>
<div className="text-right">
<span className="text-default-400">{data?.from}</span>{' '}
{data?.from_who}
</div>
</>
)}
</div>
<div className='flex gap-2'>
<Tooltip content='刷新' placement='top'>
<div className="flex gap-2">
<Tooltip content="刷新" placement="top">
<Button
onPress={run}
size='sm'
size="sm"
isLoading={loading}
isIconOnly
radius='full'
color='primary'
variant='flat'
radius="full"
color="primary"
variant="flat"
>
<IoRefresh />
</Button>
</Tooltip>
<Tooltip content='复制' placement='top'>
<Tooltip content="复制" placement="top">
<Button
onPress={onCopy}
size='sm'
size="sm"
isIconOnly
radius='full'
color='success'
variant='flat'
radius="full"
color="success"
variant="flat"
>
<IoCopy />
</Button>
</Tooltip>
</div>
</div>
);
)
}

View File

@@ -1,11 +1,11 @@
import { motion, useMotionValue, useSpring } from 'motion/react';
import { useRef, useState } from 'react';
import { motion, useMotionValue, useSpring } from 'motion/react'
import { useRef, useState } from 'react'
const springValues = {
damping: 30,
stiffness: 100,
mass: 2,
};
mass: 2
}
export interface HoverTiltedCardProps {
imageSrc: string
@@ -22,7 +22,7 @@ export interface HoverTiltedCardProps {
displayOverlayContent?: boolean
}
export default function HoverTiltedCard ({
export default function HoverTiltedCard({
imageSrc,
altText = 'NapCat',
captionText = 'NapCat',
@@ -34,95 +34,95 @@ export default function HoverTiltedCard ({
rotateAmplitude = 14,
showTooltip = false,
overlayContent = (
<div className='text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80'>
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80">
NapCat
</div>
),
displayOverlayContent = true,
displayOverlayContent = true
}: HoverTiltedCardProps) {
const ref = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useMotionValue(0), springValues)
const rotateY = useSpring(useMotionValue(0), springValues)
const scale = useSpring(1, springValues)
const opacity = useSpring(0)
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
mass: 1
})
const [lastY, setLastY] = useState(0);
const [lastY, setLastY] = useState(0)
function handleMouse (e: React.MouseEvent) {
if (!ref.current) return;
function handleMouse(e: React.MouseEvent) {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rect = ref.current.getBoundingClientRect()
const offsetX = e.clientX - rect.left - rect.width / 2
const offsetY = e.clientY - rect.top - rect.height / 2
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
rotateX.set(rotationX);
rotateY.set(rotationY);
rotateX.set(rotationX)
rotateY.set(rotationY)
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
const velocityY = offsetY - lastY
rotateFigcaption.set(-velocityY * 0.6)
setLastY(offsetY)
}
function handleMouseEnter () {
scale.set(scaleOnHover);
opacity.set(1);
function handleMouseEnter() {
scale.set(scaleOnHover)
opacity.set(1)
}
function handleMouseLeave () {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
function handleMouseLeave() {
opacity.set(0)
scale.set(1)
rotateX.set(0)
rotateY.set(0)
rotateFigcaption.set(0)
}
return (
<figure
ref={ref}
className='relative w-full h-full [perspective:800px] flex flex-col items-center justify-center'
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth,
width: containerWidth
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<motion.div
className='relative [transform-style:preserve-3d]'
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
scale
}}
>
<motion.img
src={imageSrc}
alt={altText}
className='absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none'
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
style={{
width: imageWidth,
height: imageHeight,
height: imageHeight
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className='absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]'>
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
@@ -130,17 +130,17 @@ export default function HoverTiltedCard ({
{showTooltip && (
<motion.figcaption
className='pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block'
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption,
rotate: rotateFigcaption
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,44 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { useRef, useState } from 'react';
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import { useRef, useState } from 'react'
export interface FileInputProps {
onChange: (file: File) => Promise<void> | void;
onDelete?: () => Promise<void> | void;
label?: string;
accept?: string;
onChange: (file: File) => Promise<void> | void
onDelete?: () => Promise<void> | void
label?: string
accept?: string
}
const FileInput: React.FC<FileInputProps> = ({
onChange,
onDelete,
label,
accept,
accept
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null)
const [isLoading, setIsLoading] = useState(false)
return (
<div className='flex items-end gap-2'>
<div className='flex-grow'>
<div className="flex items-end gap-2">
<div className="flex-grow">
<Input
isDisabled={isLoading}
ref={inputRef}
label={label}
type='file'
placeholder='选择文件'
type="file"
placeholder="选择文件"
accept={accept}
onChange={async (e) => {
try {
setIsLoading(true);
const file = e.target.files?.[0];
setIsLoading(true)
const file = e.target.files?.[0]
if (file) {
await onChange(file);
await onChange(file)
}
} catch (error) {
console.error(error);
console.error(error)
} finally {
setIsLoading(false);
if (inputRef.current) inputRef.current.value = '';
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
/>
@@ -47,23 +47,23 @@ const FileInput: React.FC<FileInputProps> = ({
isDisabled={isLoading}
onPress={async () => {
try {
setIsLoading(true);
if (onDelete) await onDelete();
setIsLoading(true)
if (onDelete) await onDelete()
} catch (error) {
console.error(error);
console.error(error)
} finally {
setIsLoading(false);
if (inputRef.current) inputRef.current.value = '';
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
color='primary'
variant='flat'
size='sm'
color="primary"
variant="flat"
size="sm"
>
</Button>
</div>
);
};
)
}
export default FileInput;
export default FileInput

View File

@@ -1,7 +1,7 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Input } from '@heroui/input';
import { useRef } from 'react';
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
@@ -10,47 +10,47 @@ export interface ImageInputProps {
}
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className='flex items-end gap-2'>
<div className='w-5 h-5 flex-shrink-0'>
<div className="flex items-end gap-2">
<div className="w-5 h-5 flex-shrink-0">
<Image
src={value}
alt={label}
className='w-5 h-5 flex-shrink-0 rounded-none'
className="w-5 h-5 flex-shrink-0 rounded-none"
/>
</div>
<Input
ref={inputRef}
label={label}
type='file'
placeholder='选择图片'
accept='image/*'
type="file"
placeholder="选择图片"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = async () => {
const base64 = reader.result as string;
onChange(base64);
};
reader.readAsDataURL(file);
const base64 = reader.result as string
onChange(base64)
}
reader.readAsDataURL(file)
}
}}
/>
<Button
onPress={() => {
onChange('');
if (inputRef.current) inputRef.current.value = '';
onChange('')
if (inputRef.current) inputRef.current.value = ''
}}
color='primary'
variant='flat'
size='sm'
color="primary"
variant="flat"
size="sm"
>
</Button>
</div>
);
};
)
}
export default ImageInput;
export default ImageInput

View File

@@ -1,15 +1,15 @@
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 { 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 { colorizeLogLevel } from '@/utils/terminal'
import PageLoading from '../page_loading';
import XTerm from '../xterm';
import type { XTermRef } from '../xterm';
import LogLevelSelect from './log_level_select';
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[]
@@ -32,80 +32,80 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
listLoading,
logContent,
listError,
logLoading,
} = props;
const Xterm = useRef<XTermRef>(null);
logLoading
} = props
const Xterm = useRef<XTermRef>(null)
const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error'])
);
)
const logToColored = (log: string) => {
const logs = log
.split('\n')
.map((line) => {
const colored = colorizeLogLevel(line);
return colored;
const colored = colorizeLogLevel(line)
return colored
})
.filter((log) => {
if (logLevel === 'all') {
return true;
return true
}
return logLevel.has(log.level);
return logLevel.has(log.level)
})
.map((log) => log.content)
.join('\r\n');
return logs;
};
.join('\r\n')
return logs
}
const onDownloadLog = () => {
if (!logContent) {
return;
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);
};
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;
return
}
Xterm.current.clear();
const _logContent = logToColored(logContent);
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ');
}, [logContent, logLevel]);
Xterm.current.clear()
const _logContent = logToColored(logContent)
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ')
}, [logContent, logLevel])
return (
<>
<title> - NapCat WebUI</title>
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='flex-row justify-start gap-3'>
<Card className="max-w-full h-full bg-opacity-50 backdrop-blur-sm">
<CardHeader className="flex-row justify-start gap-3">
<Select
label='选择日志'
size='sm'
label="选择日志"
size="sm"
isLoading={listLoading}
errorMessage={listError?.message}
classNames={{
trigger:
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60'
}}
placeholder='选择日志'
placeholder="选择日志"
onChange={(e) => {
const value = e.target.value;
const value = e.target.value
if (!value) {
return;
return
}
onSelect(value);
onSelect(value)
}}
selectedKeys={[selectedLog || '']}
items={list.map((name) => ({
value: name,
label: name,
label: name
}))}
>
{(item) => (
@@ -118,19 +118,19 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
/>
<Button className='flex-shrink-0' onPress={onDownloadLog}>
<Button className="flex-shrink-0" onPress={onDownloadLog}>
</Button>
<Button onPress={refreshList}></Button>
<Button onPress={refreshLog}></Button>
</CardHeader>
<CardBody className='relative'>
<CardBody className="relative">
<PageLoading loading={logLoading} />
<XTerm className='w-full h-full' ref={Xterm} />
<XTerm className="w-full h-full" ref={Xterm} />
</CardBody>
</Card>
</>
);
};
)
}
export default HistoryLogs;
export default HistoryLogs

View File

@@ -1,9 +1,9 @@
import { Chip } from '@heroui/chip';
import { Select, SelectItem } from '@heroui/select';
import { SharedSelection } from '@heroui/system';
import type { Selection } from '@react-types/shared';
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';
import { LogLevel } from '@/const/enum'
export interface LogLevelSelectProps {
selectedKeys: Selection
@@ -22,57 +22,57 @@ const logLevelColor: {
[LogLevel.INFO]: 'primary',
[LogLevel.WARN]: 'warning',
[LogLevel.ERROR]: 'primary',
[LogLevel.FATAL]: 'primary',
};
[LogLevel.FATAL]: 'primary'
}
const LogLevelSelect = (props: LogLevelSelectProps) => {
const { selectedKeys, onSelectionChange } = props;
const { selectedKeys, onSelectionChange } = props
return (
<Select
selectedKeys={selectedKeys}
onSelectionChange={(selectedKeys) => {
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
selectedKeys = 'all';
selectedKeys = 'all'
}
onSelectionChange(selectedKeys);
onSelectionChange(selectedKeys)
}}
label='日志级别'
selectionMode='multiple'
aria-label='Log Level'
label="日志级别"
selectionMode="multiple"
aria-label="Log Level"
classNames={{
label: 'mb-2',
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
popoverContent: 'bg-opacity-50 backdrop-blur-sm',
popoverContent: 'bg-opacity-50 backdrop-blur-sm'
}}
size='sm'
size="sm"
items={[
{ label: 'Debug', value: LogLevel.DEBUG },
{ label: 'Info', value: LogLevel.INFO },
{ label: 'Warn', value: LogLevel.WARN },
{ label: 'Error', value: LogLevel.ERROR },
{ label: 'Fatal', value: LogLevel.FATAL },
{ label: 'Fatal', value: LogLevel.FATAL }
]}
renderValue={(value) => {
if (value.length === 5) {
return (
<Chip size='sm' color='primary' variant='flat'>
<Chip size="sm" color="primary" variant="flat">
</Chip>
);
)
}
return (
<div className='flex gap-2'>
<div className="flex gap-2">
{value.map((v) => (
<Chip
size='sm'
size="sm"
key={v.key}
color={logLevelColor[v.data?.value as LogLevel]}
variant='flat'
variant="flat"
>
{v.data?.label}
</Chip>
))}
</div>
);
)
}}
>
{(item) => (
@@ -81,7 +81,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
</SelectItem>
)}
</Select>
);
};
)
}
export default LogLevelSelect;
export default LogLevelSelect

View File

@@ -1,114 +1,114 @@
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 { 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 { colorizeLogLevelWithTag } from '@/utils/terminal'
import WebUIManager, { Log } from '@/controllers/webui_manager';
import WebUIManager, { Log } from '@/controllers/webui_manager'
import type { XTermRef } from '../xterm';
import XTerm from '../xterm';
import LogLevelSelect from './log_level_select';
import type { XTermRef } from '../xterm'
import XTerm from '../xterm'
import LogLevelSelect from './log_level_select'
const RealTimeLogs = () => {
const Xterm = useRef<XTermRef>(null);
const Xterm = useRef<XTermRef>(null)
const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error'])
);
const [dataArr, setDataArr] = useState<Log[]>([]);
)
const [dataArr, setDataArr] = useState<Log[]>([])
const onDownloadLog = () => {
const logContent = dataArr
.filter((log) => {
if (logLevel === 'all') {
return true;
return true
}
return logLevel.has(log.level);
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);
};
.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 true
}
return logLevel.has(log.level);
return logLevel.has(log.level)
})
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n');
Xterm.current?.clear();
Xterm.current?.write(_data);
.join('\r\n')
Xterm.current?.clear()
Xterm.current?.write(_data)
} catch (error) {
console.error(error);
toast.error('获取实时日志失败');
console.error(error)
toast.error('获取实时日志失败')
}
};
}
useEffect(() => {
writeStream();
}, [logLevel, dataArr]);
writeStream()
}, [logLevel, dataArr])
useEffect(() => {
const subscribeLogs = () => {
try {
const source = WebUIManager.getRealTimeLogs((data) => {
setDataArr((prev) => {
const newData = [...prev, ...data];
const newData = [...prev, ...data]
if (newData.length > 1000) {
newData.splice(0, newData.length - 1000);
newData.splice(0, newData.length - 1000)
}
return newData;
});
});
return newData
})
})
return () => {
source.close();
};
} catch (_error) {
toast.error('获取实时日志失败');
source.close()
}
} catch (error) {
toast.error('获取实时日志失败')
}
};
}
const close = subscribeLogs();
const close = subscribeLogs()
return () => {
console.log('close');
close?.();
};
}, []);
console.log('close')
close?.()
}
}, [])
return (
<>
<title> - NapCat WebUI</title>
<div className='flex items-center gap-2'>
<div className="flex items-center gap-2">
<LogLevelSelect
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
/>
<Button
className='flex-shrink-0'
className="flex-shrink-0"
onPress={onDownloadLog}
startContent={<IoDownloadOutline className='text-lg' />}
startContent={<IoDownloadOutline className="text-lg" />}
>
</Button>
</div>
<div className='flex-1 h-full overflow-hidden'>
<div className="flex-1 h-full overflow-hidden">
<XTerm ref={Xterm} />
</div>
</>
);
};
)
}
export default RealTimeLogs;
export default RealTimeLogs

View File

@@ -1,13 +1,13 @@
import { Button } from '@heroui/button';
import { Button } from '@heroui/button'
import {
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Modal as NextUIModal,
useDisclosure,
} from '@heroui/modal';
import React from 'react';
useDisclosure
} from '@heroui/modal'
import React from 'react'
export interface ModalProps {
content: React.ReactNode
@@ -37,8 +37,8 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onConfirm,
onCancel,
...rest
} = props;
const { onClose: onNativeClose } = useDisclosure();
} = props
const { onClose: onNativeClose } = useDisclosure()
return (
<NextUIModal
@@ -46,12 +46,12 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
backdrop={backdrop}
isDismissable={dismissible}
onClose={() => {
onClose?.();
onNativeClose();
onClose?.()
onNativeClose()
}}
classNames={{
backdrop: 'z-[99]',
wrapper: 'z-[99]',
wrapper: 'z-[99]'
}}
{...rest}
>
@@ -59,27 +59,27 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
{(nativeClose) => (
<>
{title && (
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
)}
<ModalBody className='break-all'>{content}</ModalBody>
<ModalBody className="break-all">{content}</ModalBody>
<ModalFooter>
{showCancel && (
<Button
color='primary'
variant='light'
color="primary"
variant="light"
onPress={() => {
onCancel?.();
nativeClose();
onCancel?.()
nativeClose()
}}
>
{cancelText}
</Button>
)}
<Button
color='primary'
color="primary"
onPress={() => {
onConfirm?.();
nativeClose();
onConfirm?.()
nativeClose()
}}
>
{confirmText}
@@ -89,9 +89,9 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
)}
</ModalContent>
</NextUIModal>
);
});
)
})
Modal.displayName = 'Modal';
Modal.displayName = 'Modal'
export default Modal;
export default Modal

View File

@@ -1,11 +1,11 @@
import { Listbox, ListboxItem } from '@heroui/listbox';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import { MdError } from 'react-icons/md';
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 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,
@@ -13,197 +13,193 @@ import {
StarIcon,
TagIcon,
UsersIcon,
WatchersIcon,
} from '@/components/icons';
WatchersIcon
} from '@/components/icons'
import { request } from '@/utils/request';
import { openUrl } from '@/utils/url';
import { request } from '@/utils/request'
import { openUrl } from '@/utils/url'
import type {
GirhubRepo,
GithubContributor,
GithubPullRequest,
GithubRelease as GithubReleaseType,
} from '@/types/github';
GithubRelease as GithubReleaseType
} from '@/types/github'
function displayData (data: number, loading: boolean, error?: Error) {
function displayData(data: number, loading: boolean, error?: Error) {
if (error) {
return <MdError className='text-primary-400' />;
return <MdError className="text-primary-400" />
}
if (loading) {
return <Spinner size='sm' />;
return <Spinner size="sm" />
}
return <ItemCounter number={data} />;
return <ItemCounter number={data} />
}
export default function NapCatRepoInfo () {
export default function NapCatRepoInfo() {
// repo info
const {
data: repoOriData,
error: repoError,
loading: repoLoading,
loading: repoLoading
} = useRequest(() =>
request.get<GirhubRepo>('https://api.github.com/repos/NapNeko/NapCatQQ')
);
)
// release info
const {
data: releaseOriData,
error: releaseError,
loading: releaseLoading,
loading: releaseLoading
} = useRequest(() =>
request.get<GithubReleaseType[]>(
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
)
);
)
// pr info
const {
data: prData,
error: prError,
loading: prLoading,
loading: prLoading
} = useRequest(() =>
request.get<GithubPullRequest[]>(
'https://api.github.com/repos/NapNeko/NapCatQQ/pulls'
)
);
)
// contributors info
const {
data: contributorsData,
error: contributorsError,
loading: contributorsLoading,
loading: contributorsLoading
} = useRequest(() =>
request.get<GithubContributor[]>(
'https://api.github.com/repos/NapNeko/NapCatQQ/contributors'
)
);
)
const repoData = repoOriData?.data;
const releaseData = releaseOriData?.data?.[0];
const prCount = prData?.data?.length || 0;
const contributorsCount = contributorsData?.data?.length || 0;
const 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;
const releaseCount = releaseOriData?.data?.length || 0
return (
<Listbox
aria-label='NapCat Repo Info'
className='p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 max-w-[300px] overflow-visible shadow-small rounded-medium bg-opacity-50 backdrop-blur-sm'
aria-label="NapCat Repo Info"
className="p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 max-w-[300px] overflow-visible shadow-small rounded-medium bg-opacity-50 backdrop-blur-sm"
itemClasses={{
base: 'px-3 first:rounded-t-medium last:rounded-b-medium rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80',
base: 'px-3 first:rounded-t-medium last:rounded-b-medium rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80'
}}
onAction={(key: React.Key) => {
switch (key) {
case 'releases':
openUrl('https://github.com/NapNeko/NapCatQQ/releases', true);
break;
openUrl('https://github.com/NapNeko/NapCatQQ/releases', true)
break
case 'contributors':
openUrl(
'https://github.com/NapNeko/NapCatQQ/graphs/contributors',
true
);
break;
)
break
case 'license':
openUrl(
'https://github.com/NapNeko/NapCatQQ/blob/main/LICENSE',
true
);
break;
)
break
case 'watchers':
openUrl('https://github.com/NapNeko/NapCatQQ/watchers', true);
break;
openUrl('https://github.com/NapNeko/NapCatQQ/watchers', true)
break
case 'star':
openUrl('https://github.com/NapNeko/NapCatQQ/stargazers', true);
break;
openUrl('https://github.com/NapNeko/NapCatQQ/stargazers', true)
break
case 'issues':
openUrl('https://github.com/NapNeko/NapCatQQ/issues', true);
break;
openUrl('https://github.com/NapNeko/NapCatQQ/issues', true)
break
case 'pull_requests':
openUrl('https://github.com/NapNeko/NapCatQQ/pulls', true);
break;
openUrl('https://github.com/NapNeko/NapCatQQ/pulls', true)
break
default:
openUrl('https://github.com/NapNeko/NapCatQQ', true);
openUrl('https://github.com/NapNeko/NapCatQQ', true)
}
}}
>
<ListboxItem
key='star'
key="star"
endContent={displayData(
repoData?.stargazers_count ?? 0,
false,
repoError
)}
startContent={
<IconWrapper className='bg-success/10 text-success'>
<StarIcon className='text-lg' />
<IconWrapper className="bg-success/10 text-success">
<StarIcon className="text-lg" />
</IconWrapper>
}
>
Star
</ListboxItem>
<ListboxItem
key='issues'
key="issues"
endContent={displayData(
repoData?.open_issues_count ?? 0,
false,
repoError
)}
startContent={
<IconWrapper className='bg-success/10 text-success'>
<BugIcon className='text-lg' />
<IconWrapper className="bg-success/10 text-success">
<BugIcon className="text-lg" />
</IconWrapper>
}
>
Issues
</ListboxItem>
<ListboxItem
key='pull_requests'
key="pull_requests"
endContent={displayData(prCount, prLoading, prError)}
startContent={
<IconWrapper className='bg-primary/10 text-primary'>
<PullRequestIcon className='text-lg' />
<IconWrapper className="bg-primary/10 text-primary">
<PullRequestIcon className="text-lg" />
</IconWrapper>
}
>
Pull Requests
</ListboxItem>
<ListboxItem
key='releases'
className='group h-auto py-3'
key="releases"
className="group h-auto py-3"
endContent={
releaseError
? (
<MdError className='text-primary-400' />
)
: releaseLoading
? (
<Spinner size='sm' />
)
: (
<ItemCounter number={releaseCount} />
)
releaseError ? (
<MdError className="text-primary-400" />
) : releaseLoading ? (
<Spinner size="sm" />
) : (
<ItemCounter number={releaseCount} />
)
}
startContent={
<IconWrapper className='bg-primary/10 text-primary'>
<TagIcon className='text-lg' />
<IconWrapper className="bg-primary/10 text-primary">
<TagIcon className="text-lg" />
</IconWrapper>
}
textValue='Releases'
textValue="Releases"
>
{releaseData && <GithubRelease releaseData={releaseData} />}
</ListboxItem>
<ListboxItem
key='contributors'
key="contributors"
endContent={displayData(
contributorsCount,
contributorsLoading,
contributorsError
)}
startContent={
<IconWrapper className='bg-warning/10 text-warning'>
<IconWrapper className="bg-warning/10 text-warning">
<UsersIcon />
</IconWrapper>
}
@@ -211,14 +207,14 @@ export default function NapCatRepoInfo () {
Contributors
</ListboxItem>
<ListboxItem
key='watchers'
key="watchers"
endContent={displayData(
repoData?.watchers_count ?? 0,
repoLoading,
repoError
)}
startContent={
<IconWrapper className='bg-default/50 text-foreground'>
<IconWrapper className="bg-default/50 text-foreground">
<WatchersIcon />
</IconWrapper>
}
@@ -226,14 +222,14 @@ export default function NapCatRepoInfo () {
Watchers
</ListboxItem>
<ListboxItem
key='license'
key="license"
endContent={
<span className='text-small text-default-400'>
<span className="text-small text-default-400">
{repoData?.license?.name ?? 'unknown'}
</span>
}
startContent={
<IconWrapper className='bg-primary/10 text-primary dark:text-primary-500'>
<IconWrapper className="bg-primary/10 text-primary dark:text-primary-500">
<BookIcon />
</IconWrapper>
}
@@ -241,5 +237,5 @@ export default function NapCatRepoInfo () {
License
</ListboxItem>
</Listbox>
);
)
}

View File

@@ -1,87 +1,87 @@
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 { 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';
SubmitHandler
} from 'react-hook-form'
import toast from 'react-hot-toast'
import SwitchCard from '../switch_card';
import SwitchCard from '../switch_card'
export type FieldTypes = 'input' | 'select' | 'switch';
export type FieldTypes = 'input' | 'select' | 'switch'
type NetworkConfigType = OneBotConfig['network'];
type NetworkConfigType = OneBotConfig['network']
export interface Field<T extends keyof OneBotConfig['network']> {
name: keyof NetworkConfigType[T][0];
label: string;
type: FieldTypes;
options?: Array<{ key: string; value: string; }>;
placeholder?: string;
isRequired?: boolean;
isDisabled?: boolean;
description?: string;
colSpan?: 1 | 2;
name: keyof NetworkConfigType[T][0]
label: string
type: FieldTypes
options?: Array<{ key: string; value: string }>
placeholder?: string
isRequired?: boolean
isDisabled?: boolean
description?: string
colSpan?: 1 | 2
}
export interface GenericFormProps<T extends keyof NetworkConfigType> {
data?: NetworkConfigType[T][0];
defaultValues: DefaultValues<NetworkConfigType[T][0]>;
onClose: () => void;
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>;
fields: Array<Field<T>>;
data?: NetworkConfigType[T][0]
defaultValues: DefaultValues<NetworkConfigType[T][0]>
onClose: () => void
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>
fields: Array<Field<T>>
}
const GenericForm = <T extends keyof NetworkConfigType> ({
const GenericForm = <T extends keyof NetworkConfigType>({
data,
defaultValues,
onClose,
onSubmit,
fields,
fields
}: GenericFormProps<T>): ReactElement => {
const { control, handleSubmit, formState, setValue, reset } = useForm<
NetworkConfigType[T][0]
>({
defaultValues,
});
defaultValues
})
const submitAction: SubmitHandler<NetworkConfigType[T][0]> = async (data) => {
await onSubmit(data);
onClose();
};
await onSubmit(data)
onClose()
}
const _onSubmit = handleSubmit(submitAction, (e) => {
const errors = Object.values(e);
if (errors.length > 0) {
toast.error(errors[0]?.message as string);
for (const error in e) {
toast.error(e[error]?.message as string)
return
}
});
})
useEffect(() => {
if (data) {
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[];
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[]
for (const key of keys) {
const value = data[key] as PathValue<
NetworkConfig[T][0],
Path<NetworkConfig[T][0]>
>;
setValue(key, value);
>
setValue(key, value)
}
} else {
reset();
reset()
}
}, [data, reset, setValue]);
}, [data, reset, setValue])
return (
<>
<ModalBody>
<div className='grid grid-cols-2 gap-y-4 gap-x-2 w-full'>
<div className="grid grid-cols-2 gap-y-4 gap-x-2 w-full">
{fields.map((field) => (
<div
key={field.name as string}
@@ -93,9 +93,9 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
rules={
field.isRequired
? {
required: `请填写${field.label}`,
}
: undefined
required: `请填写${field.label}`
}
: void 0
}
render={({ field: controllerField }) => {
switch (field.type) {
@@ -103,14 +103,15 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
return (
<Input
value={controllerField.value as string}
onValueChange={(value) => controllerField.onChange(value)}
onChange={controllerField.onChange}
onBlur={controllerField.onBlur}
ref={controllerField.ref}
isRequired={field.isRequired}
isDisabled={field.isDisabled}
label={field.label}
placeholder={field.placeholder}
/>
);
)
case 'select':
return (
<Select
@@ -128,7 +129,7 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
</SelectItem>
)) || <></>}
</Select>
);
)
case 'switch':
return (
<SwitchCard
@@ -137,9 +138,9 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
description={field.description}
label={field.label}
/>
);
)
default:
return <></>;
return <></>
}
}}
/>
@@ -149,15 +150,15 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
</ModalBody>
<ModalFooter>
<Button
color='primary'
color="primary"
isDisabled={formState.isSubmitting}
variant='light'
variant="light"
onPress={onClose}
>
</Button>
<Button
color='primary'
color="primary"
isLoading={formState.isSubmitting}
onPress={() => _onSubmit()}
>
@@ -165,16 +166,15 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
</Button>
</ModalFooter>
</>
);
};
export default GenericForm;
export function random_token (length: number) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
)
}
export default GenericForm
export function random_token(length: number) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}

View File

@@ -1,5 +1,5 @@
import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form';
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPClientFormProps {
data?: OneBotConfig['network']['httpClients'][0]
@@ -7,12 +7,12 @@ export interface HTTPClientFormProps {
onSubmit: (data: OneBotConfig['network']['httpClients'][0]) => Promise<void>
}
type HTTPClientFormType = OneBotConfig['network']['httpClients'];
type HTTPClientFormType = OneBotConfig['network']['httpClients']
const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
data,
onClose,
onSubmit,
onSubmit
}) => {
const defaultValues: HTTPClientFormType[0] = {
enable: false,
@@ -21,8 +21,8 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
reportSelfMessage: false,
messagePostFormat: 'array',
token: random_token(16),
debug: false,
};
debug: false
}
const fields: Field<'httpClients'>[] = [
{
@@ -30,14 +30,14 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
label: '启用',
type: 'switch',
description: '保存后启用此配置',
colSpan: 1,
colSpan: 1
},
{
name: 'debug',
label: '开启Debug',
type: 'switch',
description: '是否开启调试模式',
colSpan: 1,
colSpan: 1
},
{
name: 'name',
@@ -45,21 +45,21 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
type: 'input',
placeholder: '请输入名称',
isRequired: true,
isDisabled: !!data,
isDisabled: !!data
},
{
name: 'url',
label: 'URL',
type: 'input',
placeholder: '请输入URL',
isRequired: true,
isRequired: true
},
{
name: 'reportSelfMessage',
label: '上报自身消息',
type: 'switch',
description: '是否上报自身消息',
colSpan: 1,
colSpan: 1
},
{
name: 'messagePostFormat',
@@ -69,17 +69,17 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
isRequired: true,
options: [
{ key: 'array', value: 'Array' },
{ key: 'string', value: 'String' },
{ key: 'string', value: 'String' }
],
colSpan: 1,
colSpan: 1
},
{
name: 'token',
label: 'Token',
type: 'input',
placeholder: '请输入Token',
},
];
placeholder: '请输入Token'
}
]
return (
<GenericForm
@@ -89,7 +89,7 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
onSubmit={onSubmit}
fields={fields}
/>
);
};
)
}
export default HTTPClientForm;
export default HTTPClientForm

View File

@@ -1,5 +1,5 @@
import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form';
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPServerFormProps {
data?: OneBotConfig['network']['httpServers'][0]
@@ -7,12 +7,12 @@ export interface HTTPServerFormProps {
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>
}
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
type HTTPServerFormType = OneBotConfig['network']['httpServers']
const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
data,
onClose,
onSubmit,
onSubmit
}) => {
const defaultValues: HTTPServerFormType[0] = {
enable: false,
@@ -23,8 +23,8 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
enableWebsocket: true,
messagePostFormat: 'array',
token: random_token(16),
debug: false,
};
debug: false
}
const fields: Field<'httpServers'>[] = [
{
@@ -32,14 +32,14 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
label: '启用',
type: 'switch',
description: '保存后启用此配置',
colSpan: 1,
colSpan: 1
},
{
name: 'debug',
label: '开启Debug',
type: 'switch',
description: '是否开启调试模式',
colSpan: 1,
colSpan: 1
},
{
name: 'name',
@@ -47,35 +47,35 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
type: 'input',
placeholder: '请输入名称',
isRequired: true,
isDisabled: !!data,
isDisabled: !!data
},
{
name: 'host',
label: 'Host',
type: 'input',
placeholder: '请输入主机地址',
isRequired: true,
isRequired: true
},
{
name: 'port',
label: 'Port',
type: 'input',
placeholder: '请输入端口',
isRequired: true,
isRequired: true
},
{
name: 'enableCors',
label: '启用CORS',
type: 'switch',
description: '是否启用CORS跨域',
colSpan: 1,
colSpan: 1
},
{
name: 'enableWebsocket',
label: '启用Websocket',
type: 'switch',
description: '是否启用Websocket',
colSpan: 1,
colSpan: 1
},
{
name: 'messagePostFormat',
@@ -85,16 +85,16 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
isRequired: true,
options: [
{ key: 'array', value: 'Array' },
{ key: 'string', value: 'String' },
],
{ key: 'string', value: 'String' }
]
},
{
name: 'token',
label: 'Token',
type: 'input',
placeholder: '请输入Token',
},
];
placeholder: '请输入Token'
}
]
return (
<GenericForm
@@ -104,7 +104,7 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
onSubmit={onSubmit}
fields={fields}
/>
);
};
)
}
export default HTTPServerForm;
export default HTTPServerForm

View File

@@ -1,5 +1,5 @@
import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form';
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPServerSSEFormProps {
data?: OneBotConfig['network']['httpSseServers'][0]
@@ -9,12 +9,12 @@ export interface HTTPServerSSEFormProps {
) => Promise<void>
}
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers']
const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
data,
onClose,
onSubmit,
onSubmit
}) => {
const defaultValues: HTTPServerSSEFormType[0] = {
enable: false,
@@ -26,8 +26,8 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
messagePostFormat: 'array',
token: random_token(16),
debug: false,
reportSelfMessage: false,
};
reportSelfMessage: false
}
const fields: Field<'httpSseServers'>[] = [
{
@@ -35,14 +35,14 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
label: '启用',
type: 'switch',
description: '保存后启用此配置',
colSpan: 1,
colSpan: 1
},
{
name: 'debug',
label: '开启Debug',
type: 'switch',
description: '是否开启调试模式',
colSpan: 1,
colSpan: 1
},
{
name: 'name',
@@ -50,35 +50,35 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
type: 'input',
placeholder: '请输入名称',
isRequired: true,
isDisabled: !!data,
isDisabled: !!data
},
{
name: 'host',
label: 'Host',
type: 'input',
placeholder: '请输入主机地址',
isRequired: true,
isRequired: true
},
{
name: 'port',
label: 'Port',
type: 'input',
placeholder: '请输入端口',
isRequired: true,
isRequired: true
},
{
name: 'enableCors',
label: '启用CORS',
type: 'switch',
description: '是否启用CORS跨域',
colSpan: 1,
colSpan: 1
},
{
name: 'enableWebsocket',
label: '启用Websocket',
type: 'switch',
description: '是否启用Websocket',
colSpan: 1,
colSpan: 1
},
{
name: 'messagePostFormat',
@@ -88,23 +88,23 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
isRequired: true,
options: [
{ key: 'array', value: 'Array' },
{ key: 'string', value: 'String' },
],
{ key: 'string', value: 'String' }
]
},
{
name: 'token',
label: 'Token',
type: 'input',
placeholder: '请输入Token',
placeholder: '请输入Token'
},
{
name: 'reportSelfMessage',
label: '上报自身消息',
type: 'switch',
description: '是否上报自身消息',
colSpan: 1,
},
];
colSpan: 1
}
]
return (
<GenericForm
@@ -114,7 +114,7 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
onSubmit={onSubmit}
fields={fields}
/>
);
};
)
}
export default HTTPServerSSEForm;
export default HTTPServerSSEForm

View File

@@ -1,54 +1,54 @@
import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
import toast from 'react-hot-toast';
import { Modal, ModalContent, ModalHeader } from '@heroui/modal'
import toast from 'react-hot-toast'
import useConfig from '@/hooks/use-config';
import useConfig from '@/hooks/use-config'
import HTTPClientForm from './http_client';
import HTTPServerForm from './http_server';
import HTTPServerSSEForm from './http_sse';
import WebsocketClientForm from './ws_client';
import WebsocketServerForm from './ws_server';
import HTTPClientForm from './http_client'
import HTTPServerForm from './http_server'
import HTTPServerSSEForm from './http_sse'
import WebsocketClientForm from './ws_client'
import WebsocketServerForm from './ws_server'
const modalTitle = {
httpServers: 'HTTP Server',
httpClients: 'HTTP Client',
httpSseServers: 'HTTP SSE Server',
websocketServers: 'Websocket Server',
websocketClients: 'Websocket Client',
};
websocketClients: 'Websocket Client'
}
export interface NetworkFormModalProps<
T extends keyof OneBotConfig['network']
> {
isOpen: boolean;
field: T;
data?: OneBotConfig['network'][T][0];
onOpenChange: (isOpen: boolean) => void;
isOpen: boolean
field: T
data?: OneBotConfig['network'][T][0]
onOpenChange: (isOpen: boolean) => void
}
const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
const NetworkFormModal = <T extends keyof OneBotConfig['network']>(
props: NetworkFormModalProps<T>
) => {
const { isOpen, onOpenChange, field, data } = props;
const { createNetworkConfig, updateNetworkConfig } = useConfig();
const isCreate = !data;
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);
await createNetworkConfig(field, data)
} else {
await updateNetworkConfig(field, data);
await updateNetworkConfig(field, data)
}
toast.success('保存配置成功');
toast.success('保存配置成功')
} catch (error) {
const msg = (error as Error).message;
const msg = (error as Error).message
toast.error(`保存配置失败: ${msg}`);
toast.error(`保存配置失败: ${msg}`)
throw error;
throw error
}
};
}
const renderFormComponent = (onClose: () => void) => {
switch (field) {
@@ -59,7 +59,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
onClose={onClose}
onSubmit={onSubmit}
/>
);
)
case 'httpClients':
return (
<HTTPClientForm
@@ -67,7 +67,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
onClose={onClose}
onSubmit={onSubmit}
/>
);
)
case 'websocketServers':
return (
<WebsocketServerForm
@@ -75,7 +75,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
onClose={onClose}
onSubmit={onSubmit}
/>
);
)
case 'websocketClients':
return (
<WebsocketClientForm
@@ -83,7 +83,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
onClose={onClose}
onSubmit={onSubmit}
/>
);
)
case 'httpSseServers':
return (
<HTTPServerSSEForm
@@ -91,25 +91,25 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
onClose={onClose}
onSubmit={onSubmit}
/>
);
)
default:
return null;
return null
}
};
}
return (
<Modal
backdrop='blur'
backdrop="blur"
isDismissable={false}
isOpen={isOpen}
size='lg'
scrollBehavior='outside'
size="lg"
scrollBehavior="outside"
onOpenChange={onOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className='flex flex-col gap-1'>
<ModalHeader className="flex flex-col gap-1">
{modalTitle[field]}
</ModalHeader>
{renderFormComponent(onClose)}
@@ -117,7 +117,7 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
)}
</ModalContent>
</Modal>
);
};
)
}
export default NetworkFormModal;
export default NetworkFormModal

View File

@@ -1,5 +1,5 @@
import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form';
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface WebsocketClientFormProps {
data?: OneBotConfig['network']['websocketClients'][0]
@@ -9,12 +9,12 @@ export interface WebsocketClientFormProps {
) => Promise<void>
}
type WebsocketClientFormType = OneBotConfig['network']['websocketClients'];
type WebsocketClientFormType = OneBotConfig['network']['websocketClients']
const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
data,
onClose,
onSubmit,
onSubmit
}) => {
const defaultValues: WebsocketClientFormType[0] = {
enable: false,
@@ -25,8 +25,8 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
token: random_token(16),
debug: false,
heartInterval: 30000,
reconnectInterval: 30000,
};
reconnectInterval: 30000
}
const fields: Field<'websocketClients'>[] = [
{
@@ -34,14 +34,14 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
label: '启用',
type: 'switch',
description: '保存后启用此配置',
colSpan: 1,
colSpan: 1
},
{
name: 'debug',
label: '开启Debug',
type: 'switch',
description: '是否开启调试模式',
colSpan: 1,
colSpan: 1
},
{
name: 'name',
@@ -49,21 +49,21 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
type: 'input',
placeholder: '请输入名称',
isRequired: true,
isDisabled: !!data,
isDisabled: !!data
},
{
name: 'url',
label: 'URL',
type: 'input',
placeholder: '请输入URL',
isRequired: true,
isRequired: true
},
{
name: 'reportSelfMessage',
label: '上报自身消息',
type: 'switch',
description: '是否上报自身消息',
colSpan: 1,
colSpan: 1
},
{
name: 'messagePostFormat',
@@ -73,15 +73,15 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
isRequired: true,
options: [
{ key: 'array', value: 'Array' },
{ key: 'string', value: 'String' },
{ key: 'string', value: 'String' }
],
colSpan: 1,
colSpan: 1
},
{
name: 'token',
label: 'Token',
type: 'input',
placeholder: '请输入Token',
placeholder: '请输入Token'
},
{
name: 'heartInterval',
@@ -89,7 +89,7 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
type: 'input',
placeholder: '请输入心跳间隔',
isRequired: true,
colSpan: 1,
colSpan: 1
},
{
name: 'reconnectInterval',
@@ -97,9 +97,9 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
type: 'input',
placeholder: '请输入重连间隔',
isRequired: true,
colSpan: 1,
},
];
colSpan: 1
}
]
return (
<GenericForm
@@ -109,7 +109,7 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
onSubmit={onSubmit}
fields={fields}
/>
);
};
)
}
export default WebsocketClientForm;
export default WebsocketClientForm

View File

@@ -1,5 +1,5 @@
import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form';
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface WebsocketServerFormProps {
data?: OneBotConfig['network']['websocketServers'][0]
@@ -9,12 +9,12 @@ export interface WebsocketServerFormProps {
) => Promise<void>
}
type WebsocketServerFormType = OneBotConfig['network']['websocketServers'];
type WebsocketServerFormType = OneBotConfig['network']['websocketServers']
const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
data,
onClose,
onSubmit,
onSubmit
}) => {
const defaultValues: WebsocketServerFormType[0] = {
enable: false,
@@ -26,8 +26,8 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
messagePostFormat: 'array',
token: random_token(16),
debug: false,
heartInterval: 30000,
};
heartInterval: 30000
}
const fields: Field<'websocketServers'>[] = [
{
@@ -35,14 +35,14 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
label: '启用',
type: 'switch',
description: '保存后启用此配置',
colSpan: 1,
colSpan: 1
},
{
name: 'debug',
label: '开启Debug',
type: 'switch',
description: '是否开启调试模式',
colSpan: 1,
colSpan: 1
},
{
name: 'name',
@@ -50,14 +50,14 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
type: 'input',
placeholder: '请输入名称',
isRequired: true,
isDisabled: !!data,
isDisabled: !!data
},
{
name: 'host',
label: 'Host',
type: 'input',
placeholder: '请输入主机地址',
isRequired: true,
isRequired: true
},
{
name: 'port',
@@ -65,7 +65,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
type: 'input',
placeholder: '请输入端口',
isRequired: true,
colSpan: 1,
colSpan: 1
},
{
name: 'messagePostFormat',
@@ -75,38 +75,38 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
isRequired: true,
options: [
{ key: 'array', value: 'Array' },
{ key: 'string', value: 'String' },
{ key: 'string', value: 'String' }
],
colSpan: 1,
colSpan: 1
},
{
name: 'reportSelfMessage',
label: '上报自身消息',
type: 'switch',
description: '是否上报自身消息',
colSpan: 1,
colSpan: 1
},
{
name: 'enableForcePushEvent',
label: '强制推送事件',
type: 'switch',
description: '是否强制推送事件',
colSpan: 1,
colSpan: 1
},
{
name: 'token',
label: 'Token',
type: 'input',
placeholder: '请输入Token',
placeholder: '请输入Token'
},
{
name: 'heartInterval',
label: '心跳间隔',
type: 'input',
placeholder: '请输入心跳间隔',
isRequired: true,
},
];
isRequired: true
}
]
return (
<GenericForm
@@ -116,7 +116,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
onSubmit={onSubmit}
fields={fields}
/>
);
};
)
}
export default WebsocketServerForm;
export default WebsocketServerForm

View File

@@ -1,131 +1,133 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Input } from '@heroui/input';
import { Snippet } from '@heroui/snippet';
import { useLocalStorage } from '@uidotdev/usehooks';
import { motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { IoLink, IoSend } from 'react-icons/io5';
import { PiCatDuotone } from 'react-icons/pi';
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Input } from '@heroui/input'
import { Snippet } from '@heroui/snippet'
import { useLocalStorage } from '@uidotdev/usehooks'
import { motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { IoLink, IoSend } from 'react-icons/io5'
import { PiCatDuotone } from 'react-icons/pi'
import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
import key from '@/const/key'
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 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 { request } from '@/utils/request'
import { parseAxiosResponse } from '@/utils/url'
import { generateDefaultJson, parse } from '@/utils/zod'
import DisplayStruct from './display_struct';
import DisplayStruct from './display_struct'
export interface OneBotApiDebugProps {
path: OneBotHttpApiPath;
data: OneBotHttpApiContent;
path: OneBotHttpApiPath
data: OneBotHttpApiContent
}
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data } = props;
const currentURL = new URL(window.location.origin);
currentURL.port = '3000';
const defaultHttpUrl = currentURL.href;
const { path, data } = props
const currentURL = new URL(window.location.origin)
currentURL.port = '3000'
const defaultHttpUrl = currentURL.href
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
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 responseRef = useRef<HTMLDivElement>(null);
const parsedRequest = parse(data.request);
const parsedResponse = parse(data.response);
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 responseRef = useRef<HTMLDivElement>(null)
const parsedRequest = parse(data.request)
const parsedResponse = parse(data.response)
const sendRequest = async () => {
if (isFetching) return;
setIsFetching(true);
const r = toast.loading('正在发送请求...');
if (isFetching) return
setIsFetching(true)
const r = toast.loading('正在发送请求...')
try {
const parsedRequestBody = JSON.parse(requestBody);
const requestURL = new URL(httpConfig.url);
requestURL.pathname = path;
const parsedRequestBody = JSON.parse(requestBody)
const requestURL = new URL(httpConfig.url)
requestURL.pathname = path
request
.post(requestURL.href, parsedRequestBody, {
headers: {
Authorization: `Bearer ${httpConfig.token}`,
Authorization: `Bearer ${httpConfig.token}`
},
responseType: 'text',
responseType: 'text'
})
.then((res) => {
setResponseContent(parseAxiosResponse(res));
toast.success('请求发送完成,请查看响应');
setResponseContent(parseAxiosResponse(res))
toast.success('请求发送完成,请查看响应')
})
.catch((err) => {
toast.error('请求发送失败:' + err.message);
setResponseContent(parseAxiosResponse(err.response));
toast.error('请求发送失败:' + err.message)
setResponseContent(parseAxiosResponse(err.response))
})
.finally(() => {
setIsFetching(false);
setIsResponseOpen(true);
setIsFetching(false)
setIsResponseOpen(true)
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast.dismiss(r);
});
} catch (_error) {
toast.error('请求体 JSON 格式错误');
setIsFetching(false);
toast.dismiss(r);
block: 'start'
})
toast.dismiss(r)
})
} catch (error) {
toast.error('请求体 JSON 格式错误')
setIsFetching(false)
toast.dismiss(r)
}
};
}
useEffect(() => {
setRequestBody(generateDefaultJson(data.request));
setResponseContent('');
}, [path]);
setRequestBody(generateDefaultJson(data.request))
setResponseContent('')
}, [path])
return (
<section className='p-4 pt-14 rounded-lg shadow-md'>
<h1 className='text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400'>
<section className="p-4 pt-14 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400">
<PiCatDuotone />
{data.description}
</h1>
<h1 className='text-lg font-bold mb-4'>
<h1 className="text-lg font-bold mb-4">
<Snippet
className='bg-default-50 bg-opacity-50 backdrop-blur-md'
symbol={<IoLink size={18} className='inline-block mr-1' />}
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
symbol={<IoLink size={18} className="inline-block mr-1" />}
tooltipProps={{
content: '点击复制地址',
content: '点击复制地址'
}}
>
{path}
</Snippet>
</h1>
<div className='flex gap-2 items-center'>
<div className="flex gap-2 items-center">
<Input
label='HTTP URL'
placeholder='输入 HTTP URL'
label="HTTP URL"
placeholder="输入 HTTP URL"
value={httpConfig.url}
onChange={(e) =>
setHttpConfig({ ...httpConfig, url: e.target.value })}
setHttpConfig({ ...httpConfig, url: e.target.value })
}
/>
<Input
label='Token'
placeholder='输入 Token'
label="Token"
placeholder="输入 Token"
value={httpConfig.token}
onChange={(e) =>
setHttpConfig({ ...httpConfig, token: e.target.value })}
setHttpConfig({ ...httpConfig, token: e.target.value })
}
/>
<Button
onPress={sendRequest}
color='primary'
size='lg'
radius='full'
color="primary"
size="lg"
radius="full"
isIconOnly
isDisabled={isFetching}
>
@@ -133,17 +135,17 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</Button>
</div>
<Card
shadow='sm'
className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
shadow="sm"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible"
>
<CardHeader className='font-bold text-lg gap-1 pb-0'>
<span className='mr-2'></span>
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color='warning'
variant='flat'
color="warning"
variant="flat"
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
size='sm'
radius='full'
size="sm"
radius="full"
>
{isCodeEditorOpen ? '收起' : '展开'}
</Button>
@@ -154,23 +156,24 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isCodeEditorOpen ? 1 : 0,
height: isCodeEditorOpen ? 'auto' : 0,
height: isCodeEditorOpen ? 'auto' : 0
}}
>
<CodeEditor
value={requestBody}
onChange={(value) => setRequestBody(value ?? '')}
language='json'
height='400px'
language="json"
height="400px"
/>
<div className='flex justify-end gap-1'>
<div className="flex justify-end gap-1">
<ChatInputModal />
<Button
color='primary'
variant='flat'
color="primary"
variant="flat"
onPress={() =>
setRequestBody(generateDefaultJson(data.request))}
setRequestBody(generateDefaultJson(data.request))
}
>
</Button>
@@ -179,61 +182,61 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</CardBody>
</Card>
<Card
shadow='sm'
className='my-4 relative bg-opacity-50 backdrop-blur-md'
shadow="sm"
className="my-4 relative bg-opacity-50 backdrop-blur-md"
>
<PageLoading loading={isFetching} />
<CardHeader className='font-bold text-lg gap-1 pb-0'>
<span className='mr-2'></span>
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color='warning'
variant='flat'
color="warning"
variant="flat"
onPress={() => setIsResponseOpen(!isResponseOpen)}
size='sm'
radius='full'
size="sm"
radius="full"
>
{isResponseOpen ? '收起' : '展开'}
</Button>
<Button
color='success'
variant='flat'
color="success"
variant="flat"
onPress={() => {
navigator.clipboard.writeText(responseContent);
toast.success('响应内容已复制到剪贴板');
navigator.clipboard.writeText(responseContent)
toast.success('响应内容已复制到剪贴板')
}}
size='sm'
radius='full'
size="sm"
radius="full"
>
</Button>
</CardHeader>
<CardBody>
<motion.div
className='overflow-y-auto text-sm'
className="overflow-y-auto text-sm"
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isResponseOpen ? 1 : 0,
height: isResponseOpen ? 300 : 0,
height: isResponseOpen ? 300 : 0
}}
>
<pre>
<code>
{responseContent || (
<div className='text-gray-400'></div>
<div className="text-gray-400"></div>
)}
</code>
</pre>
</motion.div>
</CardBody>
</Card>
<div className='p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm'>
<h2 className='text-xl font-semibold mb-2'></h2>
<div className="p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm">
<h2 className="text-xl font-semibold mb-2"></h2>
<DisplayStruct schema={parsedRequest} />
<h2 className='text-xl font-semibold mt-4 mb-2'></h2>
<h2 className="text-xl font-semibold mt-4 mb-2"></h2>
<DisplayStruct schema={parsedResponse} />
</div>
</section>
);
};
)
}
export default OneBotApiDebug;
export default OneBotApiDebug

View File

@@ -1,11 +1,11 @@
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 { 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';
import type { LiteralValue, ParsedSchema } from '@/utils/zod'
interface DisplayStructProps {
schema: ParsedSchema | ParsedSchema[]
@@ -13,86 +13,84 @@ interface DisplayStructProps {
const SchemaType = ({
type,
value,
value
}: {
type: string
value?: LiteralValue
}) => {
let name = type;
let name = type
switch (type) {
case 'union':
name = '联合类型';
break;
name = '联合类型'
break
case 'value':
name = '固定值';
break;
name = '固定值'
break
}
let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' =
'primary';
'primary'
switch (type) {
case 'enum':
chipColor = 'warning';
break;
chipColor = 'warning'
break
case 'union':
chipColor = 'secondary';
break;
chipColor = 'secondary'
break
case 'array':
chipColor = 'primary';
break;
chipColor = 'primary'
break
case 'object':
chipColor = 'success';
break;
chipColor = 'success'
break
}
return (
<Chip size='sm' color={chipColor} variant='flat'>
<Chip size="sm" color={chipColor} variant="flat">
{name}
{type === 'value' && (
<span className='px-1 rounded-full bg-primary-400 text-white ml-1'>
<span className="px-1 rounded-full bg-primary-400 text-white ml-1">
{value}
</span>
)}
</Chip>
);
};
)
}
const SchemaLabel: React.FC<{
schema: ParsedSchema
}> = ({ schema }) => (
<>
{Array.isArray(schema.type)
? (
schema.type.map((type) => (
<SchemaType key={type} type={type} value={schema?.value} />
))
)
: (
<SchemaType type={schema.type} value={schema?.value} />
)}
{Array.isArray(schema.type) ? (
schema.type.map((type) => (
<SchemaType key={type} type={type} value={schema?.value} />
))
) : (
<SchemaType type={schema.type} value={schema?.value} />
)}
{schema.optional && (
<Chip size='sm' color='default' variant='flat'>
<Chip size="sm" color="default" variant="flat">
</Chip>
)}
{schema.description && (
<span className='text-xs text-default-400'>{schema.description}</span>
<span className="text-xs text-default-400">{schema.description}</span>
)}
</>
);
)
const SchemaContainer: React.FC<{
schema: ParsedSchema
children: React.ReactNode
}> = ({ schema, children }) => {
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(false)
const toggleExpand = () => setExpanded(!expanded);
const toggleExpand = () => setExpanded(!expanded)
return (
<div className='mb-2'>
<div className="mb-2">
<div
onClick={toggleExpand}
className='md:cursor-pointer flex items-center gap-1'
className="md:cursor-pointer flex items-center gap-1"
>
<motion.div
initial={{ rotate: 0 }}
@@ -100,13 +98,13 @@ const SchemaContainer: React.FC<{
>
<TbSquareRoundedChevronRightFilled />
</motion.div>
<Tooltip content='点击复制' placement='top' showArrow>
<Tooltip content="点击复制" placement="top" showArrow>
<span
className='border-b border-transparent border-dashed hover:border-primary-400'
className="border-b border-transparent border-dashed hover:border-primary-400"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(schema.name || '');
toast.success('已复制');
e.stopPropagation()
navigator.clipboard.writeText(schema.name || '')
toast.success('已复制')
}}
>
{schema.name}
@@ -115,32 +113,30 @@ const SchemaContainer: React.FC<{
<SchemaLabel schema={schema} />
</div>
<motion.div
className='ml-5 overflow-hidden'
className="ml-5 overflow-hidden"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: expanded ? 1 : 0, height: expanded ? 'auto' : 0 }}
>
<div className='h-2' />
<div className="h-2"></div>
{children}
</motion.div>
</div>
);
};
)
}
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
if (schema.type === 'object') {
return (
<SchemaContainer schema={schema}>
{schema.children && schema.children.length > 0
? (
schema.children.map((child, i) => (
<RenderSchema key={child.name || i} schema={child} />
))
)
: (
<div>{'{}'}</div>
)}
{schema.children && schema.children.length > 0 ? (
schema.children.map((child, i) => (
<RenderSchema key={child.name || i} schema={child} />
))
) : (
<div>{`{}`}</div>
)}
</SchemaContainer>
);
)
}
if (schema.type === 'array' || schema.type === 'union') {
@@ -150,37 +146,37 @@ const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
<RenderSchema key={child.name || i} schema={child} />
))}
</SchemaContainer>
);
)
}
if (schema.type === 'enum' && Array.isArray(schema.enum)) {
return (
<SchemaContainer schema={schema}>
<div className='flex gap-1 items-center'>
<div className="flex gap-1 items-center">
{schema.enum?.map((value, i) => (
<Chip
key={value?.toString() || i}
size='sm'
variant='flat'
color='success'
size="sm"
variant="flat"
color="success"
>
{value?.toString()}
</Chip>
))}
</div>
</SchemaContainer>
);
)
}
return (
<div className='mb-2 flex items-center gap-1 pl-5'>
<Tooltip content='点击复制' placement='top' showArrow>
<div className="mb-2 flex items-center gap-1 pl-5">
<Tooltip content="点击复制" placement="top" showArrow>
<span
className='border-b border-transparent border-dashed hover:border-primary-400 md:cursor-pointer'
className="border-b border-transparent border-dashed hover:border-primary-400 md:cursor-pointer"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(schema.name || '');
toast.success('已复制');
e.stopPropagation()
navigator.clipboard.writeText(schema.name || '')
toast.success('已复制')
}}
>
{schema.name}
@@ -188,21 +184,19 @@ const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
</Tooltip>
<SchemaLabel schema={schema} />
</div>
);
};
)
}
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
return (
<div className='p-4 bg-content2 rounded-lg bg-opacity-50'>
{Array.isArray(schema)
? (
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
)
: (
<RenderSchema schema={schema} />
)}
<div className="p-4 bg-content2 rounded-lg bg-opacity-50">
{Array.isArray(schema) ? (
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
) : (
<RenderSchema schema={schema} />
)}
</div>
);
};
)
}
export default DisplayStruct;
export default DisplayStruct

View File

@@ -1,10 +1,10 @@
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 { 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';
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'
export interface OneBotApiNavListProps {
data: OneBotHttpApi
@@ -14,8 +14,8 @@ export interface OneBotApiNavListProps {
}
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar } = props;
const [searchValue, setSearchValue] = useState('');
const { data, selectedApi, onSelect, openSideBar } = props
const [searchValue, setSearchValue] = useState('')
return (
<motion.div
className={clsx(
@@ -26,21 +26,21 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
transition={{
type: openSideBar ? 'spring' : 'tween',
stiffness: 150,
damping: 15,
damping: 15
}}
animate={{ width: openSideBar ? '16rem' : '0rem' }}
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<div className='w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0'>
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
<Input
className='sticky top-0 z-10 text-primary-600'
className="sticky top-0 z-10 text-primary-600"
classNames={{
inputWrapper:
'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
input: 'bg-transparent !text-primary-400 !placeholder-primary-400',
input: 'bg-transparent !text-primary-400 !placeholder-primary-400'
}}
radius='full'
placeholder='搜索 API'
radius="full"
placeholder="搜索 API"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
isClearable
@@ -49,28 +49,28 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
{Object.entries(data).map(([apiName, api]) => (
<Card
key={apiName}
shadow='none'
shadow="none"
className={clsx(
'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
{
hidden: !(
apiName.includes(searchValue) ||
api.description?.includes(searchValue)
),
)
},
{
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
apiName === selectedApi,
apiName === selectedApi
}
)}
isPressable
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className='font-bold'>{api.description}</h2>
<h2 className="font-bold">{api.description}</h2>
<div
className={clsx('text-sm text-primary-200', {
'!text-primary-400': apiName === selectedApi,
'!text-primary-400': apiName === selectedApi
})}
>
{apiName}
@@ -80,7 +80,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
))}
</div>
</motion.div>
);
};
)
}
export default OneBotApiNavList;
export default OneBotApiNavList

View File

@@ -1,16 +1,16 @@
import { Avatar } from '@heroui/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import clsx from 'clsx';
import { Avatar } from '@heroui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import clsx from 'clsx'
import { isOB11GroupMessage } from '@/utils/onebot';
import { isOB11GroupMessage } from '@/utils/onebot'
import type {
OB11GroupMessage,
OB11Message,
OB11PrivateMessage,
} from '@/types/onebot';
OB11PrivateMessage
} from '@/types/onebot'
import { renderMessageContent } from '../render_message';
import { renderMessageContent } from '../render_message'
export interface OneBotMessageProps {
data: OB11Message
@@ -26,11 +26,11 @@ export interface OneBotMessagePrivateProps {
const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
return (
<div className='h-full flex flex-col overflow-hidden flex-1'>
<div className='flex gap-2 items-center flex-shrink-0'>
<div className='font-bold'>
<div className="h-full flex flex-col overflow-hidden flex-1">
<div className="flex gap-2 items-center flex-shrink-0">
<div className="font-bold">
{isOB11GroupMessage(data) && data.sender.card && (
<span className='mr-1'>{data.sender.card}</span>
<span className="mr-1">{data.sender.card}</span>
)}
<span
className={clsx(
@@ -43,12 +43,12 @@ const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
</span>
</div>
<div>({data.sender.user_id})</div>
<div className='text-sm'>ID: {data.message_id}</div>
<div className="text-sm">ID: {data.message_id}</div>
</div>
<Popover showArrow triggerScaleOnOpen={false}>
<PopoverTrigger>
<div className='flex-1 break-all overflow-hidden whitespace-pre-wrap border border-default-100 p-2 rounded-md hover:bg-content2 md:cursor-pointer transition-background relative group'>
<div className='absolute right-2 top-2 opacity-0 group-hover:opacity-100 text-default-300'>
<div className="flex-1 break-all overflow-hidden whitespace-pre-wrap border border-default-100 p-2 rounded-md hover:bg-content2 md:cursor-pointer transition-background relative group">
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 text-default-300">
</div>
{Array.isArray(data.message)
@@ -57,7 +57,7 @@ const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
</div>
</PopoverTrigger>
<PopoverContent>
<div className='p-2'>
<div className="p-2">
{Array.isArray(data.message)
? renderMessageContent(data.message)
: data.raw_message}
@@ -65,58 +65,58 @@ const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
</PopoverContent>
</Popover>
</div>
);
};
)
}
const OneBotMessageGroup: React.FC<OneBotMessageGroupProps> = ({ data }) => {
return (
<div className='h-full overflow-hidden flex flex-col w-full'>
<div className='flex items-center p-1 flex-shrink-0'>
<div className="h-full overflow-hidden flex flex-col w-full">
<div className="flex items-center p-1 flex-shrink-0">
<Avatar
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/640/`}
alt='群头像'
size='sm'
className='flex-shrink-0 mr-2'
alt="群头像"
size="sm"
className="flex-shrink-0 mr-2"
/>
<div> {data.group_id}</div>
</div>
<div className='flex items-start p-1 rounded-md h-full flex-1 border border-default-100'>
<div className="flex items-start p-1 rounded-md h-full flex-1 border border-default-100">
<Avatar
src={`https://q1.qlogo.cn/g?b=qq&nk=${data.sender.user_id}&s=100`}
alt='用户头像'
size='md'
className='flex-shrink-0 mr-2'
alt="用户头像"
size="md"
className="flex-shrink-0 mr-2"
/>
<MessageContent data={data} />
</div>
</div>
);
};
)
}
const OneBotMessagePrivate: React.FC<OneBotMessagePrivateProps> = ({
data,
data
}) => {
return (
<div className='flex items-start p-2 rounded-md h-full flex-1'>
<div className="flex items-start p-2 rounded-md h-full flex-1">
<Avatar
src={`https://q1.qlogo.cn/g?b=qq&nk=${data.sender.user_id}&s=100`}
alt='用户头像'
size='md'
className='flex-shrink-0 mr-2'
alt="用户头像"
size="md"
className="flex-shrink-0 mr-2"
/>
<MessageContent data={data} />
</div>
);
};
)
}
const OneBotMessage: React.FC<OneBotMessageProps> = ({ data }) => {
if (data.message_type === 'group') {
return <OneBotMessageGroup data={data} />;
return <OneBotMessageGroup data={data} />
} else if (data.message_type === 'private') {
return <OneBotMessagePrivate data={data} />;
return <OneBotMessagePrivate data={data} />
} else {
return <div></div>;
return <div></div>
}
};
}
export default OneBotMessage;
export default OneBotMessage

View File

@@ -1,12 +1,12 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import { getLifecycleColor, getLifecycleName } from '@/utils/onebot';
import { getLifecycleColor, getLifecycleName } from '@/utils/onebot'
import type {
OB11Meta,
OneBot11Heartbeat,
OneBot11Lifecycle,
} from '@/types/onebot';
OneBot11Lifecycle
} from '@/types/onebot'
export interface OneBotDisplayMetaProps {
data: OB11Meta
@@ -21,32 +21,32 @@ export interface OneBotDisplayMetaLifecycleProps {
}
const OneBotDisplayMetaHeartbeat: React.FC<OneBotDisplayMetaHeartbeatProps> = ({
data,
data
}) => {
return (
<div className='flex gap-2'>
<div className="flex gap-2">
<Chip></Chip>
<Chip> {data.status.interval}ms</Chip>
</div>
);
};
)
}
const OneBotDisplayMetaLifecycle: React.FC<OneBotDisplayMetaLifecycleProps> = ({
data,
data
}) => {
return (
<div className='flex gap-2'>
<div className="flex gap-2">
<Chip></Chip>
<Chip color={getLifecycleColor(data.sub_type)}>
{getLifecycleName(data.sub_type)}
</Chip>
</div>
);
};
)
}
const OneBotDisplayMeta: React.FC<OneBotDisplayMetaProps> = ({ data }) => {
return (
<div className='h-full flex items-center'>
<div className="h-full flex items-center">
{data.meta_event_type === 'lifecycle' && (
<OneBotDisplayMetaLifecycle data={data} />
)}
@@ -54,7 +54,7 @@ const OneBotDisplayMeta: React.FC<OneBotDisplayMetaProps> = ({ data }) => {
<OneBotDisplayMetaHeartbeat data={data} />
)}
</div>
);
};
)
}
export default OneBotDisplayMeta;
export default OneBotDisplayMeta

View File

@@ -1,6 +1,6 @@
import { Chip } from '@heroui/chip';
import { Chip } from '@heroui/chip'
import { getNoticeTypeName } from '@/utils/onebot';
import { getNoticeTypeName } from '@/utils/onebot'
import {
OB11Notice,
@@ -18,8 +18,8 @@ import {
OneBot11GroupUpload,
OneBot11Honor,
OneBot11LuckyKing,
OneBot11Poke,
} from '@/types/onebot';
OneBot11Poke
} from '@/types/onebot'
export interface OneBotNoticeProps {
data: OB11Notice
@@ -30,9 +30,9 @@ export interface NoticeProps<T> {
}
const GroupUploadNotice: React.FC<NoticeProps<OneBot11GroupUpload>> = ({
data,
data
}) => {
const { group_id, user_id, file } = data;
const { group_id, user_id, file } = data
return (
<>
<div>: {group_id}</div>
@@ -40,26 +40,26 @@ const GroupUploadNotice: React.FC<NoticeProps<OneBot11GroupUpload>> = ({
<div>: {file.name}</div>
<div>: {file.size} </div>
</>
);
};
)
}
const GroupAdminNotice: React.FC<NoticeProps<OneBot11GroupAdmin>> = ({
data,
data
}) => {
const { group_id, user_id, sub_type } = data;
const { group_id, user_id, sub_type } = data
return (
<>
<div>: {group_id}</div>
<div>ID: {user_id}</div>
<div>: {sub_type === 'set' ? '设置管理员' : '取消管理员'}</div>
</>
);
};
)
}
const GroupDecreaseNotice: React.FC<NoticeProps<OneBot11GroupDecrease>> = ({
data,
data
}) => {
const { group_id, operator_id, user_id, sub_type } = data;
const { group_id, operator_id, user_id, sub_type } = data
return (
<>
<div>: {group_id}</div>
@@ -67,13 +67,13 @@ const GroupDecreaseNotice: React.FC<NoticeProps<OneBot11GroupDecrease>> = ({
<div>ID: {user_id}</div>
<div>: {sub_type}</div>
</>
);
};
)
}
const GroupIncreaseNotice: React.FC<NoticeProps<OneBot11GroupIncrease>> = ({
data,
data
}) => {
const { group_id, operator_id, user_id, sub_type } = data;
const { group_id, operator_id, user_id, sub_type } = data
return (
<>
<div>: {group_id}</div>
@@ -81,11 +81,11 @@ const GroupIncreaseNotice: React.FC<NoticeProps<OneBot11GroupIncrease>> = ({
<div>ID: {user_id}</div>
<div>: {sub_type}</div>
</>
);
};
)
}
const GroupBanNotice: React.FC<NoticeProps<OneBot11GroupBan>> = ({ data }) => {
const { group_id, operator_id, user_id, sub_type, duration } = data;
const { group_id, operator_id, user_id, sub_type, duration } = data
return (
<>
<div>: {group_id}</div>
@@ -94,24 +94,24 @@ const GroupBanNotice: React.FC<NoticeProps<OneBot11GroupBan>> = ({ data }) => {
<div>: {sub_type}</div>
<div>: {duration} </div>
</>
);
};
)
}
const FriendAddNotice: React.FC<NoticeProps<OneBot11FriendAdd>> = ({
data,
data
}) => {
const { user_id } = data;
const { user_id } = data
return (
<>
<div>ID: {user_id}</div>
</>
);
};
)
}
const GroupRecallNotice: React.FC<NoticeProps<OneBot11GroupRecall>> = ({
data,
data
}) => {
const { group_id, user_id, operator_id, message_id } = data;
const { group_id, user_id, operator_id, message_id } = data
return (
<>
<div>: {group_id}</div>
@@ -119,60 +119,60 @@ const GroupRecallNotice: React.FC<NoticeProps<OneBot11GroupRecall>> = ({
<div>ID: {operator_id}</div>
<div>ID: {message_id}</div>
</>
);
};
)
}
const FriendRecallNotice: React.FC<NoticeProps<OneBot11FriendRecall>> = ({
data,
data
}) => {
const { user_id, message_id } = data;
const { user_id, message_id } = data
return (
<>
<div>ID: {user_id}</div>
<div>ID: {message_id}</div>
</>
);
};
)
}
const PokeNotice: React.FC<NoticeProps<OneBot11Poke>> = ({ data }) => {
const { group_id, user_id, target_id } = data;
const { group_id, user_id, target_id } = data
return (
<>
<div>: {group_id}</div>
<div>ID: {user_id}</div>
<div>ID: {target_id}</div>
</>
);
};
)
}
const LuckyKingNotice: React.FC<NoticeProps<OneBot11LuckyKing>> = ({
data,
data
}) => {
const { group_id, user_id, target_id } = data;
const { group_id, user_id, target_id } = data
return (
<>
<div>: {group_id}</div>
<div>ID: {user_id}</div>
<div>ID: {target_id}</div>
</>
);
};
)
}
const HonorNotice: React.FC<NoticeProps<OneBot11Honor>> = ({ data }) => {
const { group_id, user_id, honor_type } = data;
const { group_id, user_id, honor_type } = data
return (
<>
<div>: {group_id}</div>
<div>ID: {user_id}</div>
<div>: {honor_type}</div>
</>
);
};
)
}
const GroupMessageReactionNotice: React.FC<
NoticeProps<OneBot11GroupMessageReaction>
> = ({ data }) => {
const { group_id, user_id, message_id, likes } = data;
const { group_id, user_id, message_id, likes } = data
return (
<>
<div>: {group_id}</div>
@@ -185,13 +185,13 @@ const GroupMessageReactionNotice: React.FC<
.join(', ')}
</div>
</>
);
};
)
}
const GroupEssenceNotice: React.FC<NoticeProps<OneBot11GroupEssence>> = ({
data,
data
}) => {
const { group_id, message_id, sender_id, operator_id, sub_type } = data;
const { group_id, message_id, sender_id, operator_id, sub_type } = data
return (
<>
<div>: {group_id}</div>
@@ -200,13 +200,13 @@ const GroupEssenceNotice: React.FC<NoticeProps<OneBot11GroupEssence>> = ({
<div>ID: {operator_id}</div>
<div>: {sub_type}</div>
</>
);
};
)
}
const GroupCardNotice: React.FC<NoticeProps<OneBot11GroupCard>> = ({
data,
data
}) => {
const { group_id, user_id, card_new, card_old } = data;
const { group_id, user_id, card_new, card_old } = data
return (
<>
<div>: {group_id}</div>
@@ -214,79 +214,79 @@ const GroupCardNotice: React.FC<NoticeProps<OneBot11GroupCard>> = ({
<div>: {card_new}</div>
<div>: {card_old}</div>
</>
);
};
)
}
const OneBotNotice: React.FC<OneBotNoticeProps> = ({ data }) => {
let NoticeComponent: React.ReactNode;
let NoticeComponent: React.ReactNode
switch (data.notice_type) {
case OB11NoticeType.GroupUpload:
NoticeComponent = <GroupUploadNotice data={data} />;
break;
NoticeComponent = <GroupUploadNotice data={data} />
break
case OB11NoticeType.GroupAdmin:
NoticeComponent = <GroupAdminNotice data={data} />;
break;
NoticeComponent = <GroupAdminNotice data={data} />
break
case OB11NoticeType.GroupDecrease:
NoticeComponent = <GroupDecreaseNotice data={data} />;
break;
NoticeComponent = <GroupDecreaseNotice data={data} />
break
case OB11NoticeType.GroupIncrease:
NoticeComponent = (
<GroupIncreaseNotice data={data as OneBot11GroupIncrease} />
);
break;
)
break
case OB11NoticeType.GroupBan:
NoticeComponent = <GroupBanNotice data={data} />;
break;
NoticeComponent = <GroupBanNotice data={data} />
break
case OB11NoticeType.FriendAdd:
NoticeComponent = <FriendAddNotice data={data as OneBot11FriendAdd} />;
break;
NoticeComponent = <FriendAddNotice data={data as OneBot11FriendAdd} />
break
case OB11NoticeType.GroupRecall:
NoticeComponent = <GroupRecallNotice data={data as OneBot11GroupRecall} />;
break;
NoticeComponent = <GroupRecallNotice data={data as OneBot11GroupRecall} />
break
case OB11NoticeType.FriendRecall:
NoticeComponent = (
<FriendRecallNotice data={data as OneBot11FriendRecall} />
);
break;
)
break
case OB11NoticeType.Notify:
switch (data.sub_type) {
case 'poke':
NoticeComponent = <PokeNotice data={data as OneBot11Poke} />;
break;
NoticeComponent = <PokeNotice data={data as OneBot11Poke} />
break
case 'lucky_king':
NoticeComponent = <LuckyKingNotice data={data as OneBot11LuckyKing} />;
break;
NoticeComponent = <LuckyKingNotice data={data as OneBot11LuckyKing} />
break
case 'honor':
NoticeComponent = <HonorNotice data={data as OneBot11Honor} />;
break;
NoticeComponent = <HonorNotice data={data as OneBot11Honor} />
break
}
break;
break
case OB11NoticeType.GroupMsgEmojiLike:
NoticeComponent = (
<GroupMessageReactionNotice
data={data as OneBot11GroupMessageReaction}
/>
);
break;
)
break
case OB11NoticeType.GroupEssence:
NoticeComponent = (
<GroupEssenceNotice data={data as OneBot11GroupEssence} />
);
break;
)
break
case OB11NoticeType.GroupCard:
NoticeComponent = <GroupCardNotice data={data as OneBot11GroupCard} />;
break;
NoticeComponent = <GroupCardNotice data={data as OneBot11GroupCard} />
break
}
return (
<div className='flex gap-2 items-center'>
<Chip color='warning' variant='flat'>
<div className="flex gap-2 items-center">
<Chip color="warning" variant="flat">
</Chip>
<Chip>{getNoticeTypeName(data.notice_type)}</Chip>
{NoticeComponent}
</div>
);
};
)
}
export default OneBotNotice;
export default OneBotNotice

View File

@@ -1,24 +1,24 @@
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 { 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 OneBotDisplayMeta from '@/components/onebot/display_card/meta'
import { getEventName, isOB11Event } from '@/utils/onebot';
import { timestampToDateString } from '@/utils/time';
import { getEventName, isOB11Event } from '@/utils/onebot'
import { timestampToDateString } from '@/utils/time'
import type {
AllOB11WsResponse,
OB11AllEvent,
OB11Request,
} from '@/types/onebot';
OB11Request
} from '@/types/onebot'
import OneBotMessage from './message';
import OneBotNotice from './notice';
import OneBotDisplayResponse from './response';
import OneBotMessage from './message'
import OneBotNotice from './notice'
import OneBotDisplayResponse from './response'
const itemVariants = {
hidden: { opacity: 0, scale: 0.8, y: 50 },
@@ -26,12 +26,12 @@ const itemVariants = {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 300, damping: 20 },
},
};
transition: { type: 'spring' as const, stiffness: 300, damping: 20 }
}
}
function RequestComponent ({ data: _ }: { data: OB11Request }) {
return <div>Request消息</div>;
function RequestComponent({ data: _ }: { data: OB11Request }) {
return <div>Request消息</div>
}
export interface OneBotItemRenderProps {
@@ -42,78 +42,78 @@ export interface OneBotItemRenderProps {
export const getItemSize = (event: OB11AllEvent['post_type']) => {
if (event === 'meta_event') {
return 100;
return 100
}
if (event === 'message') {
return 180;
return 180
}
if (event === 'request') {
return 100;
return 100
}
if (event === 'notice') {
return 100;
return 100
}
if (event === 'message_sent') {
return 250;
return 250
}
return 100;
};
return 100
}
const renderDetail = (data: AllOB11WsResponse) => {
if (isOB11Event(data)) {
switch (data.post_type) {
case 'meta_event':
return <OneBotDisplayMeta data={data} />;
return <OneBotDisplayMeta data={data} />
case 'message':
return <OneBotMessage data={data} />;
return <OneBotMessage data={data} />
case 'request':
return <RequestComponent data={data} />;
return <RequestComponent data={data} />
case 'notice':
return <OneBotNotice data={data} />;
return <OneBotNotice data={data} />
case 'message_sent':
return <OneBotMessage data={data} />;
return <OneBotMessage data={data} />
default:
return <div></div>;
return <div></div>
}
}
return <OneBotDisplayResponse data={data} />;
};
return <OneBotDisplayResponse data={data} />
}
const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
const msg = data[index];
const isEvent = isOB11Event(msg);
const msg = data[index]
const isEvent = isOB11Event(msg)
return (
<div style={style} className='p-1 overflow-visible w-full h-full'>
<div style={style} className="p-1 overflow-visible w-full h-full">
<motion.div
variants={itemVariants}
initial='hidden'
animate='visible'
className='h-full px-2'
initial="hidden"
animate="visible"
className="h-full px-2"
>
<Card className='w-full h-full py-2 bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='py-0 text-default-500 flex-row gap-2'>
<div className='font-bold'>
<Card className="w-full h-full py-2 bg-opacity-50 backdrop-blur-sm">
<CardHeader className="py-0 text-default-500 flex-row gap-2">
<div className="font-bold">
{isEvent ? getEventName(msg.post_type) : '请求响应'}
</div>
<div className='text-sm'>
<div className="text-sm">
{isEvent && timestampToDateString(msg.time)}
</div>
<div className='ml-auto'>
<div className="ml-auto">
<Popover
placement='left'
placement="left"
showArrow
classNames={{
content: 'max-h-96 max-w-96 overflow-hidden p-0',
content: 'max-h-96 max-w-96 overflow-hidden p-0'
}}
>
<PopoverTrigger>
<Button
size='sm'
color='primary'
variant='flat'
radius='full'
size="sm"
color="primary"
variant="flat"
radius="full"
isIconOnly
className='text-medium'
className="text-medium"
>
<IoCode />
</Button>
@@ -122,17 +122,17 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
<Snippet
hideSymbol
tooltipProps={{
content: '点击复制',
content: '点击复制'
}}
classNames={{
copyButton: 'self-start sticky top-0 right-0',
copyButton: 'self-start sticky top-0 right-0'
}}
className='bg-content1 h-full overflow-y-scroll items-start'
className="bg-content1 h-full overflow-y-scroll items-start"
>
{JSON.stringify(msg, null, 2)
.split('\n')
.map((line, i) => (
<span key={i} className='whitespace-pre-wrap break-all'>
<span key={i} className="whitespace-pre-wrap break-all">
{line}
</span>
))}
@@ -141,11 +141,11 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
</Popover>
</div>
</CardHeader>
<CardBody className='py-0'>{renderDetail(msg)}</CardBody>
<CardBody className="py-0">{renderDetail(msg)}</CardBody>
</Card>
</motion.div>
</div>
);
};
)
}
export default OneBotItemRender;
export default OneBotItemRender

View File

@@ -1,39 +1,39 @@
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Snippet } from '@heroui/snippet';
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 { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot'
import { RequestResponse } from '@/types/onebot';
import { RequestResponse } from '@/types/onebot'
export interface OneBotDisplayResponseProps {
data: RequestResponse
}
const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
data,
data
}) => {
return (
<div className='flex gap-2 items-center'>
<Chip color={getResponseStatusColor(data.status)} variant='flat'>
<div className="flex gap-2 items-center">
<Chip color={getResponseStatusColor(data.status)} variant="flat">
{getResponseStatusText(data.status)}
</Chip>
{data.data && (
<Popover
placement='right'
placement="right"
showArrow
classNames={{
content: 'max-h-96 max-w-96 overflow-hidden p-0',
content: 'max-h-96 max-w-96 overflow-hidden p-0'
}}
>
<PopoverTrigger>
<Button
size='sm'
color='primary'
variant='flat'
radius='full'
className='text-medium'
size="sm"
color="primary"
variant="flat"
radius="full"
className="text-medium"
>
</Button>
@@ -42,17 +42,17 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
<Snippet
hideSymbol
tooltipProps={{
content: '点击复制',
content: '点击复制'
}}
classNames={{
copyButton: 'self-start sticky top-0 right-0',
copyButton: 'self-start sticky top-0 right-0'
}}
className='bg-content1 h-full overflow-y-scroll items-start'
className="bg-content1 h-full overflow-y-scroll items-start"
>
{JSON.stringify(data.data, null, 2)
.split('\n')
.map((line, i) => (
<span key={i} className='whitespace-pre-wrap break-all'>
<span key={i} className="whitespace-pre-wrap break-all">
{line}
</span>
))}
@@ -61,15 +61,15 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
</Popover>
)}
{data.message && (
<Chip className='pl-0.5' variant='flat'>
<Chip color='warning' size='sm' className='-ml-2 mr-1' variant='flat'>
<Chip className="pl-0.5" variant="flat">
<Chip color="warning" size="sm" className="-ml-2 mr-1" variant="flat">
</Chip>
{data.message}
</Chip>
)}
</div>
);
};
)
}
export default OneBotDisplayResponse;
export default OneBotDisplayResponse

View File

@@ -1,6 +1,6 @@
import { Select, SelectItem } from '@heroui/select';
import { SharedSelection } from '@heroui/system';
import type { Selection } from '@react-types/shared';
import { Select, SelectItem } from '@heroui/select'
import { SharedSelection } from '@heroui/system'
import type { Selection } from '@react-types/shared'
export interface FilterMessageTypeProps {
filterTypes: Selection
@@ -11,27 +11,27 @@ const items = [
{ label: '消息', value: 'message' },
{ label: '请求', value: 'request' },
{ label: '通知', value: 'notice' },
{ label: '消息发送', value: 'message_sent' },
];
{ label: '消息发送', value: 'message_sent' }
]
const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
const { filterTypes, onSelectionChange } = props;
const { filterTypes, onSelectionChange } = props
return (
<Select
selectedKeys={filterTypes}
onSelectionChange={(selectedKeys) => {
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
selectedKeys = 'all';
selectedKeys = 'all'
}
onSelectionChange(selectedKeys);
onSelectionChange(selectedKeys)
}}
label='筛选消息类型'
selectionMode='multiple'
label="筛选消息类型"
selectionMode="multiple"
items={items}
renderValue={(value) => {
if (value.length === items.length) {
return '全部';
return '全部'
}
return value.map((v) => v.data?.label).join(',');
return value.map((v) => v.data?.label).join(',')
}}
>
{(item) => (
@@ -40,8 +40,8 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
</SelectItem>
)}
</Select>
);
};
)
}
export const renderFilterMessageType = (
filterTypes: Selection,
@@ -52,7 +52,7 @@ export const renderFilterMessageType = (
filterTypes={filterTypes}
onSelectionChange={onSelectionChange}
/>
);
};
)
}
export default FilterMessageType;
export default FilterMessageType

View File

@@ -1,62 +1,62 @@
import { useEffect, useRef, useState } from 'react';
import { VariableSizeList } from 'react-window';
import { useEffect, useRef, useState } from 'react'
import { VariableSizeList } from 'react-window'
import OneBotItemRender, {
getItemSize,
} from '@/components/onebot/display_card/render';
getItemSize
} from '@/components/onebot/display_card/render'
import { isOB11Event } from '@/utils/onebot';
import { isOB11Event } from '@/utils/onebot'
import type { AllOB11WsResponse } from '@/types/onebot';
import type { AllOB11WsResponse } from '@/types/onebot'
export interface OneBotMessageListProps {
messages: AllOB11WsResponse[]
}
const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
const { messages } = props;
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<VariableSizeList>(null);
const [containerHeight, setContainerHeight] = useState(400);
const { messages } = props
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<VariableSizeList>(null)
const [containerHeight, setContainerHeight] = useState(400)
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (containerRef.current) {
setContainerHeight(containerRef.current.offsetHeight);
setContainerHeight(containerRef.current.offsetHeight)
}
});
})
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
resizeObserver.observe(containerRef.current)
}
return () => {
resizeObserver.disconnect();
};
}, []);
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (listRef.current) {
listRef.current.resetAfterIndex(0, true);
listRef.current.resetAfterIndex(0, true)
}
}, [messages]);
}, [messages])
return (
<div className='w-full h-full overflow-hidden' ref={containerRef}>
<div className="w-full h-full overflow-hidden" ref={containerRef}>
<VariableSizeList
ref={listRef}
itemCount={messages.length}
width='100%'
width="100%"
style={{
overflowX: 'hidden',
overflowX: 'hidden'
}}
itemSize={(idx) => {
const msg = messages[idx];
const msg = messages[idx]
if (isOB11Event(msg)) {
const size = getItemSize(msg.post_type);
return size;
const size = getItemSize(msg.post_type)
return size
} else {
return 100;
return 100
}
}}
height={containerHeight}
@@ -66,7 +66,7 @@ const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
{OneBotItemRender}
</VariableSizeList>
</div>
);
};
)
}
export default OneBotMessageList;
export default OneBotMessageList

View File

@@ -1,8 +1,8 @@
import { Image } from '@heroui/image';
import qface from 'qface';
import { FaReply } from 'react-icons/fa6';
import { Image } from '@heroui/image'
import qface from 'qface'
import { FaReply } from 'react-icons/fa6'
import { OB11Segment } from '@/types/onebot';
import { OB11Segment } from '@/types/onebot'
export const renderMessageContent = (
segments: OB11Segment[],
@@ -11,27 +11,27 @@ export const renderMessageContent = (
return segments.map((segment, index) => {
switch (segment.type) {
case 'text':
return <span key={index}>{segment.data.text}</span>;
return <span key={index}>{segment.data.text}</span>
case 'face':
return (
<Image
removeWrapper
classNames={{
img: 'w-6 h-6 inline !text-[0px] m-0 -mt-1.5 !p-0',
img: 'w-6 h-6 inline !text-[0px] m-0 -mt-1.5 !p-0'
}}
key={index}
src={qface.getUrl(segment.data.id)}
alt={`face-${segment.data.id}`}
/>
);
)
case 'image':
return (
<Image
classNames={{
wrapper: 'block !text-[0px] !m-0 !p-0',
img: 'block',
img: 'block'
}}
radius='sm'
radius="sm"
className={
small
? 'max-h-16 object-cover'
@@ -39,10 +39,10 @@ export const renderMessageContent = (
}
key={index}
src={segment.data.url || segment.data.file}
alt='image'
referrerPolicy='no-referrer'
alt="image"
referrerPolicy="no-referrer"
/>
);
)
case 'record':
return (
<audio
@@ -50,7 +50,7 @@ export const renderMessageContent = (
controls
src={segment.data.url || segment.data.file}
/>
);
)
case 'video':
return (
<video
@@ -58,109 +58,107 @@ export const renderMessageContent = (
controls
src={segment.data.url || segment.data.file}
/>
);
)
case 'at':
return (
<span key={index} className='text-blue-500'>
<span key={index} className="text-blue-500">
@
{segment.data.qq === 'all'
? (
'所有人'
)
: (
<span>
{segment.data.name}({segment.data.qq})
</span>
)}
{segment.data.qq === 'all' ? (
'所有人'
) : (
<span>
{segment.data.name}({segment.data.qq})
</span>
)}
</span>
);
)
case 'rps':
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
case 'dice':
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
case 'shake':
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
case 'poke':
return (
<span key={index}>
[: {segment.data.name || segment.data.id}]
</span>
);
)
case 'anonymous':
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
case 'share':
return (
<a
key={index}
href={segment.data.url}
target='_blank'
rel='noopener noreferrer'
target="_blank"
rel="noopener noreferrer"
>
{segment.data.title}
</a>
);
)
case 'contact':
return (
<span key={index}>
[{segment.data.type === 'qq' ? '好友' : '群'}: {segment.data.id}
]
</span>
);
)
case 'location':
return <span key={index}>[: {segment.data.title || '未知'}]</span>;
return <span key={index}>[: {segment.data.title || '未知'}]</span>
case 'music':
if (segment.data.type === 'custom') {
return (
<a
key={index}
href={segment.data.url}
target='_blank'
rel='noopener noreferrer'
target="_blank"
rel="noopener noreferrer"
>
{segment.data.title}
</a>
);
)
}
return (
<span key={index}>
[: {segment.data.type} - {segment.data.id}]
</span>
);
)
case 'reply':
return (
<div
key={index}
className='bg-content3 py-1 px-2 rounded-md flex items-center gap-1'
className="bg-content3 py-1 px-2 rounded-md flex items-center gap-1"
>
<FaReply className='text-default-500' />
<FaReply className="text-default-500" />
ID: {segment.data.id}
</div>
);
)
case 'forward':
return <span key={index}>[: {segment.data.id}]</span>;
return <span key={index}>[: {segment.data.id}]</span>
case 'node':
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
case 'xml':
return <pre key={index}>{segment.data.data}</pre>;
return <pre key={index}>{segment.data.data}</pre>
case 'json':
return (
<pre key={index} className='break-all whitespace-break-spaces'>
<pre key={index} className="break-all whitespace-break-spaces">
{segment.data.data}
</pre>
);
)
case 'file':
return (
<a
key={index}
href={segment.data.file}
target='_blank'
rel='noopener noreferrer'
target="_blank"
rel="noopener noreferrer"
>
[]
</a>
);
)
default:
return <span key={index}>[]</span>;
return <span key={index}>[]</span>
}
});
};
})
}

View File

@@ -1,70 +1,70 @@
import { Button } from '@heroui/button';
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from '@heroui/modal';
import { useCallback, useRef } from 'react';
import toast from 'react-hot-toast';
useDisclosure
} from '@heroui/modal'
import { useCallback, useRef } from 'react'
import toast from 'react-hot-toast'
import ChatInputModal from '@/components/chat_input/modal';
import CodeEditor from '@/components/code_editor';
import type { CodeEditorRef } from '@/components/code_editor';
import ChatInputModal from '@/components/chat_input/modal'
import CodeEditor from '@/components/code_editor'
import type { CodeEditorRef } from '@/components/code_editor'
export interface OneBotSendModalProps {
sendMessage: (msg: string) => void;
sendMessage: (msg: string) => void
}
const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
const { sendMessage } = props;
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const editorRef = useRef<CodeEditorRef | null>(null);
const { sendMessage } = props
const { isOpen, onOpen, onOpenChange } = useDisclosure()
const editorRef = useRef<CodeEditorRef | null>(null)
const handleSendMessage = useCallback(
(onClose: () => void) => {
const msg = editorRef.current?.getValue();
const msg = editorRef.current?.getValue()
if (!msg) {
toast.error('消息不能为空');
return;
toast.error('消息不能为空')
return
}
try {
sendMessage(msg);
toast.success('消息发送成功');
onClose();
} catch (_error) {
toast.error('消息发送失败');
sendMessage(msg)
toast.success('消息发送成功')
onClose()
} catch (error) {
toast.error('消息发送失败')
}
},
[sendMessage]
);
)
return (
<>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
</Button>
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
placement='top-center'
size='5xl'
scrollBehavior='outside'
placement="top-center"
size="5xl"
scrollBehavior="outside"
isDismissable={false}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className='flex flex-col gap-1'>
<ModalHeader className="flex flex-col gap-1">
</ModalHeader>
<ModalBody>
<div className='h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100'>
<div className="h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100">
<CodeEditor
height='100%'
defaultLanguage='json'
height="100%"
defaultLanguage="json"
defaultValue={`{
"action": "get_group_list"
}`}
@@ -75,11 +75,11 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
<ModalFooter>
<ChatInputModal />
<Button color='primary' variant='flat' onPress={onClose}>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button
color='primary'
color="primary"
onPress={() => handleSendMessage(onClose)}
>
@@ -90,6 +90,6 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
</ModalContent>
</Modal>
</>
);
};
export default OneBotSendModal;
)
}
export default OneBotSendModal

View File

@@ -1,39 +1,39 @@
import clsx from 'clsx';
import { ReadyState } from 'react-use-websocket';
import clsx from 'clsx'
import { ReadyState } from 'react-use-websocket'
export interface WSStatusProps {
state: ReadyState
}
function StatusTag ({
function StatusTag({
title,
color,
color
}: {
title: string
color: 'success' | 'primary' | 'warning'
}) {
const textClassName = `text-${color} text-sm`;
const bgClassName = `bg-${color}`;
const textClassName = `text-${color} text-sm`
const bgClassName = `bg-${color}`
return (
<div className='flex flex-col justify-center items-center gap-1 rounded-md px-2 col-span-2 md:col-span-1'>
<div className={clsx('w-4 h-4 rounded-full', bgClassName)} />
<div className="flex flex-col justify-center items-center gap-1 rounded-md px-2 col-span-2 md:col-span-1">
<div className={clsx('w-4 h-4 rounded-full', bgClassName)}></div>
<div className={textClassName}>{title}</div>
</div>
);
)
}
export default function WSStatus ({ state }: WSStatusProps) {
export default function WSStatus({ state }: WSStatusProps) {
if (state === ReadyState.OPEN) {
return <StatusTag title='已连接' color='success' />;
return <StatusTag title="已连接" color="success" />
}
if (state === ReadyState.CLOSED) {
return <StatusTag title='已关闭' color='primary' />;
return <StatusTag title="已关闭" color="primary" />
}
if (state === ReadyState.CONNECTING) {
return <StatusTag title='连接中' color='warning' />;
return <StatusTag title="连接中" color="warning" />
}
if (state === ReadyState.CLOSING) {
return <StatusTag title='关闭中' color='warning' />;
return <StatusTag title="关闭中" color="warning" />
}
return null;
return null
}

View File

@@ -1,24 +1,25 @@
import { Image } from '@heroui/image';
import { Image } from '@heroui/image'
import React from 'react'
import bkg_color from '@/assets/images/bkg-color.png';
import bkg_color from '@/assets/images/bkg-color.png'
const PageBackground = () => {
return (
<>
<div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
<React.Fragment>
<div className="fixed w-full h-full -z-[0] flex justify-end opacity-80">
<Image
className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
className="overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative"
src={bkg_color}
/>
</div>
<div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
<div className="fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80">
<Image
className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
className="relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44"
src={bkg_color}
/>
</div>
</>
);
};
</React.Fragment>
)
}
export default PageBackground;
export default PageBackground

View File

@@ -1,5 +1,5 @@
import { Spinner } from '@heroui/spinner';
import clsx from 'clsx';
import { Spinner } from '@heroui/spinner'
import clsx from 'clsx'
export interface PageLoadingProps {
loading?: boolean
@@ -10,13 +10,13 @@ const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
className={clsx(
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
{
hidden: !loading,
hidden: !loading
}
)}
>
<Spinner size='lg' />
<Spinner size="lg" />
</div>
);
};
)
}
export default PageLoading;
export default PageLoading

View File

@@ -1,4 +1,4 @@
import { tv } from 'tailwind-variants';
import { tv } from 'tailwind-variants'
export const title = tv({
base: 'tracking-tight inline font-semibold',
@@ -10,24 +10,24 @@ export const title = tv({
cyan: 'from-[#00b7fa] to-[#01cfea]',
green: 'from-[#6FEE8D] to-[#17c964]',
pink: 'from-[#FF72E1] to-[#F54C7A]',
foreground: 'from-[#FFFFFF] to-[#4B4B4B]',
foreground: 'from-[#FFFFFF] to-[#4B4B4B]'
},
size: {
xxs: 'text-medium lg:text-medium',
xs: 'text-xl lg:text-xl',
sm: 'text-3xl lg:text-4xl',
md: 'text-[2.3rem] lg:text-5xl leading-9',
lg: 'text-4xl lg:text-6xl',
lg: 'text-4xl lg:text-6xl'
},
fullWidth: {
true: 'w-full block',
true: 'w-full block'
},
shadow: {
true: 'drop-shadow-md',
},
true: 'drop-shadow-md'
}
},
defaultVariants: {
size: 'md',
size: 'md'
},
compoundVariants: [
{
@@ -38,21 +38,21 @@ export const title = tv({
'cyan',
'green',
'pink',
'foreground',
'foreground'
],
class: 'bg-clip-text text-transparent bg-gradient-to-b',
},
],
});
class: 'bg-clip-text text-transparent bg-gradient-to-b'
}
]
})
export const subtitle = tv({
base: 'w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full',
variants: {
fullWidth: {
true: '!w-full',
},
true: '!w-full'
}
},
defaultVariants: {
fullWidth: true,
},
});
fullWidth: true
}
})

View File

@@ -1,11 +1,11 @@
import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image';
import clsx from 'clsx';
import { BsTencentQq } from 'react-icons/bs';
import { Card, CardBody } from '@heroui/card'
import { Image } from '@heroui/image'
import clsx from 'clsx'
import { BsTencentQq } from 'react-icons/bs'
import { SelfInfo } from '@/types/user';
import { SelfInfo } from '@/types/user'
import PageLoading from './page_loading';
import PageLoading from './page_loading'
export interface QQInfoCardProps {
data?: SelfInfo
@@ -16,48 +16,46 @@ export interface QQInfoCardProps {
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
return (
<Card
className='relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50'
shadow='none'
radius='lg'
className="relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50"
shadow="none"
radius="lg"
>
<PageLoading loading={loading} />
{error
? (
<CardBody className='items-center gap-1 justify-center'>
<div className='flex-1 text-content1-foreground'>Error</div>
<div className='whitespace-nowrap text-nowrap flex-shrink-0'>
{error.message}
</div>
</CardBody>
)
: (
<CardBody className='flex-row items-center gap-2 overflow-hidden relative'>
<div className='absolute right-0 bottom-0 text-5xl text-primary-400'>
<BsTencentQq />
</div>
<div className='relative flex-shrink-0 z-10'>
<Image
src={
{error ? (
<CardBody className="items-center gap-1 justify-center">
<div className="flex-1 text-content1-foreground">Error</div>
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
{error.message}
</div>
</CardBody>
) : (
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
<div className="absolute right-0 bottom-0 text-5xl text-primary-400">
<BsTencentQq />
</div>
<div className="relative flex-shrink-0 z-10">
<Image
src={
data?.avatarUrl ??
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
}
className='shadow-md rounded-full w-12 aspect-square'
/>
<div
className={clsx(
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
data?.online ? 'bg-green-500' : 'bg-gray-500'
)}
/>
</div>
<div className='flex-col justify-center'>
<div className='text-lg truncate'>{data?.nick}</div>
<div className='text-primary-500 text-sm'>{data?.uin}</div>
</div>
</CardBody>
)}
className="shadow-md rounded-full w-12 aspect-square"
/>
<div
className={clsx(
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
data?.online ? 'bg-green-500' : 'bg-gray-500'
)}
></div>
</div>
<div className="flex-col justify-center">
<div className="text-lg truncate">{data?.nick}</div>
<div className="text-primary-500 text-sm">{data?.uin}</div>
</div>
</CardBody>
)}
</Card>
);
};
)
}
export default QQInfoCard;
export default QQInfoCard

View File

@@ -1,5 +1,5 @@
import { Spinner } from '@heroui/spinner';
import { QRCodeSVG } from 'qrcode.react';
import { Spinner } from '@heroui/spinner'
import { QRCodeSVG } from 'qrcode.react'
interface QrCodeLoginProps {
qrcode: string
@@ -7,18 +7,18 @@ interface QrCodeLoginProps {
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
return (
<div className='flex flex-col items-center'>
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
<div className="flex flex-col items-center">
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
{!qrcode && (
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
<Spinner color='primary' />
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
<Spinner color="primary" />
</div>
)}
<QRCodeSVG size={180} value={qrcode} />
</div>
<div className='mt-5 text-center'>使QQ或者TIM扫描上方二维码</div>
<div className="mt-5 text-center">使QQ或者TIM扫描上方二维码</div>
</div>
);
};
)
}
export default QrCodeLogin;
export default QrCodeLogin

View File

@@ -1,10 +1,10 @@
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Select, SelectItem } from '@heroui/select';
import { IoMdRefresh } from 'react-icons/io';
import { Avatar } from '@heroui/avatar'
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { Select, SelectItem } from '@heroui/select'
import { IoMdRefresh } from 'react-icons/io'
import { isQQQuickNewItem } from '@/utils/qq';
import { isQQQuickNewItem } from '@/utils/qq'
export interface QQItem {
uin: string
@@ -27,67 +27,67 @@ const QuickLogin: React.FC<QuickLoginProps> = ({
selectedQQ,
onUpdateQQList,
handleSelectionChange,
onSubmit,
onSubmit
}) => {
return (
<div className='flex flex-col gap-8'>
<div className='flex justify-center'>
<div className="flex flex-col gap-8">
<div className="flex justify-center">
<Image
className='shadow-lg'
className="shadow-lg"
height={100}
radius='full'
radius="full"
src={`https://q1.qlogo.cn/g?b=qq&nk=${selectedQQ || '0'}&s=100`}
width={100}
/>
</div>
<div className='flex items-center gap-2'>
<div className="flex items-center gap-2">
<Select
classNames={{
popoverContent: 'bg-opacity-50 backdrop-blur',
popoverContent: 'bg-opacity-50 backdrop-blur'
}}
aria-label='QQ Login'
aria-label="QQ Login"
isDisabled={refresh}
items={qqList}
placeholder='请选择QQ'
placeholder="请选择QQ"
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className='flex items-center gap-2'>
<div key={item.key} className="flex items-center gap-2">
<Avatar
alt={item.key?.toString()}
className='flex-shrink-0'
size='sm'
className="flex-shrink-0"
size="sm"
src={
isQQQuickNewItem(item.data)
? item.data?.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.key}&s=1`
}
/>
<div className='flex flex-col'>
<div className="flex flex-col">
{isQQQuickNewItem(item.data)
? `${item.data.nickName}(${item.key?.toString()})`
: item.key?.toString()}
</div>
</div>
));
))
}}
selectedKeys={[selectedQQ]}
size='lg'
size="lg"
onChange={handleSelectionChange}
>
{(item) => (
<SelectItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<div className="flex items-center gap-2">
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
className="flex-shrink-0"
size="sm"
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
<div className="flex flex-col">
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
@@ -98,32 +98,32 @@ const QuickLogin: React.FC<QuickLoginProps> = ({
</Select>
<Button
isIconOnly
className='flex-grow-0 flex-shrink-0'
color='secondary'
className="flex-grow-0 flex-shrink-0"
color="secondary"
isLoading={refresh}
radius='full'
size='lg'
variant='light'
radius="full"
size="lg"
variant="light"
onPress={onUpdateQQList}
>
<IoMdRefresh size={24} />
</Button>
</div>
<div className='flex justify-center mt-5'>
<div className="flex justify-center mt-5">
<Button
className='w-64 max-w-full'
color='primary'
className="w-64 max-w-full"
color="primary"
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
radius="full"
size="lg"
variant="shadow"
onPress={onSubmit}
>
</Button>
</div>
</div>
);
};
)
}
export default QuickLogin;
export default QuickLogin

View File

@@ -3,19 +3,19 @@ import {
HTMLMotionProps,
TargetAndTransition,
Transition,
motion,
} from 'motion/react';
motion
} from 'motion/react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
useState
} from 'react'
function cn (...classes: (string | undefined | null | boolean)[]): string {
return classes.filter(Boolean).join(' ');
function cn(...classes: (string | undefined | null | boolean)[]): string {
return classes.filter(Boolean).join(' ')
}
export interface RotatingTextRef {
@@ -73,65 +73,65 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
},
ref
) => {
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0);
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
const splitIntoCharacters = (text: string): string[] => {
return Array.from(text);
};
return Array.from(text)
}
const elements = useMemo(() => {
const currentText: string = texts[currentTextIndex];
const currentText: string = texts[currentTextIndex]
if (splitBy === 'characters') {
const words = currentText.split(' ');
const words = currentText.split(' ')
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}));
needsSpace: i !== words.length - 1
}))
}
if (splitBy === 'words') {
return currentText.split(' ').map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1,
}));
needsSpace: i !== arr.length - 1
}))
}
if (splitBy === 'lines') {
return currentText.split('\n').map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1,
}));
needsSpace: i !== arr.length - 1
}))
}
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1,
}));
}, [texts, currentTextIndex, splitBy]);
needsSpace: i !== arr.length - 1
}))
}, [texts, currentTextIndex, splitBy])
const getStaggerDelay = useCallback(
(index: number, totalChars: number): number => {
const total = totalChars;
if (staggerFrom === 'first') return index * staggerDuration;
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration;
const total = totalChars
if (staggerFrom === 'first') return index * staggerDuration
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
if (staggerFrom === 'center') {
const center = Math.floor(total / 2);
return Math.abs(center - index) * staggerDuration;
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === 'random') {
const randomIndex = Math.floor(Math.random() * total);
return Math.abs(randomIndex - index) * staggerDuration;
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs((staggerFrom as number) - index) * staggerDuration;
return Math.abs((staggerFrom as number) - index) * staggerDuration
},
[staggerFrom, staggerDuration]
);
)
const handleIndexChange = useCallback(
(newIndex: number) => {
setCurrentTextIndex(newIndex);
if (onNext) onNext(newIndex);
setCurrentTextIndex(newIndex)
if (onNext) onNext(newIndex)
},
[onNext]
);
)
const next = useCallback(() => {
const nextIndex =
@@ -139,11 +139,11 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
? loop
? 0
: currentTextIndex
: currentTextIndex + 1;
: currentTextIndex + 1
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex);
handleIndexChange(nextIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const previous = useCallback(() => {
const prevIndex =
@@ -151,27 +151,27 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
? loop
? texts.length - 1
: currentTextIndex
: currentTextIndex - 1;
: currentTextIndex - 1
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex);
handleIndexChange(prevIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const jumpTo = useCallback(
(index: number) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1));
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex);
handleIndexChange(validIndex)
}
},
[texts.length, currentTextIndex, handleIndexChange]
);
)
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0);
handleIndexChange(0)
}
}, [currentTextIndex, handleIndexChange]);
}, [currentTextIndex, handleIndexChange])
useImperativeHandle(
ref,
@@ -179,16 +179,16 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
next,
previous,
jumpTo,
reset,
reset
}),
[next, previous, jumpTo, reset]
);
)
useEffect(() => {
if (!auto) return;
const intervalId = setInterval(next, rotationInterval);
return () => clearInterval(intervalId);
}, [next, rotationInterval, auto]);
if (!auto) return
const intervalId = setInterval(next, rotationInterval)
return () => clearInterval(intervalId)
}, [next, rotationInterval, auto])
return (
<motion.span
@@ -200,7 +200,7 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
layout
transition={transition}
>
<span className='sr-only'>{texts[currentTextIndex]}</span>
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence
mode={animatePresenceMode}
initial={animatePresenceInitial}
@@ -213,7 +213,7 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
: 'flex flex-wrap whitespace-pre-wrap relative'
)}
layout
aria-hidden='true'
aria-hidden="true"
initial={initial as HTMLMotionProps<'div'>['initial']}
animate={animate as HTMLMotionProps<'div'>['animate']}
exit={exit as HTMLMotionProps<'div'>['exit']}
@@ -221,7 +221,7 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0);
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span
key={wordIndex}
@@ -241,7 +241,7 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
(sum, word) => sum + word.characters.length,
0
)
),
)
}}
className={cn('inline-block', elementLevelClassName)}
>
@@ -249,17 +249,17 @@ const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
</motion.span>
))}
{wordObj.needsSpace && (
<span className='whitespace-pre'> </span>
<span className="whitespace-pre"> </span>
)}
</span>
);
)
})}
</motion.div>
</AnimatePresence>
</motion.span>
);
)
}
);
)
RotatingText.displayName = 'RotatingText';
export default RotatingText;
RotatingText.displayName = 'RotatingText'
export default RotatingText

View File

@@ -1,19 +1,19 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import clsx from 'clsx';
import { motion } from 'motion/react';
import React from 'react';
import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import clsx from 'clsx'
import { motion } from 'motion/react'
import React from 'react'
import { IoMdLogOut } from 'react-icons/io'
import { MdDarkMode, MdLightMode } from 'react-icons/md'
import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme';
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png';
import type { MenuItem } from '@/config/site';
import logo from '@/assets/images/logo.png'
import type { MenuItem } from '@/config/site'
import Menus from './menus';
import Menus from './menus'
interface SideBarProps {
open: boolean
@@ -21,17 +21,17 @@ interface SideBarProps {
}
const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items } = props;
const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth();
const dialog = useDialog();
const { open, items } = props
const { toggleTheme, isDark } = useTheme()
const { revokeAuth } = useAuth()
const dialog = useDialog()
const onRevokeAuth = () => {
dialog.confirm({
title: '退出登录',
content: '确定要退出登录吗?',
onConfirm: revokeAuth,
});
};
onConfirm: revokeAuth
})
}
return (
<motion.div
className={clsx(
@@ -42,13 +42,13 @@ const SideBar: React.FC<SideBarProps> = (props) => {
transition={{
type: open ? 'spring' : 'tween',
stiffness: 150,
damping: open ? 15 : 10,
damping: open ? 15 : 10
}}
style={{ overflow: 'hidden' }}
>
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right'>
<div className='flex justify-center items-center my-2 gap-2'>
<Image radius='none' height={40} src={logo} className='mb-2' />
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
<div className="flex justify-center items-center my-2 gap-2">
<Image radius="none" height={40} src={logo} className="mb-2" />
<div
className={clsx(
'flex items-center font-bold',
@@ -58,14 +58,14 @@ const SideBar: React.FC<SideBarProps> = (props) => {
NapCat
</div>
</div>
<div className='overflow-y-auto flex flex-col flex-1 px-4'>
<div className="overflow-y-auto flex flex-col flex-1 px-4">
<Menus items={items} />
<div className='mt-auto mb-10 md:mb-0'>
<div className="mt-auto mb-10 md:mb-0">
<Button
className='w-full'
color='primary'
radius='full'
variant='light'
className="w-full"
color="primary"
radius="full"
variant="light"
onPress={toggleTheme}
startContent={
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
@@ -74,10 +74,10 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</Button>
<Button
className='w-full mb-2'
color='primary'
radius='full'
variant='light'
className="w-full mb-2"
color="primary"
radius="full"
variant="light"
onPress={onRevokeAuth}
startContent={<IoMdLogOut size={16} />}
>
@@ -87,7 +87,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</div>
</motion.div>
</motion.div>
);
};
)
}
export default SideBar;
export default SideBar

View File

@@ -1,50 +1,50 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import React from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { useLocalStorage } from '@uidotdev/usehooks'
import clsx from 'clsx'
import React from 'react'
import { matchPath, useLocation, useNavigate } from 'react-router-dom'
import key from '@/const/key';
import key from '@/const/key'
import type { MenuItem } from '@/config/site';
import type { MenuItem } from '@/config/site'
const renderItems = (items: MenuItem[], children = false) => {
return items?.map((item) => {
const navigate = useNavigate();
const locate = useLocation();
const [open, setOpen] = React.useState(!!item.autoOpen);
const navigate = useNavigate()
const locate = useLocation()
const [open, setOpen] = React.useState(!!item.autoOpen)
const canOpen = React.useMemo(
() => item.items && item.items.length > 0,
[item.items]
);
const [b64img] = useLocalStorage(key.backgroundImage, '');
)
const [b64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
);
)
const isActive = React.useMemo(() => {
if (item.href) {
return !!matchPath(item.href, locate.pathname);
return !!matchPath(item.href, locate.pathname)
}
return false;
}, [item.href, locate.pathname]);
return false
}, [item.href, locate.pathname])
const goTo = (href: string) => {
navigate(href);
};
navigate(href)
}
React.useEffect(() => {
if (item.items) {
const shouldOpen = item.items.some(
(item) => item?.href && !!matchPath(item.href, locate.pathname)
);
)
if (shouldOpen) setOpen(true);
if (shouldOpen) setOpen(true)
}
}, [item.items, locate.pathname]);
const panelRef = React.useRef<HTMLDivElement>(null);
}, [item.items, locate.pathname])
const panelRef = React.useRef<HTMLDivElement>(null)
return (
<div key={item.href + item.label}>
@@ -55,78 +55,74 @@ const renderItems = (items: MenuItem[], children = false) => {
isActive && 'bg-opacity-60',
b64img && 'backdrop-blur-md text-white'
)}
color='primary'
color="primary"
endContent={
canOpen
? (
<div
className={clsx(
'ml-auto relative w-3 h-3 transition-transform',
open && 'transform rotate-180',
isActive
? 'text-primary-500'
: 'text-primary-200 dark:text-white',
'before:rounded-full',
'before:content-[""]',
'before:block',
'before:absolute',
'before:w-3',
'before:h-[4.5px]',
'before:bg-current',
'before:top-1/2',
'before:-left-[3px]',
'before:transform',
'before:-translate-y-1/2',
'before:rotate-45',
'after:rounded-full',
'after:content-[""]',
'after:block',
'after:absolute',
'after:w-3',
'after:h-[4.5px]',
'after:bg-current',
'after:top-1/2',
'after:left-[3px]',
'after:transform',
'after:-translate-y-1/2',
'after:-rotate-45'
)}
/>
)
: (
<div
className={clsx(
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive
? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-primary-200 dark:bg-white'
)}
/>
)
canOpen ? (
<div
className={clsx(
'ml-auto relative w-3 h-3 transition-transform',
open && 'transform rotate-180',
isActive
? 'text-primary-500'
: 'text-primary-200 dark:text-white',
'before:rounded-full',
'before:content-[""]',
'before:block',
'before:absolute',
'before:w-3',
'before:h-[4.5px]',
'before:bg-current',
'before:top-1/2',
'before:-left-[3px]',
'before:transform',
'before:-translate-y-1/2',
'before:rotate-45',
'after:rounded-full',
'after:content-[""]',
'after:block',
'after:absolute',
'after:w-3',
'after:h-[4.5px]',
'after:bg-current',
'after:top-1/2',
'after:left-[3px]',
'after:transform',
'after:-translate-y-1/2',
'after:-rotate-45'
)}
/>
) : (
<div
className={clsx(
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive
? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-primary-200 dark:bg-white'
)}
/>
)
}
radius='full'
radius="full"
startContent={
customIcons[item.label]
? (
<Image
radius='none'
src={customIcons[item.label]}
alt={item.label}
className='w-5 h-5'
/>
)
: (
item.icon
)
customIcons[item.label] ? (
<Image
radius="none"
src={customIcons[item.label]}
alt={item.label}
className="w-5 h-5"
/>
) : (
item.icon
)
}
variant={isActive ? (children ? 'solid' : 'shadow') : 'light'}
onPress={() => {
if (item.href) {
if (!isActive) {
goTo(item.href);
goTo(item.href)
}
} else if (canOpen) {
setOpen(!open);
setOpen(!open)
}
}}
>
@@ -134,29 +130,29 @@ const renderItems = (items: MenuItem[], children = false) => {
</Button>
<div
ref={panelRef}
className='ml-4 overflow-hidden transition-all duration-300'
className="ml-4 overflow-hidden transition-all duration-300"
style={{
height: open ? panelRef.current?.scrollHeight : 0,
height: open ? panelRef.current?.scrollHeight : 0
}}
>
{item.items && renderItems(item.items, true)}
</div>
</div>
);
});
};
)
})
}
interface MenusProps {
items: MenuItem[]
}
const Menus: React.FC<MenusProps> = (props) => {
const { items } = props;
const { items } = props
return (
<div className='flex flex-col justify-content-center flex-1 gap-2'>
<div className="flex flex-col justify-content-center flex-1 gap-2">
{renderItems(items)}
</div>
);
};
)
}
export default Menus;
export default Menus

View File

@@ -1,6 +1,6 @@
import { Switch } from '@heroui/switch';
import clsx from 'clsx';
import React, { forwardRef } from 'react';
import { Switch } from '@heroui/switch'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export interface SwitchCardProps {
label?: string
@@ -15,8 +15,8 @@ export interface SwitchCardProps {
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
(props, ref) => {
const { label, description, value, onValueChange, disabled } = props;
const selectString = value ? 'true' : 'false';
const { label, description, value, onValueChange, disabled } = props
const selectString = value ? 'true' : 'false'
return (
<Switch
@@ -25,7 +25,7 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
),
)
}}
{...props}
ref={ref}
@@ -34,15 +34,15 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
value={selectString}
onValueChange={onValueChange}
>
<div className='flex flex-col gap-1'>
<p className='text-medium'>{label}</p>
<p className='text-tiny text-default-400'>{description}</p>
<div className="flex flex-col gap-1">
<p className="text-medium">{label}</p>
<p className="text-tiny text-default-400">{description}</p>
</div>
</Switch>
);
)
}
);
)
SwitchCard.displayName = 'SwitchCard';
SwitchCard.displayName = 'SwitchCard'
export default SwitchCard;
export default SwitchCard

Some files were not shown because too many files have changed in this diff Show More