NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# NPM LOCK files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
dist.zip
|
||||
@@ -0,0 +1 @@
|
||||
public-hoist-pattern[]=*@heroui/*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 bietiaop
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
# NapCat WebUI
|
||||
|
||||
## 功能
|
||||
|
||||
- WebUI登录
|
||||
- QQ登录
|
||||
- 网络配置
|
||||
- OneBot/WebUI配置
|
||||
- 日志查看(实时日志、历史日志)
|
||||
- HTTP调试
|
||||
- WS调试
|
||||
- 在线音乐播放器,支持网易云音乐歌单(大屏在页面右下角,小屏在页面下方)
|
||||
|
||||
如果你有更多功能需求,欢迎在 issue 中提出。
|
||||
|
||||
# License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
# Related Projects
|
||||
|
||||
- [NapCat](https://github.com/NapNeko/NapCatQQ/)
|
||||
- [Karin](https://github.com/KarinJS/Karin/)
|
||||
|
||||
# Thanks to
|
||||
|
||||
- [Vercel](https://vercel.com/)
|
||||
- [React](https://react.dev/)
|
||||
- [HeroUI](https://nextui.org/)
|
||||
- and more open-source projects
|
||||
|
||||
感谢群友“维拉”提供的在线音乐API。
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NapCat WebUI</title>
|
||||
<meta key="title" content="NapCat WebUI" property="og:title" />
|
||||
<meta content="NapCat WebUI,基于React+tailwind+NextUI" property="og:description" />
|
||||
<meta content="NapCat WebUI,基于React+tailwind+NextUI" name="description" />
|
||||
<meta key="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
name="viewport" />
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"name": "napcat-webui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"build:full": "tsc && vite build",
|
||||
"fontmin": "node scripts/fontmin.cjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@heroui/accordion": "^2.2.8",
|
||||
"@heroui/avatar": "2.2.7",
|
||||
"@heroui/breadcrumbs": "2.2.7",
|
||||
"@heroui/button": "2.2.10",
|
||||
"@heroui/card": "2.2.10",
|
||||
"@heroui/checkbox": "2.3.9",
|
||||
"@heroui/chip": "2.2.7",
|
||||
"@heroui/code": "2.2.7",
|
||||
"@heroui/divider": "^2.2.21",
|
||||
"@heroui/dropdown": "2.3.10",
|
||||
"@heroui/form": "2.1.9",
|
||||
"@heroui/image": "2.2.6",
|
||||
"@heroui/input": "2.4.10",
|
||||
"@heroui/kbd": "2.2.7",
|
||||
"@heroui/link": "2.2.8",
|
||||
"@heroui/listbox": "2.3.10",
|
||||
"@heroui/modal": "2.2.8",
|
||||
"@heroui/navbar": "2.2.9",
|
||||
"@heroui/pagination": "^2.2.9",
|
||||
"@heroui/popover": "2.3.10",
|
||||
"@heroui/select": "2.4.10",
|
||||
"@heroui/skeleton": "^2.2.6",
|
||||
"@heroui/slider": "2.4.8",
|
||||
"@heroui/snippet": "2.2.11",
|
||||
"@heroui/spinner": "2.2.7",
|
||||
"@heroui/switch": "2.2.9",
|
||||
"@heroui/system": "2.4.7",
|
||||
"@heroui/table": "^2.2.9",
|
||||
"@heroui/tabs": "2.2.8",
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@react-aria/visually-hidden": "^3.8.19",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"motion": "^12.0.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"qface": "^1.4.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-types/shared": "^3.26.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/fabric": "^5.3.9",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "5.2.3",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fontmin": "^0.9.9",
|
||||
"glob": "^10.3.10",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^2.0.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"ahooks": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"react-window": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Fontmin Script - 动态裁剪字体
|
||||
* 扫描 src 目录中所有中文字符,生成字体子集
|
||||
*/
|
||||
const Fontmin = require('fontmin');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
// 配置
|
||||
const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf');
|
||||
const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf');
|
||||
const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts');
|
||||
const OUTPUT_NAME = 'AaCute.woff';
|
||||
const SRC_DIR = path.resolve(__dirname, '../src');
|
||||
|
||||
// 基础字符集(常用汉字 + 标点 + 数字 + 字母)
|
||||
const BASE_CHARS = `
|
||||
0123456789
|
||||
abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
,。!?、;:""''()【】《》…—·
|
||||
,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\`
|
||||
基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题
|
||||
登录退出确定取消保存删除编辑新建刷新加载更新下载上传
|
||||
成功失败错误警告提示信息状态在线离线连接断开
|
||||
用户名密码账号验证码记住自动
|
||||
文件管理打开关闭复制粘贴剪切重命名移动
|
||||
发送消息输入内容搜索查找筛选排序
|
||||
帮助文档教程反馈问题建议
|
||||
开启关闭启用禁用显示隐藏展开收起
|
||||
返回前进上一步下一步完成跳过
|
||||
今天昨天明天时间日期年月日时分秒
|
||||
总量使用占用剩余内存内核主频型号
|
||||
有新版本可用当前最新立即稍后
|
||||
`;
|
||||
|
||||
/**
|
||||
* 从源码文件中提取所有中文字符
|
||||
*/
|
||||
function extractCharsFromSource () {
|
||||
const chars = new Set(BASE_CHARS.replace(/\s/g, ''));
|
||||
|
||||
// 匹配所有 .tsx, .ts, .jsx, .js, .css 文件
|
||||
const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, {
|
||||
ignore: ['**/node_modules/**'],
|
||||
});
|
||||
|
||||
// 中文字符正则
|
||||
const chineseRegex = /[\u4e00-\u9fa5]/g;
|
||||
|
||||
files.forEach(file => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const matches = content.match(chineseRegex);
|
||||
if (matches) {
|
||||
matches.forEach(char => chars.add(char));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not read file ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(chars).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 fontmin
|
||||
*/
|
||||
async function run () {
|
||||
console.log('🔍 Scanning source files for Chinese characters...');
|
||||
const text = extractCharsFromSource();
|
||||
console.log(`📝 Found ${text.length} unique characters`);
|
||||
|
||||
// 检查源字体是否存在
|
||||
const sourceFont = SOURCE_FONT;
|
||||
if (!fs.existsSync(SOURCE_FONT)) {
|
||||
// 尝试查找原始 TTF 并复制(不重命名,保留原始)
|
||||
if (fs.existsSync(SOURCE_TTF_ORIGINAL)) {
|
||||
console.log('📦 Copying original font to AaCute-full.ttf...');
|
||||
fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT);
|
||||
} else {
|
||||
console.error(`❌ Source font not found: ${SOURCE_FONT}`);
|
||||
console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✂️ Subsetting font...');
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const fontmin = new Fontmin()
|
||||
.src(sourceFont)
|
||||
.use(Fontmin.glyph({ text }))
|
||||
.use(Fontmin.ttf2woff())
|
||||
.dest(OUTPUT_DIR);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fontmin.run((err, files) => {
|
||||
if (err) {
|
||||
console.error('❌ Fontmin error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
// 重命名输出文件
|
||||
const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff');
|
||||
const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME);
|
||||
|
||||
if (fs.existsSync(generatedWoff)) {
|
||||
// 如果目标文件存在,先删除
|
||||
if (fs.existsSync(targetFile)) {
|
||||
fs.unlinkSync(targetFile);
|
||||
}
|
||||
fs.renameSync(generatedWoff, targetFile);
|
||||
}
|
||||
|
||||
// 清理生成的 TTF 文件
|
||||
const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf');
|
||||
if (fs.existsSync(generatedTtf)) {
|
||||
fs.unlinkSync(generatedTtf);
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetFile)) {
|
||||
const stats = fs.statSync(targetFile);
|
||||
const sizeKB = (stats.size / 1024).toFixed(2);
|
||||
console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Suspense, lazy, useEffect } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import PageBackground from '@/components/page_background';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import Toaster from '@/components/toaster';
|
||||
|
||||
import DialogProvider from '@/contexts/dialog';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
|
||||
import store from '@/store';
|
||||
|
||||
const WebLoginPage = lazy(() => import('@/pages/web_login'));
|
||||
const IndexPage = lazy(() => import('@/pages/index'));
|
||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'));
|
||||
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 PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
|
||||
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
|
||||
const ExtensionPage = lazy(() => import('@/pages/dashboard/extension'));
|
||||
|
||||
function App () {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthChecker ({ children }: { children: React.ReactNode; }) {
|
||||
const { isAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuth) {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const token = search.get('token');
|
||||
let url = '/web_login';
|
||||
|
||||
if (token) {
|
||||
url += `?token=${token}`;
|
||||
}
|
||||
navigate(url, { replace: true });
|
||||
}
|
||||
}, [isAuth, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppRoutes () {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path='/' element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<Route path='network' element={<NetworkPage />} />
|
||||
<Route path='config' element={<ConfigPage />} />
|
||||
<Route path='logs' element={<LogsPage />} />
|
||||
<Route path='debug' element={<DebugPage />}>
|
||||
<Route path='ws' element={<WSDebug />} />
|
||||
<Route path='http' element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route path='file_manager' element={<FileManagerPage />} />
|
||||
<Route path='terminal' element={<TerminalPage />} />
|
||||
<Route path='plugins' element={<PluginPage />} />
|
||||
<Route path='plugin_store' element={<PluginStorePage />} />
|
||||
<Route path='extension' element={<ExtensionPage />} />
|
||||
<Route path='about' element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path='/qq_login' element={<QQLoginPage />} />
|
||||
<Route path='/web_login' element={<WebLoginPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 684 KiB |
@@ -0,0 +1,410 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import React, { useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
// 转换 HSL 字符串到对象
|
||||
const parseHsl = (hslStr: string) => {
|
||||
const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||
if (match) {
|
||||
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||||
}
|
||||
return { h: 0, s: 0, l: 0 };
|
||||
};
|
||||
|
||||
// 转换 HEX 到 HSL
|
||||
const hexToHsl = (hex: string) => {
|
||||
let r = 0; let g = 0; let b = 0;
|
||||
if (hex.length === 4) {
|
||||
r = parseInt('0x' + hex[1] + hex[1]);
|
||||
g = parseInt('0x' + hex[2] + hex[2]);
|
||||
b = parseInt('0x' + hex[3] + hex[3]);
|
||||
} else if (hex.length === 7) {
|
||||
r = parseInt('0x' + hex[1] + hex[2]);
|
||||
g = parseInt('0x' + hex[3] + hex[4]);
|
||||
b = parseInt('0x' + hex[5] + hex[6]);
|
||||
}
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const cmin = Math.min(r, g, b);
|
||||
const cmax = Math.max(r, g, b);
|
||||
const delta = cmax - cmin;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
let l = 0;
|
||||
|
||||
if (delta === 0) h = 0;
|
||||
else if (cmax === r) h = ((g - b) / delta) % 6;
|
||||
else if (cmax === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
|
||||
l = (cmax + cmin) / 2;
|
||||
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
s = +(s * 100).toFixed(1);
|
||||
l = +(l * 100).toFixed(1);
|
||||
|
||||
return { h, s, l };
|
||||
};
|
||||
|
||||
// 转换 HSL 到 HEX
|
||||
const hslToHex = (h: number, s: number, l: number) => {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0; let g = 0; let b = 0;
|
||||
|
||||
if (h >= 0 && h < 60) {
|
||||
r = c; g = x; b = 0;
|
||||
} else if (h >= 60 && h < 120) {
|
||||
r = x; g = c; b = 0;
|
||||
} else if (h >= 120 && h < 180) {
|
||||
r = 0; g = c; b = x;
|
||||
} else if (h >= 180 && h < 240) {
|
||||
r = 0; g = x; b = c;
|
||||
} else if (h >= 240 && h < 300) {
|
||||
r = x; g = 0; b = c;
|
||||
} else if (h >= 300 && h < 360) {
|
||||
r = c; g = 0; b = x;
|
||||
}
|
||||
r = Math.round((r + m) * 255);
|
||||
g = Math.round((g + m) * 255);
|
||||
b = Math.round((b + m) * 255);
|
||||
|
||||
const toHex = (n: number) => {
|
||||
const hex = n.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
return '#' + toHex(r) + toHex(g) + toHex(b);
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
hsl: { h: number, s: number, l: number; };
|
||||
onChange: (newHsl: { h: number, s: number, l: number; }) => void;
|
||||
}
|
||||
|
||||
// 饱和度/亮度面板
|
||||
const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const hslRef = useRef(hsl);
|
||||
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||
|
||||
const updateColor = useCallback((clientX: number, clientY: number) => {
|
||||
if (!panelRef.current) return;
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
|
||||
const s_hsv = x;
|
||||
const v_hsv = 1 - y;
|
||||
|
||||
const l_hsl = v_hsv * (1 - s_hsv / 2);
|
||||
let s_hsl = 0;
|
||||
if (l_hsl === 0 || l_hsl === 1) {
|
||||
s_hsl = 0;
|
||||
} else {
|
||||
s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl);
|
||||
}
|
||||
|
||||
onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 });
|
||||
}, [onChange]);
|
||||
|
||||
const handleStart = (clientX: number, clientY: number) => {
|
||||
setIsDragging(true);
|
||||
updateColor(clientX, clientY);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
handleStart(touch.clientX, touch.clientY);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateColor(e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
updateColor(touch.clientX, touch.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', handleEnd);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleEnd);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}, [isDragging, updateColor]);
|
||||
|
||||
const l_val = hsl.l / 100;
|
||||
const s_val = hsl.s / 100;
|
||||
const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val);
|
||||
const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv);
|
||||
|
||||
const markerX = s_hsv * 100;
|
||||
const markerY = (1 - v_hsv) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className='w-full h-40 rounded-lg relative cursor-crosshair overflow-hidden shadow-inner touch-none'
|
||||
style={{
|
||||
backgroundColor: 'hsl(' + hsl.h + ', 100%, 50%)',
|
||||
backgroundImage: 'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div
|
||||
className='w-4 h-4 rounded-full border-2 border-white shadow-md absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none'
|
||||
style={{
|
||||
left: markerX + '%',
|
||||
top: markerY + '%',
|
||||
backgroundColor: 'hsl(' + hsl.h + ', ' + hsl.s + '%, ' + hsl.l + '%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SatLightPanel.displayName = 'SatLightPanel';
|
||||
|
||||
const HueSlider = memo(({ hsl, onChange }: PanelProps) => {
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const hslRef = useRef(hsl);
|
||||
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||
|
||||
const updateHue = useCallback((clientX: number) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = (clientX - rect.left) / rect.width;
|
||||
x = Math.max(0, Math.min(1, x));
|
||||
onChange({ ...hslRef.current, h: x * 360 });
|
||||
}, [onChange]);
|
||||
|
||||
const handleStart = (clientX: number) => {
|
||||
setIsDragging(true);
|
||||
updateHue(clientX);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
handleStart(touch.clientX);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateHue(e.clientX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
updateHue(touch.clientX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', handleEnd);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleEnd);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}, [isDragging, updateHue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className='w-full h-4 rounded-full relative cursor-pointer mt-3 shadow-inner touch-none'
|
||||
style={{
|
||||
background: 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div
|
||||
className='w-4 h-4 rounded-full border-2 border-white shadow-md absolute top-0 transform -translate-x-1/2 pointer-events-none bg-white'
|
||||
style={{ left: (hsl.h / 360) * 100 + '%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HueSlider.displayName = 'HueSlider';
|
||||
|
||||
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
||||
const [hsl, setHsl] = useState(parseHsl(color));
|
||||
const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l));
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingRef.current) return;
|
||||
const newHsl = parseHsl(color);
|
||||
if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) {
|
||||
setHsl(newHsl);
|
||||
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => {
|
||||
setHsl(newHsl);
|
||||
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||
onChange('hsl(' + Math.round(newHsl.h) + ', ' + Math.round(newHsl.s) + '%, ' + Math.round(newHsl.l) + '%)');
|
||||
}, [onChange]);
|
||||
|
||||
const handleHexChange = (value: string) => {
|
||||
setHex(value);
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
const newHsl = hexToHsl(value);
|
||||
handleHslChange(newHsl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover triggerScaleOnOpen={false} placement='bottom'>
|
||||
<PopoverTrigger>
|
||||
<div className='flex items-center gap-2 cursor-pointer group'>
|
||||
<div
|
||||
className='w-10 h-10 rounded-lg shadow-sm border-2 border-default-200 transition-transform group-hover:scale-105'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-xs font-mono text-default-500'>{hex}</span>
|
||||
<span className='text-xs font-mono text-default-400'>HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl'
|
||||
onMouseDownCapture={() => { isDraggingRef.current = true; }}
|
||||
onMouseUpCapture={() => { isDraggingRef.current = false; }}
|
||||
onTouchStartCapture={() => { isDraggingRef.current = true; }}
|
||||
onTouchEndCapture={() => { isDraggingRef.current = false; }}
|
||||
>
|
||||
<div className='flex flex-col w-full gap-2'>
|
||||
<div className='flex justify-between items-center mb-2'>
|
||||
<span className='text-sm font-bold text-default-700'>选择颜色</span>
|
||||
<div
|
||||
className='w-6 h-6 rounded-full border border-default-200 shadow-sm'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SatLightPanel hsl={hsl} onChange={handleHslChange} />
|
||||
<HueSlider hsl={hsl} onChange={handleHslChange} />
|
||||
|
||||
<div className='grid grid-cols-4 gap-2 mt-2 items-center'>
|
||||
<span className='text-xs text-default-500 col-span-1'>HEX</span>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='flat'
|
||||
value={hex}
|
||||
onChange={(e) => handleHexChange(e.target.value)}
|
||||
className='col-span-3 font-mono'
|
||||
classNames={{
|
||||
input: 'text-xs uppercase',
|
||||
inputWrapper: 'h-8 min-h-8',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-4 gap-2 items-center'>
|
||||
<span className='text-xs text-default-500 col-span-1'>HSL</span>
|
||||
<div className='col-span-3 flex gap-1'>
|
||||
<Input
|
||||
size='sm' variant='flat' type='number'
|
||||
value={Math.round(hsl.h).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, h: Number(e.target.value) })}
|
||||
endContent={<span className='text-xs text-default-400'>H</span>}
|
||||
classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }}
|
||||
/>
|
||||
<Input
|
||||
size='sm' variant='flat' type='number'
|
||||
value={Math.round(hsl.s).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, s: Number(e.target.value) })}
|
||||
endContent={<span className='text-xs text-default-400'>S</span>}
|
||||
classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }}
|
||||
/>
|
||||
<Input
|
||||
size='sm' variant='flat' type='number'
|
||||
value={Math.round(hsl.l).toString()}
|
||||
onChange={(e) => handleHslChange({ ...hsl, l: Number(e.target.value) })}
|
||||
endContent={<span className='text-xs text-default-400'>L</span>}
|
||||
classNames={{ input: 'text-xs', inputWrapper: 'h-8 min-h-8 px-1' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-1 mt-2 flex-wrap justify-between'>
|
||||
{['#006FEE', '#17C964', '#F5A524', '#F31260', '#7828C8', '#000000', '#FFFFFF'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className='w-6 h-6 rounded-full border border-default-200 shadow-sm transition-transform hover:scale-110 active:scale-95'
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => handleHexChange(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
} from '@heroui/dropdown';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { FaRegCircleQuestion } from 'react-icons/fa6';
|
||||
import { IoAddCircleOutline } from 'react-icons/io5';
|
||||
|
||||
import {
|
||||
HTTPClientIcon,
|
||||
HTTPServerIcon,
|
||||
PCIcon,
|
||||
PlusIcon,
|
||||
WebsocketIcon,
|
||||
} from '../icons';
|
||||
|
||||
export interface AddButtonProps {
|
||||
onOpen: (key: keyof OneBotConfig['network']) => void;
|
||||
}
|
||||
|
||||
const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
const { onOpen } = props;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
placement='right'
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
startContent={<IoAddCircleOutline className='text-2xl' />}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label='Create Network Config'
|
||||
color='default'
|
||||
variant='flat'
|
||||
onAction={(key) => {
|
||||
onOpen(key as keyof OneBotConfig['network']);
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key='title'
|
||||
isReadOnly
|
||||
className='cursor-default hover:!bg-transparent'
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建网络配置</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpServers'
|
||||
textValue='httpServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个HTTP服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpSseServers'
|
||||
textValue='httpSseServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPServerIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP SSE服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个HTTP SSE服务器,你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpClients'
|
||||
textValue='httpClients'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<HTTPClientIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP客户端
|
||||
<Tooltip
|
||||
content='「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端,通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的,NapCat会主动连接它。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='websocketServers'
|
||||
textValue='websocketServers'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<WebsocketIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
Websocket服务器
|
||||
<Tooltip
|
||||
content='「由NapCat建立」一个WebSocket服务器,你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址,你或者你的框架应该连接到这个地址。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='websocketClients'
|
||||
textValue='websocketClients'
|
||||
startContent={
|
||||
<div className='w-6 h-6'>
|
||||
<PCIcon />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
Websocket客户端
|
||||
<Tooltip
|
||||
content='「由框架或者你自己建立」的WebSocket,通常框架会「提供」一个ws地址,NapCat会主动连接它。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddButton;
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
reset: () => void;
|
||||
refresh?: () => void;
|
||||
isSubmitting: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
onSubmit,
|
||||
reset,
|
||||
isSubmitting,
|
||||
refresh,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full flex flex-col justify-center gap-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2 mt-5'>
|
||||
<Button
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('重置成功');
|
||||
}}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-medium shadow-md shadow-primary/20'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SaveButtons;
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaMicrophone } from 'react-icons/fa6';
|
||||
import { IoMic } from 'react-icons/io5';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const AudioInsert = () => {
|
||||
const [audioUrl, setAudioUrl] = useState<string>('');
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showAudioSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'record',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
streamRef.current = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.start();
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
if (audioChunksRef.current.length > 0) {
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result as string;
|
||||
setAudioPreview(base64Audio);
|
||||
setShowPreview(true);
|
||||
};
|
||||
audioChunksRef.current = [];
|
||||
}
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
});
|
||||
recordingIntervalRef.current = setInterval(() => {
|
||||
setRecordingTime((prevTime) => prevTime + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
mediaRecorderRef.current?.stop();
|
||||
if (recordingIntervalRef.current) {
|
||||
clearInterval(recordingIntervalRef.current);
|
||||
recordingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const startRecording = () => {
|
||||
setAudioPreview(null);
|
||||
setShowPreview(false);
|
||||
setRecordingTime(0);
|
||||
setIsRecording(true);
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const handleShowPreview = () => {
|
||||
if (audioPreview) {
|
||||
showAudioSegment(audioPreview);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送音频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<IoMic className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传音频'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
audioInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入音频地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入音频地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={audioUrl}
|
||||
onChange={(e) => setAudioUrl(e.target.value)}
|
||||
placeholder='请输入音频地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(audioUrl)) {
|
||||
toast.error('请输入正确的音频地址');
|
||||
return;
|
||||
}
|
||||
showAudioSegment(audioUrl);
|
||||
setAudioUrl('');
|
||||
}}
|
||||
>
|
||||
<FaMicrophone />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<Tooltip content='录制音频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<IoMic />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-col gap-2 p-4'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
color={isRecording ? 'primary' : 'primary'}
|
||||
variant='flat'
|
||||
onPress={isRecording ? stopRecording : startRecording}
|
||||
>
|
||||
{isRecording ? '停止录制' : '开始录制'}
|
||||
</Button>
|
||||
{showPreview && audioPreview && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={handleShowPreview}
|
||||
>
|
||||
查看消息
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isRecording || audioPreview) && (
|
||||
<div className='flex gap-1 items-center'>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full',
|
||||
isRecording
|
||||
? 'animate-pulse bg-primary-400'
|
||||
: 'bg-success-400'
|
||||
)}
|
||||
/>
|
||||
<span>录制时长: {formatTime(recordingTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && audioPreview && (
|
||||
<audio controls src={audioPreview} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={audioInputRef}
|
||||
hidden
|
||||
accept='audio/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showAudioSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { BsDice3Fill } from 'react-icons/bs';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
const DiceInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
return (
|
||||
<Tooltip content='发送骰子'>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'dice',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<BsDice3Fill className='text-lg' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiceInsert;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { data, getUrl } from 'qface';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdEmojiEmotions } from 'react-icons/md';
|
||||
|
||||
import { EmojiValue } from '../formats/emoji_blot';
|
||||
|
||||
const emojis = data.map((item) => {
|
||||
return {
|
||||
alt: item.QDes,
|
||||
src: getUrl(item.QSid),
|
||||
id: item.QSid,
|
||||
} as EmojiValue;
|
||||
});
|
||||
|
||||
export interface EmojiPickerProps {
|
||||
onInsertEmoji: (emoji: EmojiValue) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
|
||||
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([]);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setVisibleEmojis([]); // Reset visible emojis
|
||||
requestAnimationFrame(() => loadEmojis()); // Start loading emojis
|
||||
}
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const loadEmojis = (index = 0, batchSize = 10) => {
|
||||
if (index < emojis.length) {
|
||||
setVisibleEmojis((prev) => [
|
||||
...prev,
|
||||
...emojis.slice(index, index + batchSize),
|
||||
]);
|
||||
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Popover
|
||||
portalContainer={containerRef.current!}
|
||||
shouldCloseOnScroll={false}
|
||||
placement='right-start'
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
setIsPopoverOpen(v);
|
||||
}}
|
||||
>
|
||||
<Tooltip content='插入表情'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='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'>
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<Button
|
||||
key={emoji.id}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => onInsertEmoji(emoji)}
|
||||
>
|
||||
<Image src={emoji.src} alt={emoji.alt} className='w-6 h-6' />
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaFolder } from 'react-icons/fa6';
|
||||
import { LuFilePlus2 } from 'react-icons/lu';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const FileInsert = () => {
|
||||
const [fileUrl, setFileUrl] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showFileSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送文件'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<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='上传文件'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
fileInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入文件地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入文件地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder='请输入文件地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(fileUrl)) {
|
||||
toast.error('请输入正确的文件地址');
|
||||
return;
|
||||
}
|
||||
showFileSegment(fileUrl);
|
||||
setFileUrl('');
|
||||
}}
|
||||
>
|
||||
<LuFilePlus2 />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showFileSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInsert;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
export interface ImageInsertProps {
|
||||
insertImage: (url: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
||||
const [imgUrl, setImgUrl] = useState<string>('');
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<Tooltip content='插入图片'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<MdImage className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Tooltip content='上传图片'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
imageInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入图片地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入图片地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={imgUrl}
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
placeholder='请输入图片地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(imgUrl)) {
|
||||
toast.error('请输入正确的图片地址');
|
||||
return;
|
||||
}
|
||||
insertImage(imgUrl);
|
||||
setImgUrl('');
|
||||
}}
|
||||
>
|
||||
<MdAddPhotoAlternate />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={imageInputRef}
|
||||
hidden
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
insertImage(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageInsert;
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Form } from '@heroui/form';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import type { SharedSelection } from '@heroui/system';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import type { Key } from '@react-types/shared';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMusicalNotes } from 'react-icons/io5';
|
||||
import { TbMusicPlus } from 'react-icons/tb';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type {
|
||||
CustomMusicSegment,
|
||||
MusicSegment,
|
||||
OB11Segment,
|
||||
} from '@/types/onebot';
|
||||
|
||||
type MusicData = CustomMusicSegment['data'] | MusicSegment['data'];
|
||||
|
||||
const MusicInsert = () => {
|
||||
const [musicId, setMusicId] = useState<string>('');
|
||||
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']));
|
||||
const [mode, setMode] = useState<Key>('default');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { control, handleSubmit, reset } = useForm<
|
||||
Omit<CustomMusicSegment['data'], 'type'>
|
||||
>({
|
||||
defaultValues: {
|
||||
url: '',
|
||||
audio: '',
|
||||
title: '',
|
||||
image: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
const showMusicSegment = (data: MusicData) => {
|
||||
const messages: OB11Segment[] = [];
|
||||
if (data.type === 'custom') {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data: {
|
||||
...data,
|
||||
type: 'custom',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'music',
|
||||
data,
|
||||
});
|
||||
}
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
|
||||
const onSubmit = (data: Omit<CustomMusicSegment['data'], 'type'>) => {
|
||||
showMusicSegment({
|
||||
type: 'custom',
|
||||
...data,
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='overflow-visible'>
|
||||
<Popover
|
||||
placement='right-start'
|
||||
shouldCloseOnScroll={false}
|
||||
portalContainer={containerRef.current!}
|
||||
>
|
||||
<Tooltip content='发送音乐'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<IoMusicalNotes className='text-xl' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='gap-2 p-4'>
|
||||
<Tabs
|
||||
placement='top'
|
||||
className='w-96'
|
||||
fullWidth
|
||||
selectedKey={mode}
|
||||
onSelectionChange={(key) => {
|
||||
if (key !== null) setMode(key);
|
||||
}}
|
||||
>
|
||||
<Tab title='主流平台' key='default' className='flex flex-col gap-2'>
|
||||
<Select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='音乐平台'
|
||||
selectedKeys={musicType}
|
||||
label='音乐平台'
|
||||
placeholder='请选择音乐平台'
|
||||
items={[
|
||||
{
|
||||
name: 'QQ音乐',
|
||||
id: 'qq',
|
||||
},
|
||||
{
|
||||
name: '网易云音乐',
|
||||
id: '163',
|
||||
},
|
||||
{
|
||||
name: '虾米音乐',
|
||||
id: 'xm',
|
||||
},
|
||||
]}
|
||||
onSelectionChange={setMusicType}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Input
|
||||
value={musicId}
|
||||
onChange={(e) => setMusicId(e.target.value)}
|
||||
placeholder='请输入音乐ID'
|
||||
label='音乐ID'
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!musicId) {
|
||||
toast.error('请输入音乐ID');
|
||||
return;
|
||||
}
|
||||
showMusicSegment({
|
||||
type: Array.from(
|
||||
musicType
|
||||
)[0] as MusicSegment['data']['type'],
|
||||
id: musicId,
|
||||
});
|
||||
setMusicId('');
|
||||
}}
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建{Array.from(musicType)[0] === '163' ? '网易云' : 'QQ'}音乐
|
||||
</Button>
|
||||
</Tab>
|
||||
<Tab
|
||||
title='自定义音乐'
|
||||
key='custom'
|
||||
className='flex flex-col gap-2'
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className='flex flex-col gap-2'
|
||||
validationBehavior='native'
|
||||
>
|
||||
<Controller
|
||||
name='url'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音乐URL' : null;
|
||||
}}
|
||||
size='sm'
|
||||
placeholder='请输入音乐URL'
|
||||
label='音乐URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='audio'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
validate={(v) => {
|
||||
return !isURI(v) ? '请输入正确的音频URL' : null;
|
||||
}}
|
||||
size='sm'
|
||||
placeholder='请输入音频URL'
|
||||
label='音频URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='title'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
isRequired
|
||||
size='sm'
|
||||
errorMessage='请输入音乐标题'
|
||||
placeholder='请输入音乐标题'
|
||||
label='音乐标题'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='image'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size='sm'
|
||||
placeholder='请输入封面图片URL'
|
||||
label='封面图片URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='content'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
size='sm'
|
||||
placeholder='请输入音乐描述'
|
||||
label='音乐描述'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
type='submit'
|
||||
startContent={<TbMusicPlus />}
|
||||
>
|
||||
创建自定义音乐
|
||||
</Button>
|
||||
</Form>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicInsert;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useState } from 'react';
|
||||
import { BsChatQuoteFill } from 'react-icons/bs';
|
||||
import { MdAdd } from 'react-icons/md';
|
||||
|
||||
export interface ReplyInsertProps {
|
||||
insertReply: (messageId: string) => void
|
||||
}
|
||||
|
||||
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
||||
const [replyId, setReplyId] = useState<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='回复消息'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<Button color='primary' variant='flat' isIconOnly radius='full'>
|
||||
<BsChatQuoteFill className='text-lg' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-2 p-4'>
|
||||
<Input
|
||||
placeholder='输入消息 ID'
|
||||
value={replyId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
|
||||
if (isNumberReg.test(value)) {
|
||||
setReplyId(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
insertReply(replyId);
|
||||
setReplyId('');
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyInsert;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { LiaHandScissors } from 'react-icons/lia';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
const RPSInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
|
||||
return (
|
||||
<Tooltip content='发送猜拳'>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'rps',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<LiaHandScissors className='text-2xl' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default RPSInsert;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
|
||||
import { OB11Segment } from '@/types/onebot';
|
||||
|
||||
export interface ShowStructedMessageProps {
|
||||
messages: OB11Segment[]
|
||||
}
|
||||
|
||||
const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
|
||||
return (
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(messages, null, 2)}
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0',
|
||||
}}
|
||||
className='bg-content1 h-96 overflow-y-scroll items-start'
|
||||
>
|
||||
{JSON.stringify(messages, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className='whitespace-pre-wrap break-all'>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowStructedMessage;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoVideocam } from 'react-icons/io5';
|
||||
import { MdEdit, MdUpload } from 'react-icons/md';
|
||||
import { TbVideoPlus } from 'react-icons/tb';
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { isURI } from '@/utils/url';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
const VideoInsert = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const showVideoSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
];
|
||||
showStructuredMessage(messages);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content='发送视频'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger>
|
||||
<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='上传视频'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
videoInputRef?.current?.click();
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content='输入视频地址'>
|
||||
<div className='max-w-fit'>
|
||||
<PopoverTrigger tooltip='输入视频地址'>
|
||||
<Button
|
||||
className='text-lg'
|
||||
color='primary'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
radius='full'
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className='flex-row gap-1 p-2'>
|
||||
<Input
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder='请输入视频地址'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
onPress={() => {
|
||||
if (!isURI(videoUrl)) {
|
||||
toast.error('请输入正确的视频地址');
|
||||
return;
|
||||
}
|
||||
showVideoSegment(videoUrl);
|
||||
setVideoUrl('');
|
||||
}}
|
||||
>
|
||||
<TbVideoPlus />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={videoInputRef}
|
||||
hidden
|
||||
accept='video/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result;
|
||||
showVideoSegment(dataURL as string);
|
||||
e.target.value = '';
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoInsert;
|
||||
@@ -0,0 +1,41 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface EmojiValue {
|
||||
alt: string
|
||||
src: string
|
||||
id: string
|
||||
}
|
||||
class EmojiBlot extends Embed {
|
||||
static blotName: string = 'emoji';
|
||||
static tagName: string = 'img';
|
||||
static classNames: string[] = ['w-6', 'h-6'];
|
||||
|
||||
static create (value: HTMLImageElement) {
|
||||
const node = super.create(value);
|
||||
node.setAttribute('alt', value.alt);
|
||||
node.setAttribute('src', value.src);
|
||||
node.setAttribute('data-id', value.id);
|
||||
node.classList.add(...EmojiBlot.classNames);
|
||||
return node;
|
||||
}
|
||||
|
||||
static formats (node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
static value (node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiBlot;
|
||||
@@ -0,0 +1,30 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface ImageValue {
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
class ImageBlot extends Embed {
|
||||
static blotName = 'image';
|
||||
static tagName = 'img';
|
||||
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom'];
|
||||
|
||||
static create (value: ImageValue) {
|
||||
const node = super.create();
|
||||
node.setAttribute('alt', value.alt);
|
||||
node.setAttribute('src', value.src);
|
||||
node.classList.add(...ImageBlot.classNames);
|
||||
return node;
|
||||
}
|
||||
|
||||
static value (node: HTMLImageElement): ImageValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageBlot;
|
||||
@@ -0,0 +1,43 @@
|
||||
import Quill from 'quill';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const BlockEmbed = Quill.import('blots/block/embed') as any
|
||||
export interface ReplyBlockValue {
|
||||
messageId: string
|
||||
}
|
||||
class ReplyBlock extends BlockEmbed {
|
||||
static blotName = 'reply';
|
||||
static tagName = 'div';
|
||||
static classNames = [
|
||||
'p-2',
|
||||
'select-none',
|
||||
'bg-default-100',
|
||||
'rounded-md',
|
||||
'pointer-events-none',
|
||||
];
|
||||
|
||||
static create (value: ReplyBlockValue) {
|
||||
const node = super.create();
|
||||
node.setAttribute('data-message-id', value.messageId);
|
||||
node.setAttribute('contenteditable', 'false');
|
||||
node.classList.add(...ReplyBlock.classNames);
|
||||
const innerDom = document.createElement('div');
|
||||
innerDom.classList.add('text-sm', 'text-default-500', 'relative');
|
||||
const svgContainer = document.createElement('div');
|
||||
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0');
|
||||
const svg = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>';
|
||||
svgContainer.innerHTML = svg;
|
||||
innerDom.innerHTML = `消息ID:${value.messageId}`;
|
||||
innerDom.appendChild(svgContainer);
|
||||
node.appendChild(innerDom);
|
||||
return node;
|
||||
}
|
||||
|
||||
static value (node: HTMLElement): ReplyBlockValue {
|
||||
return {
|
||||
messageId: node.getAttribute('data-message-id') || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ReplyBlock;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import type { Range } from 'quill';
|
||||
import 'quill/dist/quill.core.css';
|
||||
import { useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useCustomQuill } from '@/hooks/use_custom_quill';
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
|
||||
|
||||
import { quillToMessage } from '@/utils/onebot';
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot';
|
||||
|
||||
import AudioInsert from './components/audio_insert';
|
||||
import DiceInsert from './components/dice_insert';
|
||||
import EmojiPicker from './components/emoji_picker';
|
||||
import FileInsert from './components/file_insert';
|
||||
import ImageInsert from './components/image_insert';
|
||||
import MusicInsert from './components/music_insert';
|
||||
import ReplyInsert from './components/reply_insert';
|
||||
import RPSInsert from './components/rps_insert';
|
||||
import VideoInsert from './components/video_insert';
|
||||
import EmojiBlot from './formats/emoji_blot';
|
||||
import type { EmojiValue } from './formats/emoji_blot';
|
||||
import ImageBlot from './formats/image_blot';
|
||||
import ReplyBlock from './formats/reply_blot';
|
||||
|
||||
const ChatInput = () => {
|
||||
const memorizedRange = useRef<Range | null>(null);
|
||||
|
||||
const showStructuredMessage = useShowStructuredMessage();
|
||||
const formats: string[] = ['image', 'emoji', 'reply'];
|
||||
const modules = {
|
||||
toolbar: '#toolbar',
|
||||
};
|
||||
const { quillRef, quill, Quill } = useCustomQuill({
|
||||
modules,
|
||||
formats,
|
||||
placeholder: '请输入消息',
|
||||
});
|
||||
|
||||
if (Quill && !quill) {
|
||||
Quill.register('formats/emoji', EmojiBlot);
|
||||
Quill.register('formats/image', ImageBlot, true);
|
||||
Quill.register('formats/reply', ReplyBlock);
|
||||
}
|
||||
|
||||
if (quill) {
|
||||
quill.on('selection-change', (range) => {
|
||||
if (range) {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
|
||||
if (
|
||||
typeof firstOp?.insert !== 'string' &&
|
||||
firstOp?.insert?.reply &&
|
||||
range.index === 0 &&
|
||||
range.length !== quill.getLength()
|
||||
) {
|
||||
quill.setSelection(1, Quill.sources.SILENT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quill.on('text-change', () => {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
});
|
||||
|
||||
quill.on('editor-change', (eventName: string) => {
|
||||
if (eventName === 'text-change') {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quill.root.addEventListener('compositionstart', () => {
|
||||
const editorContent = quill.getContents();
|
||||
const firstOp = editorContent.ops[0];
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selection = quill?.getSelection();
|
||||
if (selection) memorizedRange.current = selection;
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = (url: string) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection();
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0);
|
||||
quill?.insertEmbed(selection?.index || 0, 'image', {
|
||||
src: url,
|
||||
alt: '图片',
|
||||
});
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0);
|
||||
};
|
||||
function insertReplyBlock (messageId: string) {
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
|
||||
if (!isNumberReg.test(messageId)) {
|
||||
toast.error('请输入正确的消息ID');
|
||||
return;
|
||||
}
|
||||
const editorContent = quill?.getContents();
|
||||
const firstOp = editorContent?.ops[0];
|
||||
const currentSelection = quill?.getSelection();
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply
|
||||
) {
|
||||
const delta = quill?.getContents();
|
||||
if (delta) {
|
||||
delta.ops[0] = {
|
||||
insert: { reply: { messageId } },
|
||||
};
|
||||
quill?.setContents(delta, Quill.sources.USER);
|
||||
}
|
||||
} else {
|
||||
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER);
|
||||
}
|
||||
quill?.setSelection((currentSelection?.index || 0) + 1, 0);
|
||||
quill?.blur();
|
||||
}
|
||||
const onInsertEmoji = (emoji: EmojiValue) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection();
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0);
|
||||
quill?.insertEmbed(selection?.index || 0, 'emoji', {
|
||||
alt: emoji.alt,
|
||||
src: emoji.src,
|
||||
id: emoji.id,
|
||||
});
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0);
|
||||
};
|
||||
|
||||
const getChatMessage = () => {
|
||||
const delta = quill?.getContents();
|
||||
const ops =
|
||||
delta?.ops?.filter((op) => {
|
||||
return op.insert !== '\n';
|
||||
}) ?? [];
|
||||
const messages: OB11Segment[] = ops.map((op) => {
|
||||
return quillToMessage(op);
|
||||
});
|
||||
return messages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={quillRef}
|
||||
className='border border-default-200 rounded-md !mb-2 !text-base !h-64'
|
||||
/>
|
||||
<div id='toolbar' className='!border-none flex gap-2'>
|
||||
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
|
||||
<EmojiPicker
|
||||
onInsertEmoji={onInsertEmoji}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<ReplyInsert insertReply={insertReplyBlock} />
|
||||
<FileInsert />
|
||||
<AudioInsert />
|
||||
<VideoInsert />
|
||||
<MusicInsert />
|
||||
<DiceInsert />
|
||||
<RPSInsert />
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => {
|
||||
const messages = getChatMessage();
|
||||
showStructuredMessage(messages);
|
||||
}}
|
||||
className='ml-auto'
|
||||
>
|
||||
获取JSON格式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure,
|
||||
} from '@heroui/modal';
|
||||
|
||||
import ChatInput from '.';
|
||||
|
||||
interface ChatInputModalProps {
|
||||
children?: (onOpen: () => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ChatInputModal ({ children }: ChatInputModalProps) {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children
|
||||
? children(onOpen)
|
||||
: (
|
||||
<Button
|
||||
onPress={onOpen}
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className='bg-primary/10 text-primary'
|
||||
>
|
||||
构造消息
|
||||
</Button>
|
||||
)}
|
||||
<Modal
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
构造消息
|
||||
</ModalHeader>
|
||||
<ModalBody className='overflow-y-auto'>
|
||||
<div className='overflow-y-auto'>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' onPress={onClose} variant='flat'>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useImperativeHandle, useEffect, useState } from 'react';
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const getLanguageExtension = (lang?: string) => {
|
||||
switch (lang) {
|
||||
case 'json': return json();
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
export interface CodeEditorProps {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
language?: string;
|
||||
defaultLanguage?: string;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
height?: string;
|
||||
options?: any;
|
||||
onMount?: any;
|
||||
}
|
||||
|
||||
export interface CodeEditorRef {
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
const chromeless = !!props.options?.chromeless;
|
||||
|
||||
const [val, setVal] = useState(props.value || props.defaultValue || '');
|
||||
const internalRef = React.useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setVal(props.value);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => {
|
||||
// Prefer getting dynamic value from view, fallback to state
|
||||
return internalRef.current?.view?.state.doc.toString() || val;
|
||||
},
|
||||
}));
|
||||
|
||||
const customTheme = EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '14px',
|
||||
height: '100% !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
'&.cm-editor': {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: "var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)",
|
||||
lineHeight: '1.6',
|
||||
overflow: 'auto !important',
|
||||
height: '100% !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent !important',
|
||||
borderRight: 'none',
|
||||
color: isDark
|
||||
? 'hsl(var(--heroui-foreground-500) / 0.75)'
|
||||
: 'hsl(var(--heroui-foreground-500) / 0.65)',
|
||||
},
|
||||
'.cm-gutterElement': {
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent !important',
|
||||
color: isDark
|
||||
? 'hsl(var(--heroui-foreground) / 0.9) !important'
|
||||
: 'hsl(var(--heroui-foreground) / 0.8) !important',
|
||||
},
|
||||
'.cm-content': {
|
||||
color: 'hsl(var(--heroui-foreground) / 0.9)',
|
||||
caretColor: 'hsl(var(--heroui-foreground) / 0.9)',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '12px',
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: isDark
|
||||
? 'hsl(var(--heroui-foreground) / 0.08)'
|
||||
: 'hsl(var(--heroui-foreground) / 0.06)',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
backgroundColor: isDark
|
||||
? 'hsl(var(--heroui-foreground) / 0.16)'
|
||||
: 'hsl(var(--heroui-foreground) / 0.12)',
|
||||
},
|
||||
// Syntax highlighting overrides for better readability
|
||||
'.ͼo': {
|
||||
// JSON property names - use a softer primary color
|
||||
color: isDark
|
||||
? 'hsl(var(--heroui-primary) / 0.85)'
|
||||
: 'hsl(var(--heroui-primary) / 0.75)',
|
||||
},
|
||||
'.ͼd': {
|
||||
// Strings - softer green
|
||||
color: isDark ? '#98c379cc' : '#50a14fcc',
|
||||
},
|
||||
'.ͼc': {
|
||||
// Numbers - softer orange
|
||||
color: isDark ? '#d19a66cc' : '#c18401cc',
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
customTheme,
|
||||
getLanguageExtension(props.language || props.defaultLanguage),
|
||||
props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [],
|
||||
props.options?.readOnly ? EditorView.editable.of(false) : [],
|
||||
].flat();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
|
||||
className={clsx(
|
||||
chromeless
|
||||
? 'overflow-hidden transition-colors bg-transparent'
|
||||
: 'rounded-xl border overflow-hidden transition-colors backdrop-blur-sm',
|
||||
!chromeless && (isDark
|
||||
? 'border-white/10 bg-white/5 text-default-100'
|
||||
: 'border-white/40 dark:border-white/10 bg-white/60 dark:bg-black/20 text-default-700')
|
||||
)}
|
||||
>
|
||||
<CodeMirror
|
||||
ref={internalRef}
|
||||
value={props.value ?? props.defaultValue}
|
||||
height='100%'
|
||||
className='h-full w-full [&_.cm-editor]:!bg-transparent [&_.cm-scroller]:!bg-transparent'
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
theme={isDark ? oneDark : 'light'}
|
||||
extensions={extensions}
|
||||
onChange={(value) => {
|
||||
setVal(value);
|
||||
props.onChange?.(value);
|
||||
}}
|
||||
readOnly={props.options?.readOnly}
|
||||
basicSetup={{
|
||||
lineNumbers: props.options?.lineNumbers !== 'off',
|
||||
foldGutter: props.options?.folding !== false,
|
||||
highlightActiveLine: props.options?.renderLineHighlight !== 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TbCornerDownLeft, TbSearch } from 'react-icons/tb';
|
||||
|
||||
export type CommandPaletteCommand = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
export type CommandPaletteExecuteMode = 'open' | 'send';
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
commands: CommandPaletteCommand[];
|
||||
onExecute: (commandId: string, mode: CommandPaletteExecuteMode) => void;
|
||||
}
|
||||
|
||||
const isMobileByViewport = () => {
|
||||
try {
|
||||
return window.innerWidth < 768;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default function CommandPalette (props: CommandPaletteProps) {
|
||||
const { isOpen, onOpenChange, commands, onExecute } = props;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [mobile, setMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setMobile(isMobileByViewport());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
// 等 Modal 动画挂载后再 focus
|
||||
const t = window.setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = !q
|
||||
? commands
|
||||
: commands.filter((c) => {
|
||||
const hay = `${c.id} ${c.title} ${c.subtitle ?? ''} ${c.group ?? ''}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
|
||||
// 简单:优先 path 前缀命中
|
||||
if (!q) return list;
|
||||
const starts = list.filter((c) => c.id.toLowerCase().startsWith(q));
|
||||
const rest = list.filter((c) => !c.id.toLowerCase().startsWith(q));
|
||||
return [...starts, ...rest];
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex >= filtered.length) setActiveIndex(0);
|
||||
}, [filtered.length, activeIndex]);
|
||||
|
||||
const active = filtered[activeIndex];
|
||||
|
||||
const exec = (mode: CommandPaletteExecuteMode) => {
|
||||
if (!active) return;
|
||||
onExecute(active.id, mode);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1)));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Shift+Enter 仅打开;Enter 打开并发送
|
||||
exec(e.shiftKey ? 'open' : 'send');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
size={mobile ? 'full' : '2xl'}
|
||||
radius={mobile ? 'none' : 'lg'}
|
||||
scrollBehavior='inside'
|
||||
backdrop='blur'
|
||||
>
|
||||
<ModalContent>
|
||||
{() => (
|
||||
<>
|
||||
<ModalHeader className={clsx(
|
||||
'flex items-center gap-2',
|
||||
mobile ? 'border-b border-default-200/50' : ''
|
||||
)}
|
||||
>
|
||||
<span className='text-sm font-semibold'>命令面板</span>
|
||||
<span className='text-xs text-default-400 font-normal hidden md:inline'>Ctrl/Cmd + K</span>
|
||||
</ModalHeader>
|
||||
<ModalBody className={clsx('gap-3', mobile ? 'p-3' : 'p-4')}>
|
||||
<Input
|
||||
ref={inputRef as any}
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder='输入 /set_xxx 或 描述… Enter:打开并发送,Shift+Enter:仅打开'
|
||||
startContent={<TbSearch className='opacity-40' size={16} />}
|
||||
radius='lg'
|
||||
variant='flat'
|
||||
classNames={{
|
||||
inputWrapper: 'bg-content2/50 border border-default-200/50 dark:border-default-100/20',
|
||||
input: 'text-sm',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={clsx(
|
||||
'rounded-xl border border-default-200/50 dark:border-default-100/20 overflow-hidden',
|
||||
mobile ? 'flex-1 min-h-0' : 'max-h-[420px]'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'divide-y divide-default-200/50 dark:divide-default-100/20 overflow-y-auto no-scrollbar',
|
||||
mobile ? 'h-full' : 'max-h-[420px]'
|
||||
)}
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<div className='p-6 text-sm text-default-400'>没有匹配的接口</div>
|
||||
)}
|
||||
{filtered.map((c, idx) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type='button'
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 transition-colors flex items-center gap-3',
|
||||
idx === activeIndex
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-default-100/50 dark:hover:bg-default-50/10'
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onClick={() => {
|
||||
setActiveIndex(idx);
|
||||
exec('open');
|
||||
}}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2 min-w-0'>
|
||||
<span className='text-xs font-mono text-default-500 truncate'>{c.id}</span>
|
||||
{c.group && (
|
||||
<span className='text-[10px] px-2 py-0.5 rounded-full bg-default-100/60 dark:bg-default-50/20 text-default-500'>
|
||||
{c.group}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-sm text-default-700 dark:text-default-200 truncate'>{c.title}</div>
|
||||
{c.subtitle && (
|
||||
<div className='text-xs text-default-400 truncate'>{c.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 flex-shrink-0'>
|
||||
<span className='hidden md:inline text-[10px] text-default-400'>Enter</span>
|
||||
<TbCornerDownLeft className='opacity-40' size={16} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
{mobile && (
|
||||
<ModalFooter className='border-t border-default-200/50'>
|
||||
<Button radius='full' variant='flat' onPress={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
radius='full'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
isDisabled={!active}
|
||||
onPress={() => exec('open')}
|
||||
>
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
radius='full'
|
||||
color='primary'
|
||||
isDisabled={!active}
|
||||
onPress={() => exec('send')}
|
||||
>
|
||||
打开并发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { CgDebug } from 'react-icons/cg';
|
||||
import { FiEdit3 } from 'react-icons/fi';
|
||||
import { MdDeleteForever } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
|
||||
type NetworkType = OneBotConfig['network'];
|
||||
|
||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||
label: string;
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]];
|
||||
render?: (
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
) => React.ReactNode;
|
||||
}>;
|
||||
|
||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
data: NetworkType[T][0];
|
||||
typeLabel: string;
|
||||
fields: NetworkDisplayCardFields<T>;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
showType?: boolean;
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
data,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug,
|
||||
showType,
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const handleEnable = () => {
|
||||
setEditing(true);
|
||||
onEnable().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setEditing(true);
|
||||
onDelete().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleEnableDebug = () => {
|
||||
setEditing(true);
|
||||
onEnableDebug().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
tag={showType ? typeLabel : undefined}
|
||||
action={
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors'
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
isDisabled={editing}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className={clsx(
|
||||
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
|
||||
debug
|
||||
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
|
||||
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
|
||||
)}
|
||||
startContent={<CgDebug size={16} />}
|
||||
onPress={handleEnableDebug}
|
||||
isDisabled={editing}
|
||||
>
|
||||
{debug ? '默认' : '调试'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
isDisabled={editing}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
classNames={{
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{(() => {
|
||||
const targetFullField = fields.find(f => isFullWidthField(f.label));
|
||||
|
||||
if (targetFullField) {
|
||||
// 模式1:存在全宽字段(如URL),布局为:
|
||||
// Row 1: 类型 (全宽)
|
||||
// Row 2: 全宽字段 (全宽)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{targetFullField.render
|
||||
? targetFullField.render(targetFullField.value)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
|
||||
)}
|
||||
>
|
||||
{String(targetFullField.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列)
|
||||
// Row 1: 类型 | Field 0
|
||||
// Row 2: Field 1 | Field 2
|
||||
const displayFields = fields.slice(0, 3);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
{displayFields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
||||
)}
|
||||
>
|
||||
{String(field.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 如果字段不足3个,可以补充空白块占位吗?或者是让它空着?用户说要高度一致。只要是grid,通常高度会被撑开。目前这样应该能保证最多2行。 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkDisplayCard;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
export interface ContainerProps {
|
||||
title: React.ReactNode;
|
||||
tag?: React.ReactNode;
|
||||
action: React.ReactNode;
|
||||
enableSwitch: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string; // Add className prop
|
||||
}
|
||||
|
||||
export interface DisplayCardProps {
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
title: _title,
|
||||
action,
|
||||
enableSwitch,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
|
||||
<div className='flex-1 min-w-0 mr-2'>
|
||||
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
|
||||
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
|
||||
{_title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>{enableSwitch}</div>
|
||||
</CardHeader>
|
||||
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
|
||||
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplayCardContainer;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpClients'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { url, reportSelfMessage, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP客户端'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPClientDisplayCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerDisplayCard;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPSSEServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpSseServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'httpServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: 'CORS',
|
||||
value: enableCors,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'WS',
|
||||
value: enableWebsocket,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='HTTP服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPSSEServerDisplayCard;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { MdDeleteForever, MdSettings } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useState } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
function getPluginIconUrl (iconPath?: string): string | undefined {
|
||||
if (!iconPath) return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(key.token);
|
||||
if (!raw) return iconPath;
|
||||
const token = JSON.parse(raw);
|
||||
const url = new URL(iconPath, window.location.origin);
|
||||
url.searchParams.set('webui_token', token);
|
||||
return url.pathname + url.search;
|
||||
} catch {
|
||||
return iconPath;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
data: PluginItem;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onUninstall: () => Promise<void>;
|
||||
onConfig?: () => void;
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
data,
|
||||
onToggleStatus,
|
||||
onUninstall,
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status, icon } = data;
|
||||
const isEnabled = status === 'active';
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
// 后端已处理 icon,前端只需拼接 token;无 icon 时兜底 Vercel 风格头像
|
||||
const avatarUrl = getPluginIconUrl(icon) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
const handleToggle = () => {
|
||||
setProcessing(true);
|
||||
onToggleStatus().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
const handleUninstall = () => {
|
||||
setProcessing(true);
|
||||
onUninstall().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='p-4 flex flex-col gap-3'>
|
||||
{/* Header */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex items-center gap-3 min-w-0'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
className='flex-shrink-0'
|
||||
size='md'
|
||||
isBordered
|
||||
radius='full'
|
||||
color='default'
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className='text-xs text-default-500 mt-0.5 truncate'>
|
||||
by <span className='font-medium'>{author || '未知'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
|
||||
className='flex-shrink-0 font-medium h-6 px-1'
|
||||
>
|
||||
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className='relative min-h-[2.5rem] cursor-pointer group/desc'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Tooltip
|
||||
content={description}
|
||||
isDisabled={!description || description.length < 50 || isExpanded}
|
||||
placement='bottom'
|
||||
className='max-w-[280px]'
|
||||
delay={500}
|
||||
>
|
||||
<p className={clsx(
|
||||
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
|
||||
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
|
||||
)}
|
||||
>
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Version Badge */}
|
||||
<div>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className='px-4 pb-4 pt-0 gap-3'>
|
||||
<Switch
|
||||
isDisabled={processing}
|
||||
isSelected={isEnabled}
|
||||
onValueChange={handleToggle}
|
||||
size='sm'
|
||||
color='success'
|
||||
classNames={{
|
||||
wrapper: 'group-data-[selected=true]:bg-success',
|
||||
}}
|
||||
>
|
||||
<span className='text-xs font-medium text-default-600'>
|
||||
{isEnabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</Switch>
|
||||
|
||||
<div className='flex-1' />
|
||||
|
||||
{hasConfig && (
|
||||
<Tooltip content='插件配置'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
color='primary'
|
||||
onPress={onConfig}
|
||||
>
|
||||
<MdSettings size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip content='卸载插件' color='danger'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
color='danger'
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
<MdDeleteForever size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDisplayCard;
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable @stylistic/indent */
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
import { MdUpdate, MdOutlineGetApp } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useState } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined {
|
||||
// 1. 尝试从 downloadUrl 提取 GitHub 用户名 (通常是最准确的源码仓库所有者)
|
||||
if (downloadUrl) {
|
||||
try {
|
||||
const url = new URL(downloadUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 homepage 提取
|
||||
if (homepage) {
|
||||
try {
|
||||
const url = new URL(homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
} else {
|
||||
// 如果是自定义域名,尝试获取 favicon。使用主流的镜像服务以保证国内访问速度
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => void;
|
||||
onViewDetail?: () => void;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
onViewDetail,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
}) => {
|
||||
const { name, version, author, description, tags, homepage, downloadUrl } = data;
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
|
||||
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
// 作者链接组件
|
||||
const AuthorComponent = (
|
||||
<span className={clsx('font-medium transition-colors', homepage ? 'hover:text-primary hover:underline cursor-pointer' : '')}>
|
||||
{author || '未知作者'}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
// 降低边框粗细
|
||||
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody
|
||||
className={clsx('p-4 flex flex-col gap-3', onViewDetail && 'cursor-pointer')}
|
||||
onClick={onViewDetail}
|
||||
>
|
||||
{/* Header: Avatar + Name + Author */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
size='md'
|
||||
isBordered
|
||||
color='default'
|
||||
radius='full' // 圆形头像
|
||||
className='flex-shrink-0 transition-transform group-hover:scale-105'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5 min-w-0'>
|
||||
{homepage
|
||||
? (
|
||||
<Tooltip content='访问项目主页' placement='top' delay={500}>
|
||||
<a
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-base font-bold text-default-900 hover:text-primary transition-colors truncate'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可点击的作者名称 */}
|
||||
<div className='text-xs text-default-500 mt-0.5 truncate'>
|
||||
by {homepage
|
||||
? (
|
||||
<a
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{AuthorComponent}
|
||||
</a>
|
||||
)
|
||||
: AuthorComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className='relative min-h-[2.5rem] cursor-pointer group/desc'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Tooltip
|
||||
content={description}
|
||||
isDisabled={!description || description.length < 50 || isExpanded}
|
||||
placement='bottom'
|
||||
className='max-w-[280px]'
|
||||
delay={500}
|
||||
>
|
||||
<p className={clsx(
|
||||
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
|
||||
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
|
||||
)}
|
||||
>
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tags & Version */}
|
||||
<div className='flex items-center gap-1.5 flex-wrap'>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
|
||||
{/* Tags with proper truncation and hover */}
|
||||
{tags?.slice(0, 2).map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='h-5 text-xs px-0.5 bg-default-100 dark:bg-default-50/50 text-default-600'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{tags && tags.length > 2 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='flex flex-wrap gap-1 max-w-[200px] p-1'>
|
||||
{tags.map(t => (
|
||||
<span key={t} className='text-xs bg-white/10 px-1.5 py-0.5 rounded-md border border-white/10'>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='h-5 text-xs px-0.5 cursor-pointer hover:bg-default-200 transition-colors'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
+{tags.length - 2}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{installStatus === 'update-available' && installedVersion && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='shadow'
|
||||
color='warning'
|
||||
className='h-5 text-xs font-semibold px-0.5 ml-auto animate-pulse'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
新版本
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter
|
||||
className='px-4 pb-4 pt-0'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||
className='font-medium bg-success/20 text-success dark:bg-success/20 cursor-default'
|
||||
isDisabled
|
||||
>
|
||||
已安装
|
||||
</Button>
|
||||
)
|
||||
: installStatus === 'update-available'
|
||||
? (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
className='font-medium text-white shadow-warning/30 hover:shadow-warning/50 transition-shadow'
|
||||
startContent={<MdUpdate size={18} />}
|
||||
onPress={onInstall}
|
||||
>
|
||||
更新到 v{version}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='bordered'
|
||||
className='font-medium bg-white dark:bg-zinc-900 border hover:bg-primary hover:text-white transition-all shadow-sm group/btn'
|
||||
startContent={<MdOutlineGetApp size={20} className='transition-transform group-hover/btn:translate-y-0.5' />}
|
||||
onPress={onInstall}
|
||||
>
|
||||
立即安装
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginStoreCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketClients'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const {
|
||||
url,
|
||||
heartInterval,
|
||||
reconnectInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage,
|
||||
} = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketClients'> = [
|
||||
{ label: 'URL', value: url },
|
||||
{ label: '重连间隔', value: `${reconnectInterval}ms` },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='Websocket客户端'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketClientDisplayCard;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
||||
props
|
||||
) => {
|
||||
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
|
||||
const {
|
||||
host,
|
||||
port,
|
||||
heartInterval,
|
||||
messagePostFormat,
|
||||
reportSelfMessage,
|
||||
enableForcePushEvent,
|
||||
} = data;
|
||||
|
||||
const fields: NetworkDisplayCardFields<'websocketServers'> = [
|
||||
{ label: '主机', value: host },
|
||||
{ label: '端口', value: port },
|
||||
{ label: '心跳间隔', value: `${heartInterval}ms` },
|
||||
{ label: '消息格式', value: messagePostFormat },
|
||||
{
|
||||
label: '上报自身消息',
|
||||
value: reportSelfMessage,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '强制推送事件',
|
||||
value: enableForcePushEvent,
|
||||
render: (value) => (
|
||||
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
|
||||
{value ? '是' : '否'}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<NetworkDisplayCard
|
||||
data={data}
|
||||
showType={showType}
|
||||
typeLabel='Websocket服务器'
|
||||
fields={fields}
|
||||
onEdit={onEdit}
|
||||
onEnable={onEnable}
|
||||
onDelete={onDelete}
|
||||
onEnableDebug={onEnableDebug}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketServerDisplayCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
export interface NetworkItemDisplayProps {
|
||||
count: number;
|
||||
label: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
count,
|
||||
label,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 hover:bg-white/20 dark:hover:bg-black/20'
|
||||
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
|
||||
size === 'md'
|
||||
? 'col-span-8 md:col-span-2'
|
||||
: 'col-span-2 md:col-span-1'
|
||||
)}
|
||||
shadow='none'
|
||||
>
|
||||
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 font-mono font-bold',
|
||||
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium',
|
||||
size === 'md' ? 'text-sm' : 'text-xs',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkItemDisplay;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Card, CardProps } from '@heroui/card';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
// Firefox 里 backdrop-filter 和 3D transform 作用在同一个元素上时,卡片内容会画不出来
|
||||
// (#2083,macOS 上的 Firefox 157 复现),所以 Firefox 下不做倾斜,只保留光晕。
|
||||
const tiltEnabled = !/firefox/i.test(navigator.userAgent);
|
||||
|
||||
export interface HoverEffectCardProps extends CardProps {
|
||||
children: React.ReactNode
|
||||
maxXRotation?: number
|
||||
maxYRotation?: number
|
||||
lightClassName?: string
|
||||
lightStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
maxXRotation = 5,
|
||||
maxYRotation = 5,
|
||||
className,
|
||||
style,
|
||||
lightClassName,
|
||||
lightStyle,
|
||||
} = props;
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const lightRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isShowLight, setIsShowLight] = React.useState(false);
|
||||
const [pos, setPos] = React.useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'relative overflow-hidden bg-opacity-50 backdrop-blur-lg',
|
||||
className
|
||||
)}
|
||||
style={tiltEnabled
|
||||
? {
|
||||
willChange: 'transform',
|
||||
transform:
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
|
||||
...style,
|
||||
}
|
||||
: style}
|
||||
onMouseEnter={() => {
|
||||
if (tiltEnabled && cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.3s ease-out';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsShowLight(false);
|
||||
if (tiltEnabled && cardRef.current) {
|
||||
cardRef.current.style.transition = 'transform 0.5s';
|
||||
cardRef.current.style.transform =
|
||||
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current) {
|
||||
setIsShowLight(true);
|
||||
|
||||
const { x, y } = cardRef.current.getBoundingClientRect();
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
const offsetX = clientX - x;
|
||||
const offsetY = clientY - y;
|
||||
|
||||
const lightWidth = lightStyle?.width?.toString() || '100';
|
||||
const lightHeight = lightStyle?.height?.toString() || '100';
|
||||
const lightWidthNum = parseInt(lightWidth);
|
||||
const lightHeightNum = parseInt(lightHeight);
|
||||
|
||||
const left = offsetX - lightWidthNum / 2;
|
||||
const top = offsetY - lightHeightNum / 2;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top,
|
||||
});
|
||||
|
||||
if (!tiltEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
cardRef.current.style.transition = 'transform 0.1s';
|
||||
|
||||
const rangeX = 400 / 2;
|
||||
const rangeY = 400 / 2;
|
||||
|
||||
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation;
|
||||
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation;
|
||||
|
||||
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={lightRef}
|
||||
className={clsx(
|
||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]',
|
||||
lightClassName
|
||||
)}
|
||||
style={{
|
||||
...pos,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverEffectCard;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Code } from '@heroui/code';
|
||||
import { MdError } from 'react-icons/md';
|
||||
|
||||
export interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
function errorFallbackRender ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className='pt-32 flex flex-col justify-center items-center'>
|
||||
<div className='flex items-center'>
|
||||
<MdError className='mr-2' color='red' size={30} />
|
||||
<h1 className='text-2xl'>出错了</h1>
|
||||
</div>
|
||||
<div className='my-6 flex flex-col justify-center items-center'>
|
||||
<p className='mb-2'>错误信息</p>
|
||||
<Code>{error.message}</Code>
|
||||
</div>
|
||||
<Button color='primary' size='md' onPress={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default errorFallbackRender;
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
FaFile,
|
||||
FaFileAudio,
|
||||
FaFileCode,
|
||||
FaFileCsv,
|
||||
FaFileExcel,
|
||||
FaFileImage,
|
||||
FaFileLines,
|
||||
FaFilePdf,
|
||||
FaFilePowerpoint,
|
||||
FaFileVideo,
|
||||
FaFileWord,
|
||||
FaFileZipper,
|
||||
FaFolderClosed,
|
||||
} from 'react-icons/fa6';
|
||||
|
||||
export interface FileIconProps {
|
||||
name?: string
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
const FileIcon = (props: FileIconProps) => {
|
||||
const { name, isDirectory = false } = props;
|
||||
if (isDirectory) {
|
||||
return <FaFolderClosed className='text-yellow-500' />;
|
||||
}
|
||||
|
||||
const ext = name?.split('.').pop() || '';
|
||||
if (ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'svg':
|
||||
case 'bmp':
|
||||
case 'ico':
|
||||
case 'webp':
|
||||
case 'tiff':
|
||||
case 'tif':
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
case 'avif':
|
||||
case 'apng':
|
||||
case 'flif':
|
||||
case 'ai':
|
||||
case 'psd':
|
||||
case 'xcf':
|
||||
case 'sketch':
|
||||
case 'fig':
|
||||
case 'xd':
|
||||
case 'svgz':
|
||||
return <FaFileImage className='text-green-500' />;
|
||||
case 'pdf':
|
||||
return <FaFilePdf className='text-red-500' />;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FaFileWord className='text-blue-500' />;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return <FaFileExcel className='text-green-500' />;
|
||||
case 'csv':
|
||||
return <FaFileCsv className='text-green-500' />;
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return <FaFilePowerpoint className='text-red-500' />;
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
case 'bz2':
|
||||
case 'xz':
|
||||
case 'lz':
|
||||
case 'lzma':
|
||||
case 'zst':
|
||||
case 'zstd':
|
||||
case 'z':
|
||||
case 'taz':
|
||||
case 'tz':
|
||||
case 'tzo':
|
||||
return <FaFileZipper className='text-green-500' />;
|
||||
case 'txt':
|
||||
return <FaFileLines className='text-gray-500' />;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
return <FaFileAudio className='text-green-500' />;
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv':
|
||||
return <FaFileVideo className='text-red-500' />;
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'jsx':
|
||||
case 'tsx':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
case 'md':
|
||||
case 'sh':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'cs':
|
||||
case 'go':
|
||||
case 'php':
|
||||
case 'rb':
|
||||
case 'pl':
|
||||
case 'swift':
|
||||
case 'kt':
|
||||
case 'rs':
|
||||
case 'sql':
|
||||
case 'r':
|
||||
case 'scala':
|
||||
case 'groovy':
|
||||
case 'dart':
|
||||
case 'lua':
|
||||
case 'perl':
|
||||
case 'h':
|
||||
case 'm':
|
||||
case 'mm':
|
||||
case 'makefile':
|
||||
case 'cmake':
|
||||
case 'dockerfile':
|
||||
case 'gradle':
|
||||
case 'properties':
|
||||
case 'ini':
|
||||
case 'conf':
|
||||
case 'env':
|
||||
case 'bat':
|
||||
case 'cmd':
|
||||
case 'ps1':
|
||||
case 'psm1':
|
||||
case 'psd1':
|
||||
case 'ps1xml':
|
||||
case 'psc1':
|
||||
case 'pssc':
|
||||
case 'nuspec':
|
||||
case 'resx':
|
||||
case 'resw':
|
||||
case 'csproj':
|
||||
case 'vbproj':
|
||||
case 'vcxproj':
|
||||
case 'fsproj':
|
||||
case 'sln':
|
||||
case 'suo':
|
||||
case 'user':
|
||||
case 'userosscache':
|
||||
case 'sln.docstates':
|
||||
case 'dll':
|
||||
return <FaFileCode className='text-blue-500' />;
|
||||
default:
|
||||
return <FaFile className='text-gray-500' />;
|
||||
}
|
||||
}
|
||||
|
||||
return <FaFile className='text-gray-500' />;
|
||||
};
|
||||
|
||||
export default FileIcon;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface CreateFileModalProps {
|
||||
isOpen: boolean;
|
||||
fileType: 'file' | 'directory';
|
||||
newFileName: string;
|
||||
onTypeChange: (type: 'file' | 'directory') => void;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export default function CreateFileModal ({
|
||||
isOpen,
|
||||
fileType,
|
||||
newFileName,
|
||||
onTypeChange,
|
||||
onNameChange,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreateFileModalProps) {
|
||||
return (
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>新建</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ButtonGroup radius='sm' color='primary'>
|
||||
<Button
|
||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('file')}
|
||||
>
|
||||
文件
|
||||
</Button>
|
||||
<Button
|
||||
variant={fileType === 'directory' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('directory')}
|
||||
>
|
||||
目录
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Input radius='sm' label='名称' value={newFileName} onChange={onNameChange} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button radius='sm' color='primary' onPress={onCreate}>
|
||||
创建
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Code } from '@heroui/code';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
import CodeEditor from '@/components/code_editor';
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean;
|
||||
file: { path: string; content: string; } | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onContentChange: (newContent?: string) => void;
|
||||
}
|
||||
|
||||
export default function FileEditModal ({
|
||||
isOpen,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
onContentChange,
|
||||
}: FileEditModalProps) {
|
||||
// 根据文件后缀返回对应语言
|
||||
const getLanguage = (filePath: string) => {
|
||||
if (filePath.endsWith('.js')) return 'javascript';
|
||||
if (filePath.endsWith('.ts')) return 'typescript';
|
||||
if (filePath.endsWith('.tsx')) return 'tsx';
|
||||
if (filePath.endsWith('.jsx')) return 'jsx';
|
||||
if (filePath.endsWith('.vue')) return 'vue';
|
||||
if (filePath.endsWith('.svelte')) return 'svelte';
|
||||
if (filePath.endsWith('.json')) return 'json';
|
||||
if (filePath.endsWith('.html')) return 'html';
|
||||
if (filePath.endsWith('.css')) return 'css';
|
||||
if (filePath.endsWith('.scss')) return 'scss';
|
||||
if (filePath.endsWith('.less')) return 'less';
|
||||
if (filePath.endsWith('.md')) return 'markdown';
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
|
||||
if (filePath.endsWith('.xml')) return 'xml';
|
||||
if (filePath.endsWith('.sql')) return 'sql';
|
||||
if (filePath.endsWith('.sh')) return 'shell';
|
||||
if (filePath.endsWith('.bat')) return 'bat';
|
||||
if (filePath.endsWith('.php')) return 'php';
|
||||
if (filePath.endsWith('.java')) return 'java';
|
||||
if (filePath.endsWith('.c')) return 'c';
|
||||
if (filePath.endsWith('.cpp')) return 'cpp';
|
||||
if (filePath.endsWith('.h')) return 'h';
|
||||
if (filePath.endsWith('.hpp')) return 'hpp';
|
||||
if (filePath.endsWith('.go')) return 'go';
|
||||
if (filePath.endsWith('.py')) return 'python';
|
||||
if (filePath.endsWith('.rb')) return 'ruby';
|
||||
if (filePath.endsWith('.cs')) return 'csharp';
|
||||
if (filePath.endsWith('.swift')) return 'swift';
|
||||
if (filePath.endsWith('.vb')) return 'vb';
|
||||
if (filePath.endsWith('.lua')) return 'lua';
|
||||
if (filePath.endsWith('.pl')) return 'perl';
|
||||
if (filePath.endsWith('.r')) return 'r';
|
||||
return 'plaintext';
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose} scrollBehavior='inside'>
|
||||
<ModalContent className='flex flex-col h-full max-h-[100dvh]'>
|
||||
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50 flex-shrink-0'>
|
||||
<span>编辑文件</span>
|
||||
<Code radius='sm' className='text-xs'>{file?.path}</Code>
|
||||
<div className='ml-auto text-xs text-default-400 font-normal px-2'>
|
||||
按 <span className='px-1 py-0.5 rounded border border-default-300 bg-default-100'>Ctrl/Cmd + S</span> 保存
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody className='p-4 bg-content2/50 flex-1 min-h-0 overflow-hidden'>
|
||||
<div
|
||||
className='h-full w-full overflow-auto' onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
value={file?.content || ''}
|
||||
onChange={onContentChange}
|
||||
options={{ wordWrap: 'on' }}
|
||||
language={file?.path ? getLanguage(file.path) : 'plaintext'}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className='border-t border-default-200/50 flex-shrink-0'>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button radius='sm' color='primary' onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import path from 'path-browserify';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm'];
|
||||
export const audioExts = ['.mp3', '.wav'];
|
||||
|
||||
export const supportedPreviewExts = [...videoExts, ...audioExts];
|
||||
|
||||
export default function FilePreviewModal ({
|
||||
isOpen,
|
||||
filePath,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
run();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run();
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
let contentElement = null;
|
||||
if (!supportedPreviewExts.includes(ext)) {
|
||||
contentElement = <div>暂不支持预览此文件类型</div>;
|
||||
} else if (error) {
|
||||
contentElement = <div>读取文件失败</div>;
|
||||
} else if (loading || !data) {
|
||||
contentElement = (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else if (videoExts.includes(ext)) {
|
||||
contentElement = <video src={data} controls className='max-w-full' />;
|
||||
} else if (audioExts.includes(ext)) {
|
||||
contentElement = <audio src={data} controls className='w-full' />;
|
||||
} else {
|
||||
contentElement = (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className='flex justify-center items-center'>
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import {
|
||||
type Selection,
|
||||
type SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@heroui/table';
|
||||
import path from 'path-browserify';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BiRename } from 'react-icons/bi';
|
||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi';
|
||||
import { PhotoSlider } from 'react-photo-view';
|
||||
|
||||
import FileIcon from '@/components/file_icon';
|
||||
|
||||
import type { FileInfo } from '@/controllers/file_manager';
|
||||
|
||||
import { supportedPreviewExts } from './file_preview_modal';
|
||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
|
||||
|
||||
export interface FileTableProps {
|
||||
files: FileInfo[];
|
||||
currentPath: string;
|
||||
loading: boolean;
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (descriptor: SortDescriptor) => void;
|
||||
selectedFiles: Selection;
|
||||
onSelectionChange: (selected: Selection) => void;
|
||||
onDirectoryClick: (dirPath: string) => void;
|
||||
onEdit: (filePath: string) => void;
|
||||
onPreview: (filePath: string) => void;
|
||||
onRenameRequest: (name: string) => void;
|
||||
onMoveRequest: (name: string) => void;
|
||||
onCopyPath: (fileName: string) => void;
|
||||
onDelete: (filePath: string) => void;
|
||||
onDownload: (filePath: string) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function FileTable ({
|
||||
files,
|
||||
currentPath,
|
||||
loading,
|
||||
sortDescriptor,
|
||||
onSortChange,
|
||||
selectedFiles,
|
||||
onSelectionChange,
|
||||
onDirectoryClick,
|
||||
onEdit,
|
||||
onPreview,
|
||||
onRenameRequest,
|
||||
onMoveRequest,
|
||||
onCopyPath,
|
||||
onDelete,
|
||||
onDownload,
|
||||
}: FileTableProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pages = Math.ceil(files.length / PAGE_SIZE) || 1;
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
const displayFiles = files.slice(start, end);
|
||||
const [showImage, setShowImage] = useState(false);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([]);
|
||||
|
||||
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||
setPreviewImages((prev) => {
|
||||
const exists = prev.some((p) => p.key === image.key);
|
||||
if (exists) return prev;
|
||||
return [...prev, image];
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewImages([]);
|
||||
setPreviewIndex(0);
|
||||
setShowImage(false);
|
||||
setPage(1);
|
||||
}, [currentPath]);
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
const index = images.findIndex((image) => image.key === name);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
setPreviewIndex(index);
|
||||
setShowImage(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoSlider
|
||||
images={previewImages}
|
||||
visible={showImage}
|
||||
onClose={() => setShowImage(false)}
|
||||
index={previewIndex}
|
||||
onIndexChange={setPreviewIndex}
|
||||
/>
|
||||
<Table
|
||||
aria-label='文件列表'
|
||||
radius='sm'
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
defaultSelectedKeys={[]}
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode='multiple'
|
||||
bottomContent={
|
||||
<div className='flex w-full justify-center p-2 border-t border-white/10'>
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color='primary'
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
classNames={{
|
||||
cursor: 'bg-primary shadow-lg',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
classNames={{
|
||||
wrapper: 'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm p-0',
|
||||
th: 'bg-white/40 dark:bg-white/5 backdrop-blur-md text-default-600',
|
||||
td: 'group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none',
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn key='name' allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key='type' allowsSorting className='hidden md:table-cell'>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key='size' allowsSorting className='hidden md:table-cell'>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key='actions'>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
isLoading={loading}
|
||||
loadingContent={
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{displayFiles.map((file: FileInfo) => {
|
||||
const filePath = path.join(currentPath, file.name);
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
const previewable = supportedPreviewExts.includes(ext);
|
||||
const images = previewImages;
|
||||
return (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
{imageExts.includes(ext)
|
||||
? (
|
||||
<ImageNameButton
|
||||
name={file.name}
|
||||
filePath={filePath}
|
||||
onPreview={() => onPreviewImage(file.name, images)}
|
||||
onAddPreview={addPreviewImage}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
? onDirectoryClick(file.name)
|
||||
: previewable
|
||||
? onPreview(filePath)
|
||||
: onEdit(filePath)}
|
||||
className='text-left justify-start'
|
||||
startContent={
|
||||
<FileIcon
|
||||
name={file.name}
|
||||
isDirectory={file.isDirectory}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup radius='sm' size='sm' variant='light'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='danger'
|
||||
className='text-danger hover:bg-danger/10'
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import path from 'path-browserify';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
import FileIcon from '../file_icon';
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string;
|
||||
filePath: string;
|
||||
onPreview: () => void;
|
||||
onAddPreview: (image: PreviewImage) => void;
|
||||
}
|
||||
|
||||
export default function ImageNameButton ({
|
||||
name,
|
||||
filePath,
|
||||
onPreview,
|
||||
onAddPreview,
|
||||
}: ImageNameButtonProps) {
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!filePath || !imageExts.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
run();
|
||||
},
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onAddPreview({
|
||||
key: name,
|
||||
src: data,
|
||||
alt: name,
|
||||
});
|
||||
}
|
||||
}, [data, name, onAddPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
radius='sm'
|
||||
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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
import path from 'path-browserify';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoRemove } from 'react-icons/io5';
|
||||
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface MoveModalProps {
|
||||
isOpen: boolean;
|
||||
moveTargetPath: string;
|
||||
selectionInfo: string;
|
||||
onClose: () => void;
|
||||
onMove: () => void;
|
||||
onSelect: (dir: string) => void; // 新增回调
|
||||
}
|
||||
|
||||
// 将 DirectoryTree 改为递归组件
|
||||
// 新增 selectedPath 属性,用于标识当前选中的目录
|
||||
function DirectoryTree ({
|
||||
basePath,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
basePath: string;
|
||||
onSelect: (dir: string) => void;
|
||||
selectedPath?: string;
|
||||
}) {
|
||||
const [dirs, setDirs] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// 新增loading状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDirectories = async () => {
|
||||
try {
|
||||
// 直接使用 basePath 调用接口,移除 process.platform 判断
|
||||
const list = await FileManager.listDirectories(basePath);
|
||||
setDirs(list.map((item) => item.name));
|
||||
} catch (_error) {
|
||||
// ...error handling...
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!expanded) {
|
||||
setExpanded(true);
|
||||
setLoading(true);
|
||||
await fetchDirectories();
|
||||
setLoading(false);
|
||||
} else {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(basePath);
|
||||
handleToggle();
|
||||
};
|
||||
|
||||
// 计算显示的名称
|
||||
const getDisplayName = () => {
|
||||
if (basePath === '/') return '/';
|
||||
if (/^[A-Z]:$/i.test(basePath)) return basePath;
|
||||
return path.basename(basePath);
|
||||
};
|
||||
|
||||
// 更新 Button 的 variant 逻辑
|
||||
const isSeleted = selectedPath === basePath;
|
||||
const variant = isSeleted
|
||||
? 'solid'
|
||||
: selectedPath && path.dirname(selectedPath) === basePath
|
||||
? 'flat'
|
||||
: 'light';
|
||||
|
||||
return (
|
||||
<div className='ml-4'>
|
||||
<Button
|
||||
radius='sm'
|
||||
onPress={handleClick}
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-sm',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
{expanded ? <IoRemove /> : <IoAdd />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MoveModal ({
|
||||
isOpen,
|
||||
moveTargetPath,
|
||||
selectionInfo,
|
||||
onClose,
|
||||
onMove,
|
||||
onSelect,
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<DirectoryTree
|
||||
basePath='/'
|
||||
onSelect={onSelect}
|
||||
selectedPath={moveTargetPath}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-sm text-default-500 mt-2'>
|
||||
当前选择:{moveTargetPath || '未选择'}
|
||||
</p>
|
||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button radius='sm' color='primary' onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean;
|
||||
newFileName: string;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
export default function RenameModal ({
|
||||
isOpen,
|
||||
newFileName,
|
||||
onNameChange,
|
||||
onClose,
|
||||
onRename,
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input radius='sm' label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button radius='sm' color='primary' onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface IconWrapperProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconWrapper = ({ children, className }: IconWrapperProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'flex items-center rounded-small justify-center w-7 h-7'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IconWrapper;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ChevronRightIcon } from '../icons';
|
||||
|
||||
const ItemCounter = ({ number }: { number: number }) => (
|
||||
<div className='flex items-center gap-1 text-default-400'>
|
||||
<span className='text-small'>{number}</span>
|
||||
<ChevronRightIcon className='text-xl' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ItemCounter;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getReleaseTime } from '@/utils/time';
|
||||
|
||||
import type { GithubRelease as GithubReleaseType } from '@/types/github';
|
||||
|
||||
export interface GithubReleaseProps {
|
||||
releaseData: GithubReleaseType
|
||||
}
|
||||
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
|
||||
const { releaseData } = props;
|
||||
const [releaseTime, setReleaseTime] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (releaseData) {
|
||||
const timer = setInterval(() => {
|
||||
const time = getReleaseTime(releaseData.published_at);
|
||||
|
||||
setReleaseTime(time);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [releaseData]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span>Releases</span>
|
||||
<div className='px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200'>
|
||||
<span className='text-tiny text-default-600'>{releaseData.name}</span>
|
||||
<div className='flex gap-2 text-tiny'>
|
||||
<span className='text-default-500'>{releaseTime}</span>
|
||||
<span className='text-success'>Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubRelease;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @stylistic/jsx-closing-bracket-location */
|
||||
/* eslint-disable @stylistic/jsx-closing-tag-location */
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdQuote } from 'react-icons/io';
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import PageLoading from './page_loading';
|
||||
|
||||
export default function Hitokoto () {
|
||||
const {
|
||||
data: dataOri,
|
||||
error,
|
||||
loading,
|
||||
run,
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hi.logacg.com/'), {
|
||||
throttleWait: 1000,
|
||||
});
|
||||
const backupData = {
|
||||
hitokoto: '凡是过往,皆为序章。',
|
||||
from: '暴风雨',
|
||||
from_who: '莎士比亚',
|
||||
};
|
||||
const data = dataOri?.data || (error ? backupData : undefined);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('复制成功');
|
||||
} catch (_error) {
|
||||
toast.error('复制失败, 请手动复制');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<div className='relative flex flex-col items-center justify-center p-4 md:p-6'>
|
||||
{loading && !data && <PageLoading />}
|
||||
{data && (
|
||||
<>
|
||||
<IoMdQuote className={clsx(
|
||||
'text-4xl mb-4',
|
||||
hasBackground ? 'text-white/30' : 'text-primary/20'
|
||||
)}
|
||||
/>
|
||||
<div className={clsx(
|
||||
'text-xl font-medium tracking-wide leading-relaxed italic',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}
|
||||
>
|
||||
" {data?.hitokoto} "
|
||||
</div>
|
||||
<div className='mt-4 flex flex-col items-center text-sm'>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
hasBackground ? 'text-white/90' : 'text-primary-500/80'
|
||||
)}
|
||||
>—— {data?.from}
|
||||
</span>
|
||||
{data?.from_who && <span className={clsx(
|
||||
'text-xs mt-1',
|
||||
hasBackground ? 'text-white/70' : 'text-default-400'
|
||||
)}
|
||||
> {data?.from_who}
|
||||
</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Tooltip content='刷新' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-primary'
|
||||
)}
|
||||
onPress={run}
|
||||
size='sm'
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='light'
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='复制' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-success'
|
||||
)}
|
||||
onPress={onCopy}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='light'
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { motion, useMotionValue, useSpring } from 'motion/react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const springValues = {
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
mass: 2,
|
||||
};
|
||||
|
||||
export interface HoverTiltedCardProps {
|
||||
imageSrc: string
|
||||
altText?: string
|
||||
captionText?: string
|
||||
containerHeight?: string
|
||||
containerWidth?: string
|
||||
imageHeight?: string
|
||||
imageWidth?: string
|
||||
scaleOnHover?: number
|
||||
rotateAmplitude?: number
|
||||
showTooltip?: boolean
|
||||
overlayContent?: React.ReactNode
|
||||
displayOverlayContent?: boolean
|
||||
}
|
||||
|
||||
export default function HoverTiltedCard ({
|
||||
imageSrc,
|
||||
altText = 'NapCat',
|
||||
captionText = 'NapCat',
|
||||
containerHeight = '200px',
|
||||
containerWidth = '100%',
|
||||
imageHeight = '200px',
|
||||
imageWidth = '200px',
|
||||
scaleOnHover = 1.1,
|
||||
rotateAmplitude = 14,
|
||||
showTooltip = false,
|
||||
overlayContent = (
|
||||
<div className='text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80'>
|
||||
NapCat
|
||||
</div>
|
||||
),
|
||||
displayOverlayContent = true,
|
||||
}: HoverTiltedCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const rotateX = useSpring(useMotionValue(0), springValues);
|
||||
const rotateY = useSpring(useMotionValue(0), springValues);
|
||||
const scale = useSpring(1, springValues);
|
||||
const opacity = useSpring(0);
|
||||
const rotateFigcaption = useSpring(0, {
|
||||
stiffness: 350,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
});
|
||||
|
||||
const [lastY, setLastY] = useState(0);
|
||||
|
||||
function handleMouse (e: React.MouseEvent) {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left - rect.width / 2;
|
||||
const offsetY = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
|
||||
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
|
||||
|
||||
rotateX.set(rotationX);
|
||||
rotateY.set(rotationY);
|
||||
|
||||
x.set(e.clientX - rect.left);
|
||||
y.set(e.clientY - rect.top);
|
||||
|
||||
const velocityY = offsetY - lastY;
|
||||
rotateFigcaption.set(-velocityY * 0.6);
|
||||
setLastY(offsetY);
|
||||
}
|
||||
|
||||
function handleMouseEnter () {
|
||||
scale.set(scaleOnHover);
|
||||
opacity.set(1);
|
||||
}
|
||||
|
||||
function handleMouseLeave () {
|
||||
opacity.set(0);
|
||||
scale.set(1);
|
||||
rotateX.set(0);
|
||||
rotateY.set(0);
|
||||
rotateFigcaption.set(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure
|
||||
ref={ref}
|
||||
className='relative w-full h-full [perspective:800px] flex flex-col items-center justify-center'
|
||||
style={{
|
||||
height: containerHeight,
|
||||
width: containerWidth,
|
||||
}}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<motion.div
|
||||
className='relative [transform-style:preserve-3d]'
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotateX,
|
||||
rotateY,
|
||||
scale,
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
src={imageSrc}
|
||||
alt={altText}
|
||||
className='absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none'
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
}}
|
||||
/>
|
||||
|
||||
{displayOverlayContent && overlayContent && (
|
||||
<motion.div className='absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]'>
|
||||
{overlayContent}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{showTooltip && (
|
||||
<motion.figcaption
|
||||
className='pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block'
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
opacity,
|
||||
rotate: rotateFigcaption,
|
||||
}}
|
||||
>
|
||||
{captionText}
|
||||
</motion.figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FileInput: React.FC<FileInputProps> = ({
|
||||
onChange,
|
||||
onDelete,
|
||||
label,
|
||||
accept,
|
||||
placeholder,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return (
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex-grow'>
|
||||
<Input
|
||||
isDisabled={isLoading}
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type='file'
|
||||
placeholder={placeholder || '选择文件'}
|
||||
accept={accept}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await onChange(file);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (onDelete) await onDelete();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export interface ImageInputProps {
|
||||
onChange: (base64: string) => void;
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='w-5 h-5 flex-shrink-0'>
|
||||
<Image
|
||||
src={value}
|
||||
alt={label}
|
||||
className='w-5 h-5 flex-shrink-0 rounded-none'
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type='file'
|
||||
placeholder='选择图片'
|
||||
accept='image/*'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string;
|
||||
onChange(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onChange('');
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageInput;
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { colorizeLogLevel } from '@/utils/terminal';
|
||||
|
||||
import PageLoading from '../page_loading';
|
||||
import XTerm from '../xterm';
|
||||
import type { XTermRef } from '../xterm';
|
||||
import LogLevelSelect from './log_level_select';
|
||||
|
||||
export interface HistoryLogsProps {
|
||||
list: string[];
|
||||
onSelect: (name: string) => void;
|
||||
selectedLog?: string;
|
||||
refreshList: () => void;
|
||||
refreshLog: () => void;
|
||||
listLoading?: boolean;
|
||||
logLoading?: boolean;
|
||||
listError?: Error;
|
||||
logContent?: string;
|
||||
}
|
||||
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const {
|
||||
list,
|
||||
onSelect,
|
||||
selectedLog,
|
||||
refreshList,
|
||||
refreshLog,
|
||||
listLoading,
|
||||
logContent,
|
||||
listError,
|
||||
logLoading,
|
||||
} = props;
|
||||
const Xterm = useRef<XTermRef>(null);
|
||||
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const logToColored = (log: string) => {
|
||||
const logs = log
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const colored = colorizeLogLevel(line);
|
||||
return colored;
|
||||
})
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => log.content)
|
||||
.join('\r\n');
|
||||
return logs;
|
||||
};
|
||||
|
||||
const onDownloadLog = () => {
|
||||
if (!logContent) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([logContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedLog}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!Xterm.current || !logContent) {
|
||||
return;
|
||||
}
|
||||
Xterm.current.clear();
|
||||
const _logContent = logToColored(logContent);
|
||||
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ');
|
||||
}, [logContent, logLevel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>历史日志 - NapCat WebUI</title>
|
||||
<Card className={clsx(
|
||||
'max-w-full h-full backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='flex-row justify-start gap-3'>
|
||||
<Select
|
||||
label='选择日志'
|
||||
size='sm'
|
||||
isLoading={listLoading}
|
||||
errorMessage={listError?.message}
|
||||
classNames={{
|
||||
trigger:
|
||||
'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
|
||||
}}
|
||||
placeholder='选择日志'
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onSelect(value);
|
||||
}}
|
||||
selectedKeys={[selectedLog || '']}
|
||||
items={list.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<div className='flex gap-2 ml-auto'>
|
||||
<Button className='flex-shrink-0' onPress={onDownloadLog} size='sm' variant='flat' color='primary'>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList} size='sm' variant='flat'>刷新列表</Button>
|
||||
<Button onPress={refreshLog} size='sm' variant='flat'>刷新日志</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className='relative'>
|
||||
<PageLoading loading={logLoading} />
|
||||
<XTerm className='w-full h-full' ref={Xterm} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryLogs;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
import { LogLevel } from '@/const/enum';
|
||||
|
||||
export interface LogLevelSelectProps {
|
||||
selectedKeys: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
}
|
||||
const logLevelColor: {
|
||||
[key in LogLevel]:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'primary'
|
||||
} = {
|
||||
[LogLevel.DEBUG]: 'default',
|
||||
[LogLevel.INFO]: 'primary',
|
||||
[LogLevel.WARN]: 'warning',
|
||||
[LogLevel.ERROR]: 'primary',
|
||||
[LogLevel.FATAL]: 'primary',
|
||||
};
|
||||
const LogLevelSelect = (props: LogLevelSelectProps) => {
|
||||
const { selectedKeys, onSelectionChange } = props;
|
||||
return (
|
||||
<Select
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(selectedKeys) => {
|
||||
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
|
||||
selectedKeys = 'all';
|
||||
}
|
||||
onSelectionChange(selectedKeys);
|
||||
}}
|
||||
label='日志级别'
|
||||
selectionMode='multiple'
|
||||
aria-label='Log Level'
|
||||
classNames={{
|
||||
label: 'mb-2',
|
||||
trigger: 'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
|
||||
popoverContent: 'bg-opacity-50 backdrop-blur-sm',
|
||||
}}
|
||||
size='sm'
|
||||
items={[
|
||||
{ label: 'Debug', value: LogLevel.DEBUG },
|
||||
{ label: 'Info', value: LogLevel.INFO },
|
||||
{ label: 'Warn', value: LogLevel.WARN },
|
||||
{ label: 'Error', value: LogLevel.ERROR },
|
||||
{ label: 'Fatal', value: LogLevel.FATAL },
|
||||
]}
|
||||
renderValue={(value) => {
|
||||
if (value.length === 5) {
|
||||
return (
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
全部
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
{value.map((v) => (
|
||||
<Chip
|
||||
size='sm'
|
||||
key={v.key}
|
||||
color={logLevelColor[v.data?.value as LogLevel]}
|
||||
variant='flat'
|
||||
>
|
||||
{v.data?.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogLevelSelect;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoDownloadOutline } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal';
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager';
|
||||
|
||||
import type { XTermRef } from '../xterm';
|
||||
import XTerm from '../xterm';
|
||||
import LogLevelSelect from './log_level_select';
|
||||
|
||||
const RealTimeLogs = () => {
|
||||
const Xterm = useRef<XTermRef>(null);
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
const [dataArr, setDataArr] = useState<Log[]>([]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n');
|
||||
const blob = new Blob([logContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'napcat.log';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const writeStream = () => {
|
||||
try {
|
||||
const _data = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true;
|
||||
}
|
||||
return logLevel.has(log.level);
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n');
|
||||
Xterm.current?.clear();
|
||||
Xterm.current?.write(_data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('获取实时日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
writeStream();
|
||||
}, [logLevel, dataArr]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data];
|
||||
if (newData.length > 1000) {
|
||||
newData.splice(0, newData.length - 1000);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
source.close();
|
||||
};
|
||||
} catch (_error) {
|
||||
toast.error('获取实时日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const close = subscribeLogs();
|
||||
return () => {
|
||||
console.log('close');
|
||||
close?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 p-2 rounded-2xl border backdrop-blur-sm transition-all shadow-sm mb-4',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10 border-white/40 dark:border-white/10' : 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}
|
||||
>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button
|
||||
className='flex-shrink-0'
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className='text-lg' />}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 h-full overflow-hidden'>
|
||||
<XTerm ref={Xterm} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeLogs;
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { IoMdFlash, IoMdCheckmark, IoMdClose } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import MirrorManager, { MirrorTestResult } from '@/controllers/mirror_manager';
|
||||
|
||||
interface MirrorSelectorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (mirror: string | undefined) => void;
|
||||
currentMirror?: string;
|
||||
type?: 'file' | 'raw';
|
||||
}
|
||||
|
||||
export default function MirrorSelectorModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentMirror,
|
||||
type = 'file',
|
||||
}: MirrorSelectorModalProps) {
|
||||
const [mirrors, setMirrors] = useState<string[]>([]);
|
||||
const [selectedMirror, setSelectedMirror] = useState<string>(currentMirror || 'auto');
|
||||
const [testResults, setTestResults] = useState<Map<string, MirrorTestResult>>(new Map());
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testProgress, setTestProgress] = useState(0);
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
const [fastestMirror, setFastestMirror] = useState<string | null>(null);
|
||||
|
||||
// 加载镜像列表
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadMirrors();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadMirrors = async () => {
|
||||
try {
|
||||
const data = await MirrorManager.getMirrorList();
|
||||
const mirrorList = type === 'raw' ? data.rawMirrors : data.fileMirrors;
|
||||
setMirrors(mirrorList);
|
||||
if (data.customMirror) {
|
||||
setSelectedMirror(data.customMirror);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load mirrors:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startSpeedTest = () => {
|
||||
setIsTesting(true);
|
||||
setTestProgress(0);
|
||||
setTestResults(new Map());
|
||||
setFastestMirror(null);
|
||||
setTestMessage('准备测速...');
|
||||
|
||||
MirrorManager.testMirrorsSSE(type, {
|
||||
onStart: (data) => {
|
||||
setTestMessage(data.message);
|
||||
},
|
||||
onTesting: (data) => {
|
||||
setTestProgress((data.index / data.total) * 100);
|
||||
setTestMessage(data.message);
|
||||
},
|
||||
onResult: (data) => {
|
||||
setTestResults((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(data.result.mirror, data.result);
|
||||
return newMap;
|
||||
});
|
||||
setTestProgress(((data.index + 1) / data.total) * 100);
|
||||
},
|
||||
onComplete: (data) => {
|
||||
setIsTesting(false);
|
||||
setTestProgress(100);
|
||||
setTestMessage(data.message);
|
||||
if (data.fastest) {
|
||||
setFastestMirror(data.fastest.mirror);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsTesting(false);
|
||||
setTestMessage(`测速失败: ${error}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const mirror = selectedMirror === 'auto' ? undefined : selectedMirror;
|
||||
onSelect(mirror);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='2xl'
|
||||
scrollBehavior='inside'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
<div className='flex items-center justify-between pr-8'>
|
||||
<span>选择镜像源</span>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
startContent={!isTesting && <IoMdFlash />}
|
||||
onPress={startSpeedTest}
|
||||
isLoading={isTesting}
|
||||
>
|
||||
{isTesting ? '测速中...' : '一键测速'}
|
||||
</Button>
|
||||
</div>
|
||||
{isTesting && (
|
||||
<div className='mt-2'>
|
||||
<div className='w-full bg-default-200 rounded-full h-2'>
|
||||
<div
|
||||
className='bg-primary h-2 rounded-full transition-all duration-300'
|
||||
style={{ width: `${testProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-default-500 mt-1'>{testMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* 自动选择选项 */}
|
||||
<MirrorOption
|
||||
value='auto'
|
||||
label='自动选择'
|
||||
description='系统自动选择最快的镜像源'
|
||||
isSelected={selectedMirror === 'auto'}
|
||||
onSelect={() => setSelectedMirror('auto')}
|
||||
badge={<Chip size='sm' color='primary' variant='flat'>推荐</Chip>}
|
||||
/>
|
||||
|
||||
{/* 原始 GitHub */}
|
||||
<MirrorOption
|
||||
value='https://github.com'
|
||||
label='GitHub 原始'
|
||||
description='直连 GitHub(可能较慢)'
|
||||
isSelected={selectedMirror === 'https://github.com'}
|
||||
onSelect={() => setSelectedMirror('https://github.com')}
|
||||
testResult={testResults.get('https://github.com (原始)')}
|
||||
isFastest={fastestMirror === 'https://github.com (原始)'}
|
||||
/>
|
||||
|
||||
{/* 镜像列表 */}
|
||||
{mirrors.map((mirror) => {
|
||||
if (!mirror) return null;
|
||||
const result = testResults.get(mirror);
|
||||
const isFastest = fastestMirror === mirror;
|
||||
|
||||
let hostname = mirror;
|
||||
try {
|
||||
hostname = new URL(mirror).hostname;
|
||||
} catch { }
|
||||
|
||||
return (
|
||||
<MirrorOption
|
||||
key={mirror}
|
||||
value={mirror}
|
||||
label={hostname}
|
||||
description={mirror}
|
||||
isSelected={selectedMirror === mirror}
|
||||
onSelect={() => setSelectedMirror(mirror)}
|
||||
testResult={result}
|
||||
isFastest={isFastest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={handleConfirm}>
|
||||
确认选择
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// 镜像选项组件
|
||||
interface MirrorOptionProps {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
testResult?: MirrorTestResult;
|
||||
isFastest?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
function MirrorOption ({
|
||||
label,
|
||||
description,
|
||||
isSelected,
|
||||
onSelect,
|
||||
testResult,
|
||||
isFastest,
|
||||
badge,
|
||||
}: MirrorOptionProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
isSelected ? 'border-primary' : 'border-transparent',
|
||||
isFastest && 'ring-2 ring-success'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<p className='font-medium'>{label}</p>
|
||||
<p className='text-xs text-default-500 truncate'>{description}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 ml-2'>
|
||||
{badge}
|
||||
{isFastest && !badge && (
|
||||
<Chip size='sm' color='success' variant='flat'>最快</Chip>
|
||||
)}
|
||||
{testResult && <MirrorStatus result={testResult} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 镜像状态显示组件
|
||||
function MirrorStatus ({ result }: { result: MirrorTestResult; }) {
|
||||
const formatLatency = (latency: number) => {
|
||||
if (latency >= 5000) return '>5s';
|
||||
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
|
||||
return `${latency}ms`;
|
||||
};
|
||||
|
||||
if (!result.success) {
|
||||
return (
|
||||
<Tooltip content={result.error || '连接失败'}>
|
||||
<Chip
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
startContent={<IoMdClose size={14} />}
|
||||
>
|
||||
失败
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const getColor = (): 'success' | 'warning' | 'danger' => {
|
||||
if (result.latency < 300) return 'success';
|
||||
if (result.latency < 1000) return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
color={getColor()}
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmark size={14} />}
|
||||
>
|
||||
{formatLatency(result.latency)}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Modal as NextUIModal,
|
||||
useDisclosure,
|
||||
} from '@heroui/modal';
|
||||
import React from 'react';
|
||||
|
||||
export interface ModalProps {
|
||||
content: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size'];
|
||||
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior'];
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent';
|
||||
showCancel?: boolean;
|
||||
dismissible?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
const {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
hideFooter = false,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
...rest
|
||||
} = props;
|
||||
const { onClose: onNativeClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<NextUIModal
|
||||
defaultOpen
|
||||
backdrop={backdrop}
|
||||
isDismissable={dismissible}
|
||||
onClose={() => {
|
||||
onClose?.();
|
||||
onNativeClose();
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99] backdrop-blur-sm',
|
||||
wrapper: 'z-[99]',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
<>
|
||||
{title && (
|
||||
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody className='break-all'>{content}</ModalBody>
|
||||
{!hideFooter && (
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='light'
|
||||
onPress={() => {
|
||||
onCancel?.();
|
||||
nativeClose();
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => {
|
||||
onConfirm?.();
|
||||
nativeClose();
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</NextUIModal>
|
||||
);
|
||||
});
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Listbox, ListboxItem } from '@heroui/listbox';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { MdError } from 'react-icons/md';
|
||||
|
||||
import IconWrapper from '@/components/github_info/icon_wrapper';
|
||||
import ItemCounter from '@/components/github_info/item_counter';
|
||||
import GithubRelease from '@/components/github_info/release';
|
||||
import {
|
||||
BookIcon,
|
||||
BugIcon,
|
||||
PullRequestIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
WatchersIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import { openUrl } from '@/utils/url';
|
||||
|
||||
import type {
|
||||
GirhubRepo,
|
||||
GithubContributor,
|
||||
GithubPullRequest,
|
||||
GithubRelease as GithubReleaseType,
|
||||
} from '@/types/github';
|
||||
|
||||
function displayData (data: number, loading: boolean, error?: Error) {
|
||||
if (error) {
|
||||
return <MdError className='text-primary-400' />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spinner size='sm' />;
|
||||
}
|
||||
|
||||
return <ItemCounter number={data} />;
|
||||
}
|
||||
|
||||
export default function NapCatRepoInfo () {
|
||||
// repo info
|
||||
const {
|
||||
data: repoOriData,
|
||||
error: repoError,
|
||||
loading: repoLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GirhubRepo>('https://api.github.com/repos/NapNeko/NapCatQQ')
|
||||
);
|
||||
|
||||
// release info
|
||||
const {
|
||||
data: releaseOriData,
|
||||
error: releaseError,
|
||||
loading: releaseLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubReleaseType[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
);
|
||||
|
||||
// pr info
|
||||
const {
|
||||
data: prData,
|
||||
error: prError,
|
||||
loading: prLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubPullRequest[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/pulls'
|
||||
)
|
||||
);
|
||||
|
||||
// contributors info
|
||||
const {
|
||||
data: contributorsData,
|
||||
error: contributorsError,
|
||||
loading: contributorsLoading,
|
||||
} = useRequest(() =>
|
||||
request.get<GithubContributor[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/contributors'
|
||||
)
|
||||
);
|
||||
|
||||
const repoData = repoOriData?.data;
|
||||
const releaseData = releaseOriData?.data?.[0];
|
||||
const prCount = prData?.data?.length || 0;
|
||||
const contributorsCount = contributorsData?.data?.length || 0;
|
||||
|
||||
const releaseCount = releaseOriData?.data?.length || 0;
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
aria-label='NapCat Repo Info'
|
||||
className='p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 max-w-[300px] overflow-visible shadow-small rounded-medium bg-opacity-50 backdrop-blur-sm'
|
||||
itemClasses={{
|
||||
base: 'px-3 first:rounded-t-medium last:rounded-b-medium rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80',
|
||||
}}
|
||||
onAction={(key: React.Key) => {
|
||||
switch (key) {
|
||||
case 'releases':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/releases', true);
|
||||
break;
|
||||
case 'contributors':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/graphs/contributors',
|
||||
true
|
||||
);
|
||||
break;
|
||||
case 'license':
|
||||
openUrl(
|
||||
'https://github.com/NapNeko/NapCatQQ/blob/main/LICENSE',
|
||||
true
|
||||
);
|
||||
break;
|
||||
case 'watchers':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/watchers', true);
|
||||
break;
|
||||
case 'star':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/stargazers', true);
|
||||
break;
|
||||
case 'issues':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/issues', true);
|
||||
break;
|
||||
case 'pull_requests':
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ/pulls', true);
|
||||
break;
|
||||
default:
|
||||
openUrl('https://github.com/NapNeko/NapCatQQ', true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListboxItem
|
||||
key='star'
|
||||
endContent={displayData(
|
||||
repoData?.stargazers_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-success/10 text-success'>
|
||||
<StarIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Star
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='issues'
|
||||
endContent={displayData(
|
||||
repoData?.open_issues_count ?? 0,
|
||||
false,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-success/10 text-success'>
|
||||
<BugIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Issues
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='pull_requests'
|
||||
endContent={displayData(prCount, prLoading, prError)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary'>
|
||||
<PullRequestIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Pull Requests
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='releases'
|
||||
className='group h-auto py-3'
|
||||
endContent={
|
||||
releaseError
|
||||
? (
|
||||
<MdError className='text-primary-400' />
|
||||
)
|
||||
: releaseLoading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<ItemCounter number={releaseCount} />
|
||||
)
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary'>
|
||||
<TagIcon className='text-lg' />
|
||||
</IconWrapper>
|
||||
}
|
||||
textValue='Releases'
|
||||
>
|
||||
{releaseData && <GithubRelease releaseData={releaseData} />}
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='contributors'
|
||||
endContent={displayData(
|
||||
contributorsCount,
|
||||
contributorsLoading,
|
||||
contributorsError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-warning/10 text-warning'>
|
||||
<UsersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Contributors
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='watchers'
|
||||
endContent={displayData(
|
||||
repoData?.watchers_count ?? 0,
|
||||
repoLoading,
|
||||
repoError
|
||||
)}
|
||||
startContent={
|
||||
<IconWrapper className='bg-default/50 text-foreground'>
|
||||
<WatchersIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
Watchers
|
||||
</ListboxItem>
|
||||
<ListboxItem
|
||||
key='license'
|
||||
endContent={
|
||||
<span className='text-small text-default-400'>
|
||||
{repoData?.license?.name ?? 'unknown'}
|
||||
</span>
|
||||
}
|
||||
startContent={
|
||||
<IconWrapper className='bg-primary/10 text-primary dark:text-primary-500'>
|
||||
<BookIcon />
|
||||
</IconWrapper>
|
||||
}
|
||||
>
|
||||
License
|
||||
</ListboxItem>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { ReactElement, useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import type {
|
||||
DefaultValues,
|
||||
Path,
|
||||
PathValue,
|
||||
SubmitHandler,
|
||||
} from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SwitchCard from '../switch_card';
|
||||
|
||||
export type FieldTypes = 'input' | 'select' | 'switch';
|
||||
|
||||
type NetworkConfigType = OneBotConfig['network'];
|
||||
|
||||
export interface Field<T extends keyof OneBotConfig['network']> {
|
||||
name: keyof NetworkConfigType[T][0];
|
||||
label: string;
|
||||
type: FieldTypes;
|
||||
options?: Array<{ key: string; value: string; }>;
|
||||
placeholder?: string;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
description?: string;
|
||||
colSpan?: 1 | 2;
|
||||
}
|
||||
|
||||
export interface GenericFormProps<T extends keyof NetworkConfigType> {
|
||||
data?: NetworkConfigType[T][0];
|
||||
defaultValues: DefaultValues<NetworkConfigType[T][0]>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>;
|
||||
fields: Array<Field<T>>;
|
||||
}
|
||||
|
||||
const GenericForm = <T extends keyof NetworkConfigType> ({
|
||||
data,
|
||||
defaultValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
fields,
|
||||
}: GenericFormProps<T>): ReactElement => {
|
||||
const { control, handleSubmit, formState, setValue, reset } = useForm<
|
||||
NetworkConfigType[T][0]
|
||||
>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitAction: SubmitHandler<NetworkConfigType[T][0]> = async (data) => {
|
||||
await onSubmit(data);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const _onSubmit = handleSubmit(submitAction, (e) => {
|
||||
const errors = Object.values(e);
|
||||
if (errors.length > 0) {
|
||||
toast.error(errors[0]?.message as string);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[];
|
||||
for (const key of keys) {
|
||||
const value = data[key] as PathValue<
|
||||
NetworkConfig[T][0],
|
||||
Path<NetworkConfig[T][0]>
|
||||
>;
|
||||
setValue(key, value);
|
||||
}
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [data, reset, setValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody>
|
||||
<div className='grid grid-cols-2 gap-y-4 gap-x-2 w-full'>
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.name as string}
|
||||
className={field.colSpan === 1 ? 'col-span-1' : 'col-span-2'}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.name as Path<NetworkConfig[T][0]>}
|
||||
rules={
|
||||
field.isRequired
|
||||
? {
|
||||
required: `请填写${field.label}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
render={({ field: controllerField }) => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={controllerField.value as string}
|
||||
onValueChange={(value) => controllerField.onChange(value)}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
isDisabled={field.isDisabled}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
{...controllerField}
|
||||
ref={controllerField.ref}
|
||||
isRequired={field.isRequired}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
selectedKeys={[controllerField.value as string]}
|
||||
value={controllerField.value.toString()}
|
||||
classNames={{
|
||||
trigger: 'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
value: 'text-default-700',
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.key} value={option.value}>
|
||||
{option.value}
|
||||
</SelectItem>
|
||||
)) || <></>}
|
||||
</Select>
|
||||
);
|
||||
case 'switch':
|
||||
return (
|
||||
<SwitchCard
|
||||
{...controllerField}
|
||||
value={controllerField.value as boolean}
|
||||
description={field.description}
|
||||
label={field.label}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color='primary'
|
||||
isDisabled={formState.isSubmitting}
|
||||
variant='light'
|
||||
onPress={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={formState.isSubmitting}
|
||||
onPress={() => _onSubmit()}
|
||||
>
|
||||
保存
|
||||
</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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPClientFormProps {
|
||||
data?: OneBotConfig['network']['httpClients'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpClients'][0]) => Promise<void>
|
||||
}
|
||||
|
||||
type HTTPClientFormType = OneBotConfig['network']['httpClients'];
|
||||
|
||||
const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'http://localhost:8080',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPClientForm;
|
||||
@@ -0,0 +1,110 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
data?: OneBotConfig['network']['httpServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
|
||||
|
||||
const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerForm;
|
||||
@@ -0,0 +1,120 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
data?: OneBotConfig['network']['httpSseServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
|
||||
|
||||
const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: HTTPServerSSEFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
|
||||
const fields: Field<'httpSseServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'enableCors',
|
||||
label: '启用CORS',
|
||||
type: 'switch',
|
||||
description: '是否启用CORS跨域',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableWebsocket',
|
||||
label: '启用Websocket',
|
||||
type: 'switch',
|
||||
description: '是否启用Websocket',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPServerSSEForm;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import HTTPClientForm from './http_client';
|
||||
import HTTPServerForm from './http_server';
|
||||
import HTTPServerSSEForm from './http_sse';
|
||||
import WebsocketClientForm from './ws_client';
|
||||
import WebsocketServerForm from './ws_server';
|
||||
|
||||
const modalTitle = {
|
||||
httpServers: 'HTTP Server',
|
||||
httpClients: 'HTTP Client',
|
||||
httpSseServers: 'HTTP SSE Server',
|
||||
websocketServers: 'Websocket Server',
|
||||
websocketClients: 'Websocket Client',
|
||||
};
|
||||
|
||||
export interface NetworkFormModalProps<
|
||||
T extends keyof OneBotConfig['network']
|
||||
> {
|
||||
isOpen: boolean;
|
||||
field: T;
|
||||
data?: OneBotConfig['network'][T][0];
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
|
||||
props: NetworkFormModalProps<T>
|
||||
) => {
|
||||
const { isOpen, onOpenChange, field, data } = props;
|
||||
const { createNetworkConfig, updateNetworkConfig } = useConfig();
|
||||
const dialog = useDialog();
|
||||
const isCreate = !data;
|
||||
|
||||
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
|
||||
const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, dataToSave);
|
||||
} else {
|
||||
await updateNetworkConfig(field, dataToSave);
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
|
||||
const serverData = data as any;
|
||||
if (!serverData.token && serverData.host !== '127.0.0.1') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '安全警告',
|
||||
content: (
|
||||
<div>
|
||||
<p>检测到未配置Token,这可能导致安全风险。确认要继续吗?</p>
|
||||
<p className='text-sm text-gray-500 mt-2'>(未配置Token时,Host将被强制限制为 127.0.0.1)</p>
|
||||
</div>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
serverData.host = '127.0.0.1';
|
||||
try {
|
||||
await saveData(serverData);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
reject(new Error('Cancelled'));
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveData(data);
|
||||
};
|
||||
|
||||
const renderFormComponent = (onClose: () => void) => {
|
||||
switch (field) {
|
||||
case 'httpServers':
|
||||
return (
|
||||
<HTTPServerForm
|
||||
data={data as OneBotConfig['network']['httpServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'httpClients':
|
||||
return (
|
||||
<HTTPClientForm
|
||||
data={data as OneBotConfig['network']['httpClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'websocketServers':
|
||||
return (
|
||||
<WebsocketServerForm
|
||||
data={data as OneBotConfig['network']['websocketServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'websocketClients':
|
||||
return (
|
||||
<WebsocketClientForm
|
||||
data={data as OneBotConfig['network']['websocketClients'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
case 'httpSseServers':
|
||||
return (
|
||||
<HTTPServerSSEForm
|
||||
data={data as OneBotConfig['network']['httpSseServers'][0]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backdrop='blur'
|
||||
isDismissable={false}
|
||||
isOpen={isOpen}
|
||||
size='lg'
|
||||
scrollBehavior='outside'
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
{modalTitle[field]}
|
||||
</ModalHeader>
|
||||
{renderFormComponent(onClose)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkFormModal;
|
||||
@@ -0,0 +1,123 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface WebsocketClientFormProps {
|
||||
data?: OneBotConfig['network']['websocketClients'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type WebsocketClientFormType = OneBotConfig['network']['websocketClients'];
|
||||
|
||||
const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: WebsocketClientFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
url: 'ws://localhost:8082',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
reconnectInterval: 30000,
|
||||
verifyCertificate: true,
|
||||
};
|
||||
|
||||
const fields: Field<'websocketClients'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'input',
|
||||
placeholder: '请输入URL',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'reconnectInterval',
|
||||
label: '重连间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入重连间隔',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'verifyCertificate',
|
||||
label: 'SSL证书验证',
|
||||
type: 'switch',
|
||||
description: '是否验证SSL证书(wss连接)。关闭后可连接自签名证书的服务器,但会降低安全性',
|
||||
colSpan: 2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketClientForm;
|
||||
@@ -0,0 +1,122 @@
|
||||
import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface WebsocketServerFormProps {
|
||||
data?: OneBotConfig['network']['websocketServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type WebsocketServerFormType = OneBotConfig['network']['websocketServers'];
|
||||
|
||||
const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const defaultValues: WebsocketServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '127.0.0.1',
|
||||
port: 3001,
|
||||
reportSelfMessage: false,
|
||||
enableForcePushEvent: true,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
};
|
||||
|
||||
const fields: Field<'websocketServers'>[] = [
|
||||
{
|
||||
name: 'enable',
|
||||
label: '启用',
|
||||
type: 'switch',
|
||||
description: '保存后启用此配置',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
label: '开启Debug',
|
||||
type: 'switch',
|
||||
description: '是否开启调试模式',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称',
|
||||
isRequired: true,
|
||||
isDisabled: !!data,
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Host',
|
||||
type: 'input',
|
||||
placeholder: '请输入主机地址',
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'Port',
|
||||
type: 'input',
|
||||
placeholder: '请输入端口',
|
||||
isRequired: true,
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'messagePostFormat',
|
||||
label: '消息格式',
|
||||
type: 'select',
|
||||
placeholder: '请选择消息格式',
|
||||
isRequired: true,
|
||||
options: [
|
||||
{ key: 'array', value: 'Array' },
|
||||
{ key: 'string', value: 'String' },
|
||||
],
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'reportSelfMessage',
|
||||
label: '上报自身消息',
|
||||
type: 'switch',
|
||||
description: '是否上报自身消息',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'enableForcePushEvent',
|
||||
label: '强制推送事件',
|
||||
type: 'switch',
|
||||
description: '是否强制推送事件',
|
||||
colSpan: 1,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
label: 'Token',
|
||||
type: 'input',
|
||||
placeholder: '请输入Token',
|
||||
},
|
||||
{
|
||||
name: 'heartInterval',
|
||||
label: '心跳间隔',
|
||||
type: 'input',
|
||||
placeholder: '请输入心跳间隔',
|
||||
isRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericForm
|
||||
data={data}
|
||||
defaultValues={defaultValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsocketServerForm;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface NewDeviceVerifyProps {
|
||||
/** jumpUrl from loginErrorInfo */
|
||||
jumpUrl: string;
|
||||
/** QQ uin for OIDB requests */
|
||||
uin: string;
|
||||
/** Called when QR verification is confirmed, passes str_nt_succ_token */
|
||||
onVerified: (token: string) => void;
|
||||
/** Called when user cancels */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error';
|
||||
|
||||
const NewDeviceVerify: React.FC<NewDeviceVerifyProps> = ({
|
||||
jumpUrl,
|
||||
uin,
|
||||
onVerified,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [qrUrl, setQrUrl] = useState<string>('');
|
||||
const [status, setStatus] = useState<QRStatus>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback((token: string) => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const result = await QQManager.pollNewDeviceQR(uin, token);
|
||||
if (!mountedRef.current) return;
|
||||
const s = result?.uint32_guarantee_status;
|
||||
if (s === 3) {
|
||||
setStatus('scanned');
|
||||
} else if (s === 1) {
|
||||
stopPolling();
|
||||
setStatus('confirmed');
|
||||
const ntToken = result?.str_nt_succ_token || '';
|
||||
onVerified(ntToken);
|
||||
}
|
||||
// s === 0 means still waiting, do nothing
|
||||
} catch {
|
||||
// Ignore poll errors, keep polling
|
||||
}
|
||||
}, 2500);
|
||||
}, [uin, onVerified, stopPolling]);
|
||||
|
||||
const fetchQRCode = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl);
|
||||
if (!mountedRef.current) return;
|
||||
if (result?.str_url) {
|
||||
setQrUrl(result.str_url);
|
||||
setStatus('waiting');
|
||||
// bytes_token 用于轮询,如果 OIDB 未返回则用空字符串
|
||||
startPolling(result.bytes_token || '');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMsg('获取二维码失败,请重试');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mountedRef.current) return;
|
||||
setStatus('error');
|
||||
setErrorMsg((e as Error).message || '获取二维码失败');
|
||||
}
|
||||
}, [uin, jumpUrl, startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchQRCode();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling();
|
||||
};
|
||||
}, [fetchQRCode, stopPolling]);
|
||||
|
||||
const statusText: Record<QRStatus, string> = {
|
||||
loading: '正在获取二维码...',
|
||||
waiting: '请使用手机QQ扫描二维码完成验证',
|
||||
scanned: '已扫描,请在手机上确认',
|
||||
confirmed: '验证成功,正在登录...',
|
||||
error: errorMsg || '获取二维码失败',
|
||||
};
|
||||
|
||||
const statusColor: Record<QRStatus, string> = {
|
||||
loading: 'text-default-500',
|
||||
waiting: 'text-warning',
|
||||
scanned: 'text-primary',
|
||||
confirmed: 'text-success',
|
||||
error: 'text-danger',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
<p className='text-warning text-sm'>
|
||||
检测到新设备登录,请使用手机QQ扫描下方二维码完成验证
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col items-center gap-3' style={{ minHeight: 280 }}>
|
||||
{status === 'loading'
|
||||
? (
|
||||
<div className='flex items-center justify-center' style={{ height: 240 }}>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)
|
||||
: status === 'error'
|
||||
? (
|
||||
<div className='flex flex-col items-center justify-center gap-3' style={{ height: 240 }}>
|
||||
<p className='text-danger text-sm'>{errorMsg}</p>
|
||||
<Button color='primary' variant='flat' onPress={fetchQRCode}>
|
||||
重新获取
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='p-3 bg-white rounded-lg'>
|
||||
<QRCodeSVG value={qrUrl} size={220} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={`text-sm ${statusColor[status]}`}>
|
||||
{statusText[status]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
{status === 'waiting' && (
|
||||
<Button color='default' variant='flat' size='sm' onPress={fetchQRCode}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewDeviceVerify;
|
||||
@@ -0,0 +1,477 @@
|
||||
import { Button } from '@heroui/button';
|
||||
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
|
||||
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 { request } from '@/utils/request';
|
||||
|
||||
import { BaseResponseSchema, parseTypeBox, generateDefaultFromTypeBox } from '@/utils/typebox';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
import DisplayStruct from './display_struct';
|
||||
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath;
|
||||
data: OneBotHttpApiContent;
|
||||
adapterName?: string;
|
||||
}
|
||||
|
||||
export interface OneBotApiDebugRef {
|
||||
setRequestBody: (value: string) => void;
|
||||
sendWithBody: (value: string) => void;
|
||||
focusRequestEditor: () => void;
|
||||
}
|
||||
|
||||
const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props, ref) => {
|
||||
const { path, data, adapterName } = props;
|
||||
const currentURL = new URL(window.location.origin);
|
||||
currentURL.port = '3000';
|
||||
const defaultHttpUrl = currentURL.href;
|
||||
const defaultToken = localStorage.getItem('token') || '';
|
||||
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
|
||||
url: defaultHttpUrl,
|
||||
token: defaultToken,
|
||||
});
|
||||
|
||||
const [requestBody, setRequestBody] = useState('{}');
|
||||
const [responseContent, setResponseContent] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<any>('request');
|
||||
const [responseExpanded, setResponseExpanded] = useState(true);
|
||||
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
|
||||
// Height Resizing Logic
|
||||
const [responseHeight, setResponseHeight] = useState(240);
|
||||
const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240);
|
||||
|
||||
// 使用 useMemo 缓存解析结果,避免每次渲染都重新解析
|
||||
const parsedRequest = useMemo(() => {
|
||||
try {
|
||||
return parseTypeBox(data?.payload);
|
||||
} catch (e) {
|
||||
console.error('Error parsing request schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.payload]);
|
||||
|
||||
// 将返回值的 data 结构包装进 BaseResponseSchema 进行展示
|
||||
// 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型
|
||||
const parsedResponse = useMemo(() => {
|
||||
try {
|
||||
const wrappedResponseSchema = Type.Object({
|
||||
...BaseResponseSchema.properties,
|
||||
data: data?.response || Type.Any({ description: '数据' }),
|
||||
});
|
||||
return parseTypeBox(wrappedResponseSchema);
|
||||
} catch (e) {
|
||||
console.error('Error parsing response schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.response]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const sendRequest = async (bodyOverride?: string) => {
|
||||
if (isFetching) return;
|
||||
setIsFetching(true);
|
||||
setResponseStatus(null);
|
||||
const r = toast.loading('正在发送请求...');
|
||||
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(bodyOverride ?? requestBody);
|
||||
|
||||
// 如果有 adapterName,走后端转发
|
||||
if (adapterName) {
|
||||
request.post(`/api/Debug/call/${adapterName}`, {
|
||||
action: path,
|
||||
params: parsedRequestBody,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
}).then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
setResponseContent(JSON.stringify(res.data.data, null, 2));
|
||||
setResponseStatus({ code: 200, text: 'OK' });
|
||||
} else {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: 500, text: res.data.message });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
}).catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
|
||||
setResponseStatus({ code: 500, text: 'Error' });
|
||||
setResponseExpanded(true);
|
||||
}).finally(() => {
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑 (直接请求)
|
||||
const requestURL = new URL(httpConfig.url);
|
||||
requestURL.pathname = path;
|
||||
request
|
||||
.post(requestURL.href, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`,
|
||||
},
|
||||
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON
|
||||
.then((res) => {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: res.status, text: res.statusText });
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2));
|
||||
if (err.response) {
|
||||
setResponseStatus({ code: err.response.status, text: err.response.statusText });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
});
|
||||
} catch (_error) {
|
||||
toast.error('请求体 JSON 格式错误');
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setRequestBody: (value: string) => {
|
||||
setActiveTab('request');
|
||||
setRequestBody(value);
|
||||
},
|
||||
sendWithBody: (value: string) => {
|
||||
setActiveTab('request');
|
||||
setRequestBody(value);
|
||||
// 直接用 override 发送,避免 setState 异步导致拿到旧值
|
||||
sendRequest(value);
|
||||
},
|
||||
focusRequestEditor: () => {
|
||||
setActiveTab('request');
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payloadExample) {
|
||||
setRequestBody(JSON.stringify(data.payloadExample, null, 2));
|
||||
} else {
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
setRequestBody('{}');
|
||||
}
|
||||
}
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
}, [path]);
|
||||
|
||||
// Sync from storage on mount
|
||||
useEffect(() => {
|
||||
setResponseHeight(storedHeight);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = responseHeight;
|
||||
let currentH = startHeight;
|
||||
let frameId: number;
|
||||
|
||||
const handleMouseMove = (mv: MouseEvent) => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const delta = startY - mv.clientY;
|
||||
currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta));
|
||||
setResponseHeight(currentH);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
setStoredHeight(currentH);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [responseHeight, setStoredHeight]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
const startY = touch.clientY;
|
||||
const startHeight = responseHeight;
|
||||
let currentH = startHeight;
|
||||
let frameId: number;
|
||||
|
||||
const handleTouchMove = (mv: TouchEvent) => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const mvTouch = mv.touches[0];
|
||||
const delta = startY - mvTouch.clientY;
|
||||
currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta));
|
||||
setResponseHeight(currentH);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
setStoredHeight(currentH);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}, [responseHeight, setStoredHeight]);
|
||||
|
||||
return (
|
||||
|
||||
<div className='flex flex-col h-full w-full relative overflow-hidden'>
|
||||
{/* 1. Top Toolbar: URL & Actions */}
|
||||
<div className={clsx(
|
||||
'flex items-center gap-4 px-4 py-2 border-b flex-shrink-0 z-10',
|
||||
hasBackground ? 'border-white/10 bg-white/5' : 'border-black/5 dark:border-white/10 bg-white/40 dark:bg-black/20'
|
||||
)}
|
||||
>
|
||||
{/* Method & Path */}
|
||||
{/* Method & Path */}
|
||||
{/* Method & Path */}
|
||||
<div className='flex items-center gap-3 flex-1 min-w-0 pl-1'>
|
||||
<div className={clsx(
|
||||
'text-sm font-mono truncate select-all px-2 py-1 rounded-md transition-colors',
|
||||
hasBackground ? 'text-white/90 bg-black/10' : 'text-foreground dark:text-white/90 bg-default-100/50'
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Popover placement='bottom-end' backdrop='transparent'>
|
||||
<PopoverTrigger>
|
||||
<Button size='sm' variant='light' radius='sm' isIconOnly className='opacity-60 hover:opacity-100'>
|
||||
<IoSettingsSharp className='text-lg' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[260px] p-3 rounded-md border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p>
|
||||
<Input label='Base URL' labelPlacement='outside' placeholder='http://...' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='bordered' />
|
||||
<Input label='Token' labelPlacement='outside' placeholder='access_token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='bordered' />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
onPress={() => sendRequest()}
|
||||
color='primary'
|
||||
radius='sm'
|
||||
size='sm'
|
||||
className='font-bold shadow-sm px-4'
|
||||
isLoading={isFetching}
|
||||
startContent={!isFetching && <IoSend className='text-xs' />}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Main Workspace (Request) - Flexible Height */}
|
||||
<div className='flex-1 min-h-0 flex flex-col relative'>
|
||||
<div className='flex-1 flex flex-col overflow-hidden relative'>
|
||||
{/* Request Toolbar */}
|
||||
<div className={clsx(
|
||||
'px-4 flex items-center justify-between h-10 flex-shrink-0 border-b',
|
||||
hasBackground ? 'border-white/10' : 'border-default-100 dark:border-white/10'
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
aria-label='Request Options'
|
||||
size='sm'
|
||||
variant='underlined'
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={setActiveTab}
|
||||
classNames={{
|
||||
tabList: 'p-0 gap-6 bg-transparent',
|
||||
cursor: 'w-full bg-foreground dark:bg-white h-[2px]',
|
||||
tab: 'px-0 h-full',
|
||||
tabContent: 'text-xs font-medium text-default-500 dark:text-white/50 group-data-[selected=true]:text-foreground dark:group-data-[selected=true]:text-white',
|
||||
}}
|
||||
>
|
||||
<Tab key='request' title='请求体' />
|
||||
<Tab key='docs' title='接口文档' />
|
||||
</Tabs>
|
||||
|
||||
<div className='flex items-center gap-1 opacity-70'>
|
||||
<ChatInputModal>
|
||||
{(onOpen) => (
|
||||
<Tooltip content='构造 CQ 码' closeDelay={0}>
|
||||
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={onOpen}>
|
||||
<TbMessageCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ChatInputModal>
|
||||
<Tooltip content='生成示例' closeDelay={0}>
|
||||
<Button
|
||||
isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => {
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
toast.error('生成示例失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 relative overflow-hidden'>
|
||||
{activeTab === 'request'
|
||||
? (
|
||||
<div className='absolute inset-0'>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 16, bottom: 16 },
|
||||
lineNumbersMinChars: 3,
|
||||
chromeless: true,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='p-6 space-y-8 overflow-y-auto h-full scrollbar-hide'>
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold text-default-700 dark:text-default-50 uppercase tracking-widest mb-4'>Request Params</h3>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
</section>
|
||||
<div className='h-px bg-white/10 w-full' />
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold text-default-700 dark:text-default-50 uppercase tracking-widest mb-4'>Response Data</h3>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Response Panel (Bottom) */}
|
||||
<div
|
||||
className='flex-shrink-0 flex flex-col overflow-hidden relative'
|
||||
style={{ height: responseExpanded ? undefined : 'auto' }}
|
||||
>
|
||||
{/* Resize Handle / Header */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-1.5 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors select-none group relative border-t',
|
||||
hasBackground ? 'border-white/10' : 'border-default-100 dark:border-white/10'
|
||||
)}
|
||||
onClick={() => setResponseExpanded(!responseExpanded)}
|
||||
>
|
||||
{/* Invisible Draggable Area */}
|
||||
{responseExpanded && (
|
||||
<div
|
||||
className='absolute -top-1.5 left-0 w-full h-4 cursor-ns-resize z-20'
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className={clsx('transition-transform duration-200', !responseExpanded && '-rotate-90')}>
|
||||
<IoChevronDown size={14} className='opacity-50' />
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-bold tracking-widest uppercase',
|
||||
hasBackground ? 'text-white' : 'text-foreground dark:text-white'
|
||||
)}
|
||||
>Response
|
||||
</span>
|
||||
{responseStatus && (
|
||||
<Chip size='sm' variant='dot' color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className='h-5 text-[10px] font-mono border-none bg-transparent pl-0'>
|
||||
{responseStatus.code} {responseStatus.text}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size='sm' variant='light' isIconOnly radius='sm' className='h-6 w-6 opacity-40 hover:opacity-100' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}>
|
||||
<IoCopy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Response Editor */}
|
||||
{responseExpanded && (
|
||||
<div style={{ height: responseHeight }} className='relative bg-transparent'>
|
||||
<PageLoading loading={isFetching} />
|
||||
<div className='absolute inset-0'>
|
||||
<CodeEditor
|
||||
value={responseContent || '// Waiting for response...'}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineNumbers: 'off',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
folding: true,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
renderLineHighlight: 'none',
|
||||
chromeless: true,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default OneBotApiDebug;
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { motion } from 'motion/react';
|
||||
import React, { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb';
|
||||
|
||||
import type { LiteralValue, ParsedSchema } from '@/utils/zod';
|
||||
|
||||
interface DisplayStructProps {
|
||||
schema: ParsedSchema | ParsedSchema[];
|
||||
}
|
||||
|
||||
const SchemaType = ({
|
||||
type,
|
||||
value,
|
||||
}: {
|
||||
type: string;
|
||||
value?: LiteralValue;
|
||||
}) => {
|
||||
let name = type;
|
||||
switch (type) {
|
||||
case 'union':
|
||||
name = '联合类型';
|
||||
break;
|
||||
case 'value':
|
||||
name = '固定值';
|
||||
break;
|
||||
}
|
||||
let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' =
|
||||
'primary';
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
chipColor = 'warning';
|
||||
break;
|
||||
case 'union':
|
||||
chipColor = 'secondary';
|
||||
break;
|
||||
case 'array':
|
||||
chipColor = 'primary';
|
||||
break;
|
||||
case 'object':
|
||||
chipColor = 'success';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip size='sm' color={chipColor} variant='flat'>
|
||||
{name}
|
||||
{type === 'value' && (
|
||||
<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} />
|
||||
)}
|
||||
{schema.optional && (
|
||||
<Chip size='sm' color='default' variant='flat'>
|
||||
可选
|
||||
</Chip>
|
||||
)}
|
||||
{schema.description && (
|
||||
<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 toggleExpand = () => setExpanded(!expanded);
|
||||
|
||||
return (
|
||||
<div className='mb-2'>
|
||||
<div
|
||||
onClick={toggleExpand}
|
||||
className='md:cursor-pointer flex items-center gap-1'
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
>
|
||||
<TbSquareRoundedChevronRightFilled />
|
||||
</motion.div>
|
||||
<Tooltip content='点击复制' placement='top' showArrow>
|
||||
<span
|
||||
className='border-b border-transparent border-dashed hover:border-primary-400'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(schema.name || '');
|
||||
toast.success('已复制');
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
<motion.div
|
||||
className='ml-5 overflow-hidden'
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: expanded ? 1 : 0, height: expanded ? 'auto' : 0 }}
|
||||
>
|
||||
<div className='h-2' />
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
// 处理循环引用和截断的情况,直接显示提示而不继续递归
|
||||
if (schema.isCircularRef || schema.isTruncated) {
|
||||
return (
|
||||
<div className='mb-2 flex items-center gap-1 pl-5'>
|
||||
{schema.name && (
|
||||
<span className='text-default-400'>{schema.name}</span>
|
||||
)}
|
||||
<Chip size='sm' color='default' variant='flat'>
|
||||
{schema.description || '...'}
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</SchemaContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === 'array' || schema.type === 'union') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children?.map((child, i) => (
|
||||
<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'>
|
||||
{schema.enum?.map((value, i) => (
|
||||
<Chip
|
||||
key={value?.toString() || i}
|
||||
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>
|
||||
<span
|
||||
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('已复制');
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
|
||||
return (
|
||||
<div className=''>
|
||||
{Array.isArray(schema)
|
||||
? (
|
||||
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
|
||||
)
|
||||
: (
|
||||
<RenderSchema schema={schema} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplayStruct;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { isOB11GroupMessage } from '@/utils/onebot';
|
||||
|
||||
import type {
|
||||
OB11GroupMessage,
|
||||
OB11Message,
|
||||
OB11PrivateMessage,
|
||||
} from '@/types/onebot';
|
||||
|
||||
import { renderMessageContent } from '../render_message';
|
||||
|
||||
export interface OneBotMessageProps {
|
||||
data: OB11Message;
|
||||
}
|
||||
|
||||
export interface OneBotMessageGroupProps {
|
||||
data: OB11GroupMessage;
|
||||
}
|
||||
|
||||
export interface OneBotMessagePrivateProps {
|
||||
data: OB11PrivateMessage;
|
||||
}
|
||||
|
||||
const MessageContent: React.FC<{ data: OB11Message; }> = ({ data }) => {
|
||||
return (
|
||||
<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={clsx(
|
||||
isOB11GroupMessage(data) &&
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
)}
|
||||
>
|
||||
{data.sender.nickname}
|
||||
</span>
|
||||
</div>
|
||||
<div>({data.sender.user_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>
|
||||
{Array.isArray(data.message)
|
||||
? renderMessageContent(data.message, true)
|
||||
: data.raw_message}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className='p-2'>
|
||||
{Array.isArray(data.message)
|
||||
? renderMessageContent(data.message)
|
||||
: data.raw_message}
|
||||
</div>
|
||||
</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'>
|
||||
<Avatar
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/0/`}
|
||||
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'>
|
||||
<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'
|
||||
/>
|
||||
<MessageContent data={data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OneBotMessagePrivate: React.FC<OneBotMessagePrivateProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
return (
|
||||
<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'
|
||||
/>
|
||||
<MessageContent data={data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OneBotMessage: React.FC<OneBotMessageProps> = ({ data }) => {
|
||||
if (data.message_type === 'group') {
|
||||
return <OneBotMessageGroup data={data} />;
|
||||
} else if (data.message_type === 'private') {
|
||||
return <OneBotMessagePrivate data={data} />;
|
||||
} else {
|
||||
return <div>未知消息类型</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default OneBotMessage;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { getLifecycleColor, getLifecycleName } from '@/utils/onebot';
|
||||
|
||||
import type {
|
||||
OB11Meta,
|
||||
OneBot11Heartbeat,
|
||||
OneBot11Lifecycle,
|
||||
} from '@/types/onebot';
|
||||
|
||||
export interface OneBotDisplayMetaProps {
|
||||
data: OB11Meta
|
||||
}
|
||||
|
||||
export interface OneBotDisplayMetaHeartbeatProps {
|
||||
data: OneBot11Heartbeat
|
||||
}
|
||||
|
||||
export interface OneBotDisplayMetaLifecycleProps {
|
||||
data: OneBot11Lifecycle
|
||||
}
|
||||
|
||||
const OneBotDisplayMetaHeartbeat: React.FC<OneBotDisplayMetaHeartbeatProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Chip>心跳</Chip>
|
||||
<Chip>间隔 {data.status.interval}ms</Chip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OneBotDisplayMetaLifecycle: React.FC<OneBotDisplayMetaLifecycleProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
return (
|
||||
<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'>
|
||||
{data.meta_event_type === 'lifecycle' && (
|
||||
<OneBotDisplayMetaLifecycle data={data} />
|
||||
)}
|
||||
{data.meta_event_type === 'heartbeat' && (
|
||||
<OneBotDisplayMetaHeartbeat data={data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotDisplayMeta;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { getNoticeTypeName } from '@/utils/onebot';
|
||||
|
||||
import {
|
||||
OB11Notice,
|
||||
OB11NoticeType,
|
||||
OneBot11FriendAdd,
|
||||
OneBot11FriendRecall,
|
||||
OneBot11GroupAdmin,
|
||||
OneBot11GroupBan,
|
||||
OneBot11GroupCard,
|
||||
OneBot11GroupDecrease,
|
||||
OneBot11GroupEssence,
|
||||
OneBot11GroupIncrease,
|
||||
OneBot11GroupMessageReaction,
|
||||
OneBot11GroupRecall,
|
||||
OneBot11GroupUpload,
|
||||
OneBot11Honor,
|
||||
OneBot11LuckyKing,
|
||||
OneBot11Poke,
|
||||
} from '@/types/onebot';
|
||||
|
||||
export interface OneBotNoticeProps {
|
||||
data: OB11Notice
|
||||
}
|
||||
|
||||
export interface NoticeProps<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
const GroupUploadNotice: React.FC<NoticeProps<OneBot11GroupUpload>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { group_id, user_id, file } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>文件名: {file.name}</div>
|
||||
<div>文件大小: {file.size} 字节</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupAdminNotice: React.FC<NoticeProps<OneBot11GroupAdmin>> = ({
|
||||
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,
|
||||
}) => {
|
||||
const { group_id, operator_id, user_id, sub_type } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>原因: {sub_type}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupIncreaseNotice: React.FC<NoticeProps<OneBot11GroupIncrease>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { group_id, operator_id, user_id, sub_type } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<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;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>禁言类型: {sub_type}</div>
|
||||
<div>禁言时长: {duration} 秒</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FriendAddNotice: React.FC<NoticeProps<OneBot11FriendAdd>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { user_id } = data;
|
||||
return (
|
||||
<>
|
||||
<div>用户ID: {user_id}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupRecallNotice: React.FC<NoticeProps<OneBot11GroupRecall>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { group_id, user_id, operator_id, message_id } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FriendRecallNotice: React.FC<NoticeProps<OneBot11FriendRecall>> = ({
|
||||
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;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>目标ID: {target_id}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LuckyKingNotice: React.FC<NoticeProps<OneBot11LuckyKing>> = ({
|
||||
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;
|
||||
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;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
<div>
|
||||
表情回应:
|
||||
{likes
|
||||
.map((like) => `表情ID: ${like.emoji_id}, 数量: ${like.count}`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupEssenceNotice: React.FC<NoticeProps<OneBot11GroupEssence>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { group_id, message_id, sender_id, operator_id, sub_type } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>消息ID: {message_id}</div>
|
||||
<div>发送者ID: {sender_id}</div>
|
||||
<div>操作者ID: {operator_id}</div>
|
||||
<div>操作类型: {sub_type}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupCardNotice: React.FC<NoticeProps<OneBot11GroupCard>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { group_id, user_id, card_new, card_old } = data;
|
||||
return (
|
||||
<>
|
||||
<div>群号: {group_id}</div>
|
||||
<div>用户ID: {user_id}</div>
|
||||
<div>新名片: {card_new}</div>
|
||||
<div>旧名片: {card_old}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OneBotNotice: React.FC<OneBotNoticeProps> = ({ data }) => {
|
||||
let NoticeComponent: React.ReactNode;
|
||||
switch (data.notice_type) {
|
||||
case OB11NoticeType.GroupUpload:
|
||||
NoticeComponent = <GroupUploadNotice data={data} />;
|
||||
break;
|
||||
case OB11NoticeType.GroupAdmin:
|
||||
NoticeComponent = <GroupAdminNotice data={data} />;
|
||||
break;
|
||||
case OB11NoticeType.GroupDecrease:
|
||||
NoticeComponent = <GroupDecreaseNotice data={data} />;
|
||||
break;
|
||||
case OB11NoticeType.GroupIncrease:
|
||||
NoticeComponent = (
|
||||
<GroupIncreaseNotice data={data as OneBot11GroupIncrease} />
|
||||
);
|
||||
break;
|
||||
case OB11NoticeType.GroupBan:
|
||||
NoticeComponent = <GroupBanNotice data={data} />;
|
||||
break;
|
||||
case OB11NoticeType.FriendAdd:
|
||||
NoticeComponent = <FriendAddNotice data={data as OneBot11FriendAdd} />;
|
||||
break;
|
||||
case OB11NoticeType.GroupRecall:
|
||||
NoticeComponent = <GroupRecallNotice data={data as OneBot11GroupRecall} />;
|
||||
break;
|
||||
case OB11NoticeType.FriendRecall:
|
||||
NoticeComponent = (
|
||||
<FriendRecallNotice data={data as OneBot11FriendRecall} />
|
||||
);
|
||||
break;
|
||||
case OB11NoticeType.Notify:
|
||||
switch (data.sub_type) {
|
||||
case 'poke':
|
||||
NoticeComponent = <PokeNotice data={data as OneBot11Poke} />;
|
||||
break;
|
||||
case 'lucky_king':
|
||||
NoticeComponent = <LuckyKingNotice data={data as OneBot11LuckyKing} />;
|
||||
break;
|
||||
case 'honor':
|
||||
NoticeComponent = <HonorNotice data={data as OneBot11Honor} />;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case OB11NoticeType.GroupMsgEmojiLike:
|
||||
NoticeComponent = (
|
||||
<GroupMessageReactionNotice
|
||||
data={data as OneBot11GroupMessageReaction}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case OB11NoticeType.GroupEssence:
|
||||
NoticeComponent = (
|
||||
<GroupEssenceNotice data={data as OneBot11GroupEssence} />
|
||||
);
|
||||
break;
|
||||
case OB11NoticeType.GroupCard:
|
||||
NoticeComponent = <GroupCardNotice data={data as OneBot11GroupCard} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Chip color='warning' variant='flat'>
|
||||
通知
|
||||
</Chip>
|
||||
<Chip>{getNoticeTypeName(data.notice_type)}</Chip>
|
||||
{NoticeComponent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotNotice;
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
import { motion } from 'motion/react';
|
||||
import { IoCode } from 'react-icons/io5';
|
||||
|
||||
import OneBotDisplayMeta from '@/components/onebot/display_card/meta';
|
||||
|
||||
import { getEventName, isOB11Event } from '@/utils/onebot';
|
||||
import { timestampToDateString } from '@/utils/time';
|
||||
|
||||
import type {
|
||||
AllOB11WsResponse,
|
||||
OB11AllEvent,
|
||||
OB11Request,
|
||||
} from '@/types/onebot';
|
||||
|
||||
import OneBotMessage from './message';
|
||||
import OneBotNotice from './notice';
|
||||
import OneBotDisplayResponse from './response';
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, scale: 0.8, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 20 },
|
||||
},
|
||||
};
|
||||
|
||||
function RequestComponent ({ data: _ }: { data: OB11Request; }) {
|
||||
return <div>Request消息,暂未适配</div>;
|
||||
}
|
||||
|
||||
export interface OneBotItemRenderProps {
|
||||
data: AllOB11WsResponse[];
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const getItemSize = (event: OB11AllEvent['post_type']) => {
|
||||
if (event === 'meta_event') {
|
||||
return 100;
|
||||
}
|
||||
if (event === 'message') {
|
||||
return 180;
|
||||
}
|
||||
if (event === 'request') {
|
||||
return 100;
|
||||
}
|
||||
if (event === 'notice') {
|
||||
return 100;
|
||||
}
|
||||
if (event === 'message_sent') {
|
||||
return 250;
|
||||
}
|
||||
return 100;
|
||||
};
|
||||
|
||||
const renderDetail = (data: AllOB11WsResponse) => {
|
||||
if (isOB11Event(data)) {
|
||||
switch (data.post_type) {
|
||||
case 'meta_event':
|
||||
return <OneBotDisplayMeta data={data} />;
|
||||
case 'message':
|
||||
return <OneBotMessage data={data} />;
|
||||
case 'request':
|
||||
return <RequestComponent data={data} />;
|
||||
case 'notice':
|
||||
return <OneBotNotice data={data} />;
|
||||
case 'message_sent':
|
||||
return <OneBotMessage data={data} />;
|
||||
default:
|
||||
return <div>未知类型的消息</div>;
|
||||
}
|
||||
}
|
||||
return <OneBotDisplayResponse data={data} />;
|
||||
};
|
||||
|
||||
const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
||||
const msg = data[index];
|
||||
const isEvent = isOB11Event(msg);
|
||||
return (
|
||||
<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'
|
||||
>
|
||||
<Card className='w-full h-full py-2 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-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'>
|
||||
{isEvent && timestampToDateString(msg.time)}
|
||||
</div>
|
||||
<div className='ml-auto'>
|
||||
<Popover
|
||||
placement='left'
|
||||
showArrow
|
||||
classNames={{
|
||||
content: 'max-h-96 max-w-96 overflow-hidden p-0',
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
className='text-medium'
|
||||
>
|
||||
<IoCode />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(msg, null, 2)}
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0',
|
||||
}}
|
||||
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'>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className='py-0'>{renderDetail(msg)}</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotItemRender;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
|
||||
import { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot';
|
||||
|
||||
import { RequestResponse } from '@/types/onebot';
|
||||
|
||||
export interface OneBotDisplayResponseProps {
|
||||
data: RequestResponse
|
||||
}
|
||||
|
||||
const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Chip color={getResponseStatusColor(data.status)} variant='flat'>
|
||||
{getResponseStatusText(data.status)}
|
||||
</Chip>
|
||||
{data.data && (
|
||||
<Popover
|
||||
placement='right'
|
||||
showArrow
|
||||
classNames={{
|
||||
content: 'max-h-96 max-w-96 overflow-hidden p-0',
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
className='text-medium'
|
||||
>
|
||||
查看数据
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
codeString={JSON.stringify(data.data, null, 2)}
|
||||
tooltipProps={{
|
||||
content: '点击复制',
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0',
|
||||
}}
|
||||
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'>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{data.message && (
|
||||
<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;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
export interface FilterMessageTypeProps {
|
||||
filterTypes: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
}
|
||||
const items = [
|
||||
{ label: '元事件', value: 'meta_event' },
|
||||
{ label: '消息', value: 'message' },
|
||||
{ label: '请求', value: 'request' },
|
||||
{ label: '通知', value: 'notice' },
|
||||
{ label: '消息发送', value: 'message_sent' },
|
||||
];
|
||||
const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
|
||||
const { filterTypes, onSelectionChange } = props;
|
||||
return (
|
||||
<Select
|
||||
selectedKeys={filterTypes}
|
||||
onSelectionChange={(selectedKeys) => {
|
||||
if (selectedKeys !== 'all' && selectedKeys?.size === 0) {
|
||||
selectedKeys = 'all';
|
||||
}
|
||||
onSelectionChange(selectedKeys);
|
||||
}}
|
||||
label='筛选消息类型'
|
||||
selectionMode='multiple'
|
||||
className='w-full'
|
||||
items={items}
|
||||
renderValue={(value) => {
|
||||
if (value.length === items.length) {
|
||||
return '全部';
|
||||
}
|
||||
return value.map((v) => v.data?.label).join(',');
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderFilterMessageType = (
|
||||
filterTypes: Selection,
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
) => {
|
||||
return (
|
||||
<FilterMessageType
|
||||
filterTypes={filterTypes}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterMessageType;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
import OneBotItemRender, {
|
||||
getItemSize,
|
||||
} from '@/components/onebot/display_card/render';
|
||||
|
||||
import { isOB11Event } from '@/utils/onebot';
|
||||
|
||||
import type { AllOB11WsResponse } from '@/types/onebot';
|
||||
|
||||
export interface OneBotMessageListProps {
|
||||
messages: AllOB11WsResponse[]
|
||||
}
|
||||
|
||||
const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0, true);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full overflow-hidden' ref={containerRef}>
|
||||
<VariableSizeList
|
||||
ref={listRef}
|
||||
itemCount={messages.length}
|
||||
width='100%'
|
||||
style={{
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
itemSize={(idx) => {
|
||||
const msg = messages[idx];
|
||||
if (isOB11Event(msg)) {
|
||||
const size = getItemSize(msg.post_type);
|
||||
return size;
|
||||
} else {
|
||||
return 100;
|
||||
}
|
||||
}}
|
||||
height={containerHeight}
|
||||
itemData={messages}
|
||||
itemKey={(index) => messages.length - index - 1}
|
||||
>
|
||||
{OneBotItemRender}
|
||||
</VariableSizeList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotMessageList;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Image } from '@heroui/image';
|
||||
import qface from 'qface';
|
||||
import { FaReply } from 'react-icons/fa6';
|
||||
|
||||
import { OB11Segment } from '@/types/onebot';
|
||||
|
||||
export const renderMessageContent = (
|
||||
segments: OB11Segment[],
|
||||
small = false
|
||||
): React.ReactElement[] => {
|
||||
return segments.map((segment, index) => {
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return <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',
|
||||
}}
|
||||
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',
|
||||
}}
|
||||
radius='sm'
|
||||
className={
|
||||
small
|
||||
? 'max-h-16 object-cover'
|
||||
: 'max-w-64 max-h-96 h-auto object-cover'
|
||||
}
|
||||
key={index}
|
||||
src={segment.data.url || segment.data.file}
|
||||
alt='image'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
);
|
||||
case 'record':
|
||||
return (
|
||||
<audio
|
||||
key={index}
|
||||
controls
|
||||
src={segment.data.url || segment.data.file}
|
||||
/>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
key={index}
|
||||
controls
|
||||
src={segment.data.url || segment.data.file}
|
||||
/>
|
||||
);
|
||||
case 'at':
|
||||
return (
|
||||
<span key={index} className='text-blue-500'>
|
||||
@
|
||||
{segment.data.qq === 'all'
|
||||
? (
|
||||
'所有人'
|
||||
)
|
||||
: (
|
||||
<span>
|
||||
{segment.data.name}({segment.data.qq})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
case 'rps':
|
||||
return <span key={index}>[猜拳]</span>;
|
||||
case 'dice':
|
||||
return <span key={index}>[掷骰子]</span>;
|
||||
case 'shake':
|
||||
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>;
|
||||
case 'share':
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={segment.data.url}
|
||||
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>;
|
||||
case 'music':
|
||||
if (segment.data.type === 'custom') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={segment.data.url}
|
||||
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'
|
||||
>
|
||||
<FaReply className='text-default-500' />
|
||||
回复消息ID: {segment.data.id}
|
||||
</div>
|
||||
);
|
||||
case 'forward':
|
||||
return <span key={index}>[合并转发: {segment.data.id}]</span>;
|
||||
case 'node':
|
||||
return <span key={index}>[消息节点]</span>;
|
||||
case 'xml':
|
||||
return <pre key={index}>{segment.data.data}</pre>;
|
||||
case 'json':
|
||||
return (
|
||||
<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'
|
||||
>
|
||||
[文件]
|
||||
</a>
|
||||
);
|
||||
default:
|
||||
return <span key={index}>[不支持的消息类型]</span>;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
const { sendMessage } = props;
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const editorRef = useRef<CodeEditorRef | null>(null);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(onClose: () => void) => {
|
||||
const msg = editorRef.current?.getValue();
|
||||
if (!msg) {
|
||||
toast.error('消息不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sendMessage(msg);
|
||||
toast.success('消息发送成功');
|
||||
onClose();
|
||||
} catch (_error) {
|
||||
toast.error('消息发送失败');
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat' size='sm' className='font-medium'>
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
placement='top-center'
|
||||
size='5xl'
|
||||
scrollBehavior='outside'
|
||||
isDismissable={false}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='h-96'>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
defaultValue={`{
|
||||
"action": "get_group_list"
|
||||
}`}
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ChatInputModal />
|
||||
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => handleSendMessage(onClose)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default OneBotSendModal;
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
import { ReadyState } from 'react-use-websocket';
|
||||
|
||||
export interface WSStatusProps {
|
||||
state: ReadyState
|
||||
}
|
||||
|
||||
function StatusTag ({
|
||||
title,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
color: 'success' | 'primary' | 'warning'
|
||||
}) {
|
||||
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={textClassName}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WSStatus ({ state }: WSStatusProps) {
|
||||
if (state === ReadyState.OPEN) {
|
||||
return <StatusTag title='已连接' color='success' />;
|
||||
}
|
||||
if (state === ReadyState.CLOSED) {
|
||||
return <StatusTag title='已关闭' color='primary' />;
|
||||
}
|
||||
if (state === ReadyState.CONNECTING) {
|
||||
return <StatusTag title='连接中' color='warning' />;
|
||||
}
|
||||
if (state === ReadyState.CLOSING) {
|
||||
return <StatusTag title='关闭中' color='warning' />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'>
|
||||
{/* 静态光斑 - ACG风格 */}
|
||||
<div
|
||||
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
|
||||
/>
|
||||
<div
|
||||
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
|
||||
/>
|
||||
<div
|
||||
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBackground;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PageLoadingProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-30 flex justify-center items-center backdrop-blur',
|
||||
{
|
||||
hidden: !loading,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoading;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
import TencentCaptchaModal from '@/components/tencent_captcha';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
import NewDeviceVerify from '@/components/new_device_verify';
|
||||
|
||||
interface PasswordLoginProps {
|
||||
onSubmit: (uin: string, password: string) => void;
|
||||
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
|
||||
onNewDeviceVerified?: (token: string) => void;
|
||||
isLoading: boolean;
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
captchaState?: {
|
||||
needCaptcha: boolean;
|
||||
proofWaterUrl: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null;
|
||||
captchaVerifying?: boolean;
|
||||
newDeviceState?: {
|
||||
needNewDevice: boolean;
|
||||
jumpUrl: string;
|
||||
uin: string;
|
||||
} | null;
|
||||
onCaptchaCancel?: () => void;
|
||||
onNewDeviceCancel?: () => void;
|
||||
}
|
||||
|
||||
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, captchaVerifying, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => {
|
||||
const [uin, setUin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!uin) {
|
||||
toast.error('请输入QQ号');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
toast.error('请输入密码');
|
||||
return;
|
||||
}
|
||||
onSubmit(uin, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
{captchaState?.needCaptcha && captchaState.proofWaterUrl
|
||||
? (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
{captchaVerifying
|
||||
? (
|
||||
<>
|
||||
<p className='text-primary text-sm'>验证码已提交,正在等待服务器验证结果...</p>
|
||||
<div className='flex items-center justify-center py-8 gap-3'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<p className='text-warning text-sm'>登录需要安全验证,请完成验证码</p>
|
||||
<TencentCaptchaModal
|
||||
proofWaterUrl={captchaState.proofWaterUrl}
|
||||
onSuccess={(data) => {
|
||||
onCaptchaSubmit?.(captchaState.uin, captchaState.password, data);
|
||||
}}
|
||||
onCancel={onCaptchaCancel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCaptchaCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: newDeviceState?.needNewDevice && newDeviceState.jumpUrl
|
||||
? (
|
||||
<NewDeviceVerify
|
||||
jumpUrl={newDeviceState.jumpUrl}
|
||||
uin={newDeviceState.uin}
|
||||
onVerified={(token) => onNewDeviceVerified?.(token)}
|
||||
onCancel={onNewDeviceCancel}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||
width={100}
|
||||
alt='QQ Avatar'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
type='text'
|
||||
label='QQ账号'
|
||||
placeholder='请输入QQ号'
|
||||
value={uin}
|
||||
onValueChange={setUin}
|
||||
variant='bordered'
|
||||
size='lg'
|
||||
autoComplete='off'
|
||||
endContent={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant='light' size='sm' radius='full'>
|
||||
<IoChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label='QQ Login History'
|
||||
items={qqList}
|
||||
onAction={(key) => setUin(key.toString())}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
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'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type='password'
|
||||
label='密码'
|
||||
placeholder='请输入密码'
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant='bordered'
|
||||
size='lg'
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordLogin;
|
||||
Loaded 100 of 1111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user