feat: 新版webui

This commit is contained in:
bietiaop
2025-01-24 21:13:44 +08:00
parent 1d0d25eea2
commit ee1291e42c
201 changed files with 18454 additions and 3422 deletions

68
napcat.webui/src/App.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { Suspense, lazy, useEffect } from 'react'
import { Provider } from 'react-redux'
import { Route, Routes, useNavigate } from 'react-router-dom'
import PageBackground from '@/components/page_background'
import PageLoading from '@/components/page_loading'
import Toaster from '@/components/toaster'
import DialogProvider from '@/contexts/dialog'
import AudioProvider from '@/contexts/songs'
import useAuth from '@/hooks/auth'
import store from '@/store'
const WebLoginPage = lazy(() => import('@/pages/web_login'))
const IndexPage = lazy(() => import('@/pages/index'))
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
function App() {
return (
<DialogProvider>
<Provider store={store}>
<PageBackground />
<Toaster />
<AudioProvider>
<Suspense fallback={<PageLoading />}>
<AuthChecker>
<AppRoutes />
</AuthChecker>
</Suspense>
</AudioProvider>
</Provider>
</DialogProvider>
)
}
function AuthChecker({ children }: { children: React.ReactNode }) {
const { isAuth } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!isAuth) {
const search = new URLSearchParams(window.location.search)
const token = search.get('token')
let url = '/web_login'
if (token) {
url += `?token=${token}`
}
navigate(url, { replace: true })
}
}, [isAuth, navigate])
return <>{children}</>
}
function AppRoutes() {
return (
<Routes>
<Route element={<IndexPage />} path="/*" />
<Route element={<QQLoginPage />} path="/qq_login" />
<Route element={<WebLoginPage />} path="/web_login" />
</Routes>
)
}
export default App

View File

@@ -1,112 +0,0 @@
<template>
<div id="app" theme-mode="dark">
<router-view />
</div>
<div v-if="show">
<t-sticky-tool shape="round" placement="right-bottom" :offset="[-50, 10]" @click="changeTheme">
<t-sticky-item label="浅色" popup="切换浅色模式">
<template #icon><sunny-icon /></template>
</t-sticky-item>
<t-sticky-item label="深色" popup="切换深色模式">
<template #icon><mode-dark-icon /></template>
</t-sticky-item>
<t-sticky-item label="自动" popup="跟随系统">
<template #icon><control-platform-icon /></template>
</t-sticky-item>
</t-sticky-tool>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
import { ControlPlatformIcon, ModeDarkIcon, SunnyIcon } from 'tdesign-icons-vue-next';
const smallScreen = window.matchMedia('(max-width: 768px)');
interface Item {
label: string;
popup: string;
}
interface Context {
item: Item;
}
enum ThemeMode {
Dark = 'dark',
Light = 'light',
Auto = 'auto',
}
const themeLabelMap: Record<string, ThemeMode> = {
"浅色": ThemeMode.Light,
"深色": ThemeMode.Dark,
"自动": ThemeMode.Auto,
};
const show = ref<boolean>(true);
const createSetThemeAttributeFunction = () => {
let mediaQueryForAutoTheme: MediaQueryList | null = null;
return (mode: ThemeMode | null) => {
const element = document.documentElement;
if (mode === ThemeMode.Dark) {
element.setAttribute('theme-mode', ThemeMode.Dark);
} else if (mode === ThemeMode.Light) {
element.removeAttribute('theme-mode');
} else if (mode === ThemeMode.Auto) {
mediaQueryForAutoTheme = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = (e: MediaQueryListEvent) => {
if (e.matches) {
element.setAttribute('theme-mode', ThemeMode.Dark);
} else {
element.removeAttribute('theme-mode');
}
};
mediaQueryForAutoTheme.addEventListener('change', handleMediaChange);
const event = new Event('change');
Object.defineProperty(event, 'matches', {
value: mediaQueryForAutoTheme.matches,
writable: false,
});
mediaQueryForAutoTheme.dispatchEvent(event);
onBeforeUnmount(() => {
if (mediaQueryForAutoTheme) {
mediaQueryForAutoTheme.removeEventListener('change', handleMediaChange);
}
});
}
};
};
const setThemeAttribute = createSetThemeAttributeFunction();
const getStoredTheme = (): ThemeMode | null => {
return localStorage.getItem('theme') as ThemeMode | null;
};
const initTheme = () => {
const storedTheme = getStoredTheme();
if (storedTheme === null) {
setThemeAttribute(ThemeMode.Auto);
} else {
setThemeAttribute(storedTheme);
}
};
const changeTheme = (context: Context) => {
const themeLabel = themeLabelMap[context.item.label] as ThemeMode;
console.log(themeLabel);
setThemeAttribute(themeLabel);
localStorage.setItem('theme', themeLabel);
};
const haddingFbars = () => {
show.value = !smallScreen.matches;
if (smallScreen.matches) {
localStorage.setItem('theme', 'auto');
}
};
onMounted(() => {
initTheme();
haddingFbars();
window.addEventListener('resize', haddingFbars);
});
onUnmounted(() => {
window.removeEventListener('resize', haddingFbars);
});
</script>
<style></style>

Binary file not shown.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,66 +0,0 @@
export class githubApiManager {
public async GetBaseData(): Promise<Response | null> {
try {
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
return await ConfigResponse.json();
}
} catch (error) {
console.error('Error getting github data :', error);
}
return null;
}
public async GetReleasesData(): Promise<Response | null> {
try {
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/releases', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
return await ConfigResponse.json();
}
} catch (error) {
console.error('Error getting releases data:', error);
}
return null;
}
public async GetPullsData(): Promise<Response | null> {
try {
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/pulls', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
return await ConfigResponse.json();
}
} catch (error) {
console.error('Error getting Pulls data:', error);
}
return null;
}
public async GetContributors(): Promise<Response | null> {
try {
const ConfigResponse = await fetch('https://api.github.com/repos/NapNeko/NapCatQQ/contributors', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
return await ConfigResponse.json();
}
} catch (error) {
console.error('Error getting Pulls data:', error);
}
return null;
}
}

View File

@@ -1,72 +0,0 @@
import { EventSourcePolyfill } from 'event-source-polyfill';
type LogListItem = string;
type LogListData = LogListItem[];
let eventSourcePoly: EventSourcePolyfill | null = null;
export class LogManager {
private readonly retCredential: string;
private readonly apiPrefix: string;
//调试时http://127.0.0.1:6099/api 打包时 ../api
constructor(retCredential: string, apiPrefix: string = '../api') {
this.retCredential = retCredential;
this.apiPrefix = apiPrefix;
}
public async GetLogList(): Promise<LogListData> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/Log/GetLogList`, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == 0) {
return ConfigResponseJson?.data as LogListData;
}
}
} catch (error) {
console.error('Error getting LogList:', error);
}
return [] as LogListData;
}
public async GetLog(FileName: string): Promise<string> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/Log/GetLog?id=${FileName}`, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == 0) {
return ConfigResponseJson?.data;
}
}
} catch (error) {
console.error('Error getting LogData:', error);
}
return 'null';
}
public async getRealTimeLogs(): Promise<EventSourcePolyfill | null> {
this.creatEventSource();
return eventSourcePoly;
}
private creatEventSource() {
try {
eventSourcePoly = new EventSourcePolyfill(`${this.apiPrefix}/Log/GetLogRealTime`, {
heartbeatTimeout: 3 * 60 * 1000,
headers: {
Authorization: 'Bearer ' + this.retCredential,
Accept: 'text/event-stream',
},
withCredentials: true,
});
} catch (error) {
console.error('创建SSE连接出错:', error);
}
}
}

View File

@@ -1,205 +0,0 @@
import { OneBotConfig } from '../../../src/onebot/config/config';
import { ResponseCode } from '../../../src/webui/src/const/status';
export class QQLoginManager {
private retCredential: string;
private readonly apiPrefix: string;
//调试时http://127.0.0.1:6099/api 打包时 ../api
constructor(retCredential: string, apiPrefix: string = '../api') {
this.retCredential = retCredential;
this.apiPrefix = apiPrefix;
}
// TODO:
public async GetOB11Config(): Promise<OneBotConfig> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/GetConfig`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == ResponseCode.Success) {
return ConfigResponseJson.data;
}
}
} catch (error) {
console.error('Error getting OB11 config:', error);
}
return {} as OneBotConfig;
}
public async SetOB11Config(config: OneBotConfig): Promise<boolean> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/SetConfig`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
body: JSON.stringify({ config: JSON.stringify(config) }),
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == 0) {
return true;
}
}
} catch (error) {
console.error('Error setting OB11 config:', error);
}
return false;
}
public async checkQQLoginStatus(): Promise<boolean> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data.isLogin;
}
}
} catch (error) {
console.error('Error checking QQ login status:', error);
}
return false;
}
public async checkQQLoginStatusWithQrcode(): Promise<{ qrcodeurl: string; isLogin: string } | undefined> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data;
}
}
} catch (error) {
console.error('Error checking QQ login status:', error);
}
return undefined;
}
public async checkWebUiLogined(): Promise<boolean> {
try {
const LoginResponse = await fetch(`${this.apiPrefix}/auth/check`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (LoginResponse.status == 200) {
const LoginResponseJson = await LoginResponse.json();
if (LoginResponseJson.code == 0) {
return true;
}
}
} catch (error) {
console.error('Error checking web UI login status:', error);
}
return false;
}
public async loginWithToken(token: string): Promise<string | null> {
try {
const loginResponse = await fetch(`${this.apiPrefix}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: token }),
});
const loginResponseJson = await loginResponse.json();
const retCode = loginResponseJson.code;
if (retCode === 0) {
this.retCredential = loginResponseJson.data.Credential;
return this.retCredential;
}
} catch (error) {
console.error('Error logging in with token:', error);
}
return null;
}
public async getQQLoginQrcode(): Promise<string> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQQLoginQrcode`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data.qrcode || '';
}
}
} catch (error) {
console.error('Error getting QQ login QR code:', error);
}
return '';
}
public async getQQQuickLoginList(): Promise<string[]> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQuickLoginList`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data || [];
}
}
} catch (error) {
console.error('Error getting QQ quick login list:', error);
}
return [];
}
public async setQuickLogin(uin: string): Promise<{ result: boolean; errMsg: string }> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/SetQuickLogin`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
body: JSON.stringify({ uin: uin }),
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return { result: true, errMsg: '' };
} else {
return { result: false, errMsg: QQLoginResponseJson.message };
}
}
} catch (error) {
console.error('Error setting quick login:', error);
}
return { result: false, errMsg: '接口异常' };
}
}

View File

@@ -1,95 +0,0 @@
<template>
<t-layout class="dashboard-container">
<div v-if="!mediaQuery.matches">
<SidebarMenu
:menu-items="menuItems"
class="sidebar-menu"
:menu-width="sidebarWidth"
/>
</div>
<t-layout>
<router-view />
</t-layout>
<div v-if="mediaQuery.matches" class="bottom-menu">
<BottomMenu :menu-items="menuItems" />
</div>
</t-layout>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import SidebarMenu from './webui/Nav.vue';
import BottomMenu from './webui/NavBottom.vue';
import emitter from '@/ts/event-bus';
const mediaQuery = window.matchMedia('(max-width: 768px)');
const sidebarWidth = ['232px', '64px'];
interface MenuItem {
value: string;
icon: string;
label: string;
route: string;
}
const menuItems = ref<MenuItem[]>([
{ value: 'item1', icon: 'dashboard', label: '基础信息', route: '/dashboard/basic-info' },
{ value: 'item3', icon: 'wifi-1', label: '网络配置', route: '/dashboard/network-config' },
{ value: 'item4', icon: 'setting', label: '其余配置', route: '/dashboard/other-config' },
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
]);
emitter.on('sendMenu', (event) => {
const menuWidth = event ? sidebarWidth[1] : sidebarWidth[0];
emitter.emit('sendWidth', menuWidth);
localStorage.setItem('menuWidth', menuWidth.toString() || '0');
});
onMounted(() => {
if (mediaQuery.matches){
localStorage.setItem('menuWidth', '0');
}
});
onUnmounted(() => {
});
</script>
<style scoped>
.dashboard-container {
display: flex;
flex-direction: row;
height: 100vh;
width: 100%;
}
.sidebar-menu {
position: relative;
z-index: 2;
}
.bottom-menu {
position: fixed;
bottom: 0;
width: 100%;
z-index: 2;
}
@media (max-width: 768px) {
.content {
padding: 10px;
}
}
</style>
<style>
@media (max-width: 768px) {
.t-head-menu__inner .t-menu:first-child {
margin-left: 0;
}
.t-head-menu__inner{
width: 100%;
}
.t-head-menu .t-menu{
justify-content: space-evenly;
}
.t-menu__content{
display: none;
}
}
</style>

View File

@@ -1,187 +0,0 @@
<template>
<t-card class="layout" :bordered="false">
<div class="login-container">
<h2 class="sotheby-font">QQ Login</h2>
<div class="login-methods">
<t-tooltip content="快速登录">
<t-button id="quick-login" class="login-method" :class="{ active: loginMethod === 'quick' }"
@click="loginMethod = 'quick'">Quick Login</t-button>
</t-tooltip>
<t-tooltip content="二维码登录">
<t-button id="qrcode-login" class="login-method" :class="{ active: loginMethod === 'qrcode' }"
@click="loginMethod = 'qrcode'">QR Code</t-button>
</t-tooltip>
</div>
<div v-show="loginMethod === 'quick'" id="quick-login-dropdown" class="login-form">
<t-select id="quick-login-select" v-model="selectedAccount" placeholder="Select Account"
@change="selectAccount">
<t-option v-for="account in quickLoginList" :key="account" :value="account">{{ account }}</t-option>
</t-select>
</div>
<div v-show="loginMethod === 'qrcode'" id="qrcode" class="qrcode">
<canvas ref="qrcodeCanvas"></canvas>
</div>
</div>
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import * as QRCode from 'qrcode';
import { useRouter } from 'vue-router';
import { MessagePlugin } from 'tdesign-vue-next';
import { QQLoginManager } from '@/backend/shell';
const router = useRouter();
const loginMethod = ref<'quick' | 'qrcode'>('quick');
const quickLoginList = ref<string[]>([]);
const selectedAccount = ref<string>('');
const qrcodeCanvas = ref<HTMLCanvasElement | null>(null);
const qqLoginManager = new QQLoginManager(localStorage.getItem('auth') || '');
let heartBeatTimer: number | null = null;
let qrcodeUrl: string = '';
const selectAccount = async (accountName: string): Promise<void> => {
const { result, errMsg } = await qqLoginManager.setQuickLogin(accountName);
if (result) {
if (heartBeatTimer) {
clearInterval(heartBeatTimer);
}
await MessagePlugin.success('登录成功即将跳转');
await router.push({ path: '/dashboard/basic-info' });
} else {
await MessagePlugin.error('登录失败,' + errMsg);
}
};
const generateQrCode = (data: string, canvas: HTMLCanvasElement | null): void => {
if (!canvas) {
console.error('Canvas element not found');
return;
}
QRCode.toCanvas(canvas, data, function (error: Error | null | undefined) {
if (error) {
console.error('Error generating QR Code:', error);
} else {
console.log('QR Code generated!');
}
});
};
const HeartBeat = async (): Promise<void> => {
const isLogined = await qqLoginManager.checkQQLoginStatusWithQrcode();
if (isLogined?.isLogin) {
if (heartBeatTimer) {
clearInterval(heartBeatTimer);
}
await MessagePlugin.success('登录成功即将跳转');
await router.push({ path: '/dashboard/basic-info' });
} else if (isLogined?.qrcodeurl && qrcodeUrl !== isLogined.qrcodeurl) {
qrcodeUrl = isLogined.qrcodeurl;
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
}
};
const InitPages = async (): Promise<void> => {
quickLoginList.value = await qqLoginManager.getQQQuickLoginList();
qrcodeUrl = await qqLoginManager.getQQLoginQrcode();
await nextTick();
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
};
onMounted(() => {
InitPages().then().catch((err) => {
console.error('InitPages Error:', err);
});
heartBeatTimer = window.setInterval(HeartBeat, 3000);
});
onBeforeUnmount(() => {
if (heartBeatTimer) {
clearInterval(heartBeatTimer);
}
});
watch(loginMethod, async (newMethod) => {
if (newMethod === 'qrcode') {
await nextTick();
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
}
});
</script>
<style scoped>
.layout {
height: 100vh;
}
.login-container {
padding: 20px;
border-radius: 5px;
max-width: 400px;
min-width: 300px;
position: relative;
margin: 50px auto;
}
@media (max-width: 600px) {
.login-container {
width: 90%;
min-width: unset;
}
}
.login-methods {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.login-method {
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
.login-method.active {
background-color: #e6f0ff;
color: #007bff;
}
.login-form,
.qrcode {
display: flex;
flex-direction: column;
gap: 15px;
}
.qrcode {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
text-align: center;
}
.sotheby-font {
font-family: Sotheby, Helvetica, monospace;
font-size: 3.125rem;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.footer {
text-align: center;
margin: 0;
font-size: 0.875rem;
color: #888;
position: fixed;
bottom: 20px;
left: 0;
right: 0;
}
</style>

View File

@@ -1,153 +0,0 @@
<template>
<t-card class="layout" :bordered="false">
<div class="login-container">
<h2 class="sotheby-font">WebUi Login</h2>
<t-form ref="form" :data="formData" colon :label-width="0" @submit="onSubmit">
<t-form-item name="password">
<t-input v-model="formData.token" type="password" clearable placeholder="请输入Token">
<template #prefix-icon>
<lock-on-icon />
</template>
</t-input>
</t-form-item>
<t-form-item>
<t-button theme="primary" type="submit" block>登录</t-button>
</t-form-item>
</t-form>
</div>
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
</t-card>
</template>
<script setup lang="ts">
import '../css/style.css';
import '../css/font.css';
import { reactive, onMounted } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { LockOnIcon } from 'tdesign-icons-vue-next';
import { useRouter } from 'vue-router';
import { QQLoginManager } from '@/backend/shell';
const router = useRouter();
interface FormData {
token: string;
}
const formData: FormData = reactive({
token: '',
});
const handleLoginSuccess = async (credential: string) => {
localStorage.setItem('auth', credential);
await checkLoginStatus();
};
const handleLoginFailure = (message: string) => {
MessagePlugin.error(message);
};
const checkLoginStatus = async () => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
return;
}
const loginManager = new QQLoginManager(storedCredential);
const isWenUiLoggedIn = await loginManager.checkWebUiLogined();
console.log('isWenUiLoggedIn', isWenUiLoggedIn);
if (!isWenUiLoggedIn) {
return;
}
const isQQLoggedIn = await loginManager.checkQQLoginStatus();
if (isQQLoggedIn) {
await router.push({ path: '/dashboard/basic-info' });
} else {
await router.push({ path: '/qqlogin' });
}
};
const loginWithToken = async (token: string) => {
const loginManager = new QQLoginManager('');
const credential = await loginManager.loginWithToken(token);
if (credential) {
await handleLoginSuccess(credential);
} else {
handleLoginFailure('登录失败请检查Token');
}
};
onMounted(() => {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (token) {
loginWithToken(token);
}
checkLoginStatus();
});
const onSubmit = async ({ validateResult }: { validateResult: boolean }) => {
if (validateResult) {
await loginWithToken(formData.token);
} else {
handleLoginFailure('请填写Token');
}
};
</script>
<style scoped>
.layout {
height: 100vh;
}
.login-container {
padding: 20px;
border-radius: 5px;
max-width: 400px;
min-width: 300px;
position: relative;
margin: 50px auto;
}
@media (max-width: 600px) {
.login-container {
width: 90%;
min-width: unset;
}
}
.tdesign-demo-block-column {
display: flex;
flex-direction: column;
row-gap: 16px;
}
.tdesign-demo-block-column-large {
display: flex;
flex-direction: column;
row-gap: 32px;
}
.tdesign-demo-block-row {
display: flex;
column-gap: 16px;
align-items: center;
}
.sotheby-font {
font-family: Sotheby, Helvetica, monospace;
font-size: 3.125rem;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.footer {
text-align: center;
margin: 0;
font-size: 0.875rem;
color: #888;
position: fixed;
bottom: 20px;
left: 0;
right: 0;
}
</style>

View File

@@ -0,0 +1,425 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Image } from '@heroui/image'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import { Slider } from '@heroui/slider'
import { Tooltip } from '@heroui/tooltip'
import { useLocalStorage } from '@uidotdev/usehooks'
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import {
BiSolidSkipNextCircle,
BiSolidSkipPreviousCircle
} from 'react-icons/bi'
import {
FaPause,
FaPlay,
FaRegHandPointRight,
FaRepeat,
FaShuffle
} from 'react-icons/fa6'
import { TbRepeatOnce } from 'react-icons/tb'
import { useMediaQuery } from 'react-responsive'
import { PlayMode } from '@/const/enum'
import key from '@/const/key'
import { VolumeHighIcon, VolumeLowIcon } from './icons'
export interface AudioPlayerProps
extends React.AudioHTMLAttributes<HTMLAudioElement> {
src: string
title?: string
artist?: string
cover?: string
pressNext?: () => void
pressPrevious?: () => void
onPlayEnd?: () => void
onChangeMode?: (mode: PlayMode) => void
mode?: PlayMode
}
export default function AudioPlayer(props: AudioPlayerProps) {
const {
src,
pressNext,
pressPrevious,
cover = 'https://nextui.org/images/album-cover.png',
title = '未知',
artist = '未知',
onTimeUpdate,
onLoadedData,
onPlay,
onPause,
onPlayEnd,
onChangeMode,
autoPlay,
mode = PlayMode.Loop,
...rest
} = props
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [volume, setVolume] = useState(100)
const [isCollapsed, setIsCollapsed] = useLocalStorage(
key.isCollapsedMusicPlayer,
false
)
const audioRef = useRef<HTMLAudioElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const startY = useRef(0)
const startX = useRef(0)
const [translateY, setTranslateY] = useState(0)
const [translateX, setTranslateX] = useState(0)
const isSmallScreen = useMediaQuery({ maxWidth: 767 })
const isMediumUp = useMediaQuery({ minWidth: 768 })
const shouldAdd = useRef(false)
const currentProgress = (currentTime / duration) * 100
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
key.autoPlay,
true
)
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement
setCurrentTime(audio.currentTime)
onTimeUpdate?.(event)
}
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement
setDuration(audio.duration)
onLoadedData?.(event)
}
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(true)
setStorageAutoPlay(true)
onPlay?.(e)
}
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(false)
onPause?.(e)
}
const changeMode = () => {
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single]
const currentIndex = modes.findIndex((_mode) => _mode === mode)
const nextIndex = currentIndex + 1
const nextMode = modes[nextIndex] || modes[0]
onChangeMode?.(nextMode)
}
const volumeChange = (value: number) => {
setVolume(value)
}
useEffect(() => {
const audio = audioRef.current
if (audio) {
audio.volume = volume / 100
}
}, [volume])
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY
startX.current = e.touches[0].clientX
}
const handleTouchMove = (e: React.TouchEvent) => {
const deltaY = e.touches[0].clientY - startY.current
const deltaX = e.touches[0].clientX - startX.current
const container = cardRef.current
const header = cardRef.current?.querySelector('[data-header]')
const headerHeight = header?.clientHeight || 20
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
const _shouldAdd = isCollapsed && deltaY < 0
if (isSmallScreen) {
shouldAdd.current = _shouldAdd
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY)
} else {
setTranslateX(deltaX)
}
}
const handleTouchEnd = () => {
if (isSmallScreen) {
const container = cardRef.current
const header = cardRef.current?.querySelector('[data-header]')
const headerHeight = header?.clientHeight || 20
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
const _translateY = translateY - (shouldAdd.current ? addHeight : 0)
if (_translateY > 100) {
setIsCollapsed(true)
} else if (_translateY < -100) {
setIsCollapsed(false)
}
setTranslateY(0)
} else {
if (translateX > 100) {
setIsCollapsed(true)
} else if (translateX < -100) {
setIsCollapsed(false)
}
setTranslateX(0)
}
}
const dragTranslate = isSmallScreen
? translateY
? `translateY(${translateY}px)`
: ''
: translateX
? `translateX(${translateX}px)`
: ''
const collapsedTranslate = isCollapsed
? isSmallScreen
? 'translateY(90%)'
: 'translateX(96%)'
: ''
const translateStyle = dragTranslate || collapsedTranslate
if (!src) return null
return (
<div
className={clsx(
'fixed right-0 bottom-0 z-[9999] w-full md:w-96',
!translateX && !translateY && 'transition-transform',
isCollapsed && 'md:hover:!translate-x-80'
)}
style={{
transform: translateStyle
}}
>
<audio
src={src}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onEnded={onPlayEnd}
autoPlay={autoPlay ?? storageAutoPlay}
{...rest}
controls={false}
hidden
ref={audioRef}
/>
<Card
ref={cardRef}
className={clsx(
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
)}
classNames={{
body: 'p-0'
}}
shadow="sm"
radius="none"
>
{isMediumUp && (
<Button
isIconOnly
className={clsx(
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
isCollapsed
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant="solid"
color="danger"
size="sm"
onPress={() => setIsCollapsed(!isCollapsed)}
>
<FaRegHandPointRight />
</Button>
)}
{isSmallScreen && (
<CardHeader
data-header
className="flex-row justify-center pt-4"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="w-24 h-2 rounded-full bg-content2-foreground shadow-sm"></div>
</CardHeader>
)}
<CardBody>
<div className="grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0">
<div className="relative col-span-6 md:col-span-4 flex justify-center">
<Image
alt="Album cover"
className="object-cover"
classNames={{
wrapper: 'w-36 aspect-square md:w-24 flex',
img: 'block w-full h-full'
}}
shadow="md"
src={cover}
width="100%"
/>
</div>
<div className="flex flex-col col-span-6 md:col-span-8">
<div className="flex flex-col gap-0">
<h1 className="font-medium truncate">{title}</h1>
<p className="text-xs text-foreground/80 truncate">{artist}</p>
</div>
<div className="flex flex-col">
<Slider
aria-label="Music progress"
classNames={{
track: 'bg-default-500/30 border-none',
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
filler: 'rounded-full'
}}
color="foreground"
value={currentProgress || 0}
defaultValue={0}
size="sm"
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value
const audio = audioRef.current
if (audio) {
audio.currentTime = (value / 100) * duration
}
}}
/>
<div className="flex justify-between h-3">
<p className="text-xs">
{Math.floor(currentTime / 60)}:
{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')}
</p>
<p className="text-xs text-foreground/50">
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
.padStart(2, '0')}
</p>
</div>
</div>
<div className="flex w-full items-center justify-center">
<Tooltip
content={
mode === PlayMode.Loop
? '列表循环'
: mode === PlayMode.Random
? '随机播放'
: '单曲循环'
}
>
<Button
isIconOnly
className="data-[hover]:bg-foreground/10 text-lg md:text-medium"
radius="full"
variant="light"
size="md"
onPress={changeMode}
>
{mode === PlayMode.Loop && (
<FaRepeat className="text-foreground/80" />
)}
{mode === PlayMode.Random && (
<FaShuffle className="text-foreground/80" />
)}
{mode === PlayMode.Single && (
<TbRepeatOnce className="text-foreground/80 text-xl" />
)}
</Button>
</Tooltip>
<Tooltip content="上一首">
<Button
isIconOnly
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
radius="full"
variant="light"
size="md"
onPress={pressPrevious}
>
<BiSolidSkipPreviousCircle />
</Button>
</Tooltip>
<Tooltip content={isPlaying ? '暂停' : '播放'}>
<Button
isIconOnly
className="data-[hover]:bg-foreground/10 text-3xl md:text-3xl"
radius="full"
variant="light"
size="lg"
onPress={() => {
if (isPlaying) {
audioRef.current?.pause()
setStorageAutoPlay(false)
} else {
audioRef.current?.play()
}
}}
>
{isPlaying ? <FaPause /> : <FaPlay className="ml-1" />}
</Button>
</Tooltip>
<Tooltip content="下一首">
<Button
isIconOnly
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
radius="full"
variant="light"
size="md"
onPress={pressNext}
>
<BiSolidSkipNextCircle />
</Button>
</Tooltip>
<Popover
placement="top"
classNames={{
content: 'bg-opacity-30 backdrop-blur-md'
}}
>
<PopoverTrigger>
<Button
isIconOnly
className="data-[hover]:bg-foreground/10 text-xl md:text-xl"
radius="full"
variant="light"
size="md"
>
<VolumeHighIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<Slider
orientation="vertical"
showTooltip
aria-label="Volume"
className="h-40"
color="primary"
defaultValue={volume}
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value
volumeChange(value)
}}
startContent={<VolumeHighIcon className="text-2xl" />}
size="sm"
endContent={<VolumeLowIcon className="text-2xl" />}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
)
}

View File

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

View File

@@ -0,0 +1,49 @@
import { Button } from '@heroui/button'
import toast from 'react-hot-toast'
import { IoMdRefresh } from 'react-icons/io'
export interface SaveButtonsProps {
onSubmit: () => void
reset: () => void
refresh: () => void
isSubmitting: boolean
}
const SaveButtons: React.FC<SaveButtonsProps> = ({
onSubmit,
reset,
isSubmitting,
refresh
}) => (
<div className="max-w-full mx-3 w-96 flex flex-col justify-center gap-3">
<div className="flex items-center justify-center gap-2 mt-5">
<Button
color="default"
onPress={() => {
reset()
toast.success('重置成功')
}}
>
</Button>
<Button
color="primary"
isLoading={isSubmitting}
onPress={() => onSubmit()}
>
</Button>
<Button
isIconOnly
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
</div>
</div>
)
export default SaveButtons

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import Quill from 'quill'
// eslint-disable-next-line
const Embed = Quill.import('blots/embed') as any
export interface EmojiValue {
alt: string
src: string
id: string
}
class EmojiBlot extends Embed {
static blotName: string = 'emoji'
static tagName: string = 'img'
static classNames: string[] = ['w-6', 'h-6']
static create(value: HTMLImageElement) {
const node = super.create(value)
node.setAttribute('alt', value.alt)
node.setAttribute('src', value.src)
node.setAttribute('data-id', value.id)
node.classList.add(...EmojiBlot.classNames)
return node
}
static formats(node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? ''
}
}
static value(node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? ''
}
}
}
export default EmojiBlot

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Switch } from '@heroui/switch'
import { useState } from 'react'
import { CgDebug } from 'react-icons/cg'
import { FiEdit3 } from 'react-icons/fi'
import { MdDeleteForever } from 'react-icons/md'
import DisplayCardContainer from './container'
type NetworkType = OneBotConfig['network']
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]
render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode
}>
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]
showType?: boolean
typeLabel: string
fields: NetworkDisplayCardFields<T>
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const NetworkDisplayCard = <T extends keyof NetworkType>({
data,
showType,
typeLabel,
fields,
onEdit,
onEnable,
onDelete,
onEnableDebug
}: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data
const [editing, setEditing] = useState(false)
const handleEnable = () => {
setEditing(true)
onEnable().finally(() => setEditing(false))
}
const handleDelete = () => {
setEditing(true)
onDelete().finally(() => setEditing(false))
}
const handleEnableDebug = () => {
setEditing(true)
onEnableDebug().finally(() => setEditing(false))
}
return (
<DisplayCardContainer
action={
<ButtonGroup
fullWidth
isDisabled={editing}
radius="full"
size="sm"
variant="shadow"
>
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
</Button>
<Button
color={debug ? 'success' : 'default'}
startContent={<CgDebug />}
onPress={handleEnableDebug}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
color="danger"
startContent={<MdDeleteForever />}
onPress={handleDelete}
>
</Button>
</ButtonGroup>
}
enableSwitch={
<Switch
isDisabled={editing}
isSelected={enable}
onChange={handleEnable}
/>
}
tag={showType && typeLabel}
title={name}
>
<div className="grid grid-cols-2 gap-1">
{fields.map((field, index) => (
<div
key={index}
className={`flex items-center gap-2 ${
field.label === 'URL' ? 'col-span-2' : ''
}`}
>
<span className="text-default-400">{field.label}</span>
{field.render ? (
field.render(field.value)
) : (
<span>{field.value}</span>
)}
</div>
))}
</div>
</DisplayCardContainer>
)
}
export default NetworkDisplayCard

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'
interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const {
url,
heartInterval,
reconnectInterval,
messagePostFormat,
reportSelfMessage
} = data
const fields: NetworkDisplayCardFields<'websocketClients'> = [
{ label: 'URL', value: url },
{ label: '重连间隔', value: `${reconnectInterval}ms` },
{ label: '心跳间隔', value: `${heartInterval}ms` },
{ label: '消息格式', value: messagePostFormat },
{
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'}
</Chip>
)
}
]
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel="Websocket客户端"
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
)
}
export default WebsocketClientDisplayCard

View File

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

View File

@@ -0,0 +1,58 @@
import { Card, CardBody } from '@heroui/card'
import clsx from 'clsx'
import { title } from '@/components/primitives'
export interface NetworkItemDisplayProps {
count: number
label: string
size?: 'sm' | 'md'
}
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
count,
label,
size = 'md'
}) => {
return (
<Card
className={clsx(
'bg-opacity-60 shadow-sm md:rounded-3xl',
size === 'md'
? 'col-span-8 md:col-span-2 bg-danger-50 shadow-danger-100'
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)}
shadow="sm"
>
<CardBody className="items-center md:gap-1 p-1 md:p-2">
<div
className={clsx(
'font-outfit flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',
size
})
)}
>
{count}
</div>
<div
className={clsx(
'whitespace-nowrap text-nowrap flex-shrink-0',
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs'
})
)}
>
{label}
</div>
</CardBody>
</Card>
)
}
export default NetworkItemDisplay

View File

@@ -0,0 +1,109 @@
import { Card, CardProps } from '@heroui/card'
import clsx from 'clsx'
import React from 'react'
export interface HoverEffectCardProps extends CardProps {
children: React.ReactNode
maxXRotation?: number
maxYRotation?: number
lightClassName?: string
lightStyle?: React.CSSProperties
}
const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
const {
children,
maxXRotation = 5,
maxYRotation = 5,
className,
style,
lightClassName,
lightStyle
} = props
const cardRef = React.useRef<HTMLDivElement | null>(null)
const lightRef = React.useRef<HTMLDivElement | null>(null)
const [isShowLight, setIsShowLight] = React.useState(false)
const [pos, setPos] = React.useState({
left: 0,
top: 0
})
return (
<Card
{...props}
ref={cardRef}
className={clsx(
'relative overflow-hidden bg-opacity-50 backdrop-blur-lg',
className
)}
style={{
willChange: 'transform',
transform:
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
...style
}}
onMouseEnter={() => {
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.3s ease-out'
}
}}
onMouseLeave={() => {
setIsShowLight(false)
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.5s'
cardRef.current.style.transform =
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'
}
}}
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
if (cardRef.current) {
setIsShowLight(true)
const { x, y } = cardRef.current.getBoundingClientRect()
const { clientX, clientY } = e
const offsetX = clientX - x
const offsetY = clientY - y
const lightWidth = lightStyle?.width?.toString() || '100'
const lightHeight = lightStyle?.height?.toString() || '100'
const lightWidthNum = parseInt(lightWidth)
const lightHeightNum = parseInt(lightHeight)
const left = offsetX - lightWidthNum / 2
const top = offsetY - lightHeightNum / 2
setPos({
left,
top
})
cardRef.current.style.transition = 'transform 0.1s'
const rangeX = 400 / 2
const rangeY = 400 / 2
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
}
}}
>
<div
ref={lightRef}
className={clsx(
isShowLight ? 'opacity-100' : 'opacity-0',
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
lightClassName
)}
style={{
...pos
}}
/>
{children}
</Card>
)
}
export default HoverEffectCard

View File

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

View File

@@ -0,0 +1,19 @@
import clsx from 'clsx'
export interface IconWrapperProps {
children?: React.ReactNode
className?: string
}
const IconWrapper = ({ children, className }: IconWrapperProps) => (
<div
className={clsx(
className,
'flex items-center rounded-small justify-center w-7 h-7'
)}
>
{children}
</div>
)
export default IconWrapper

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,136 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Select, SelectItem } from '@heroui/select'
import type { Selection } from '@react-types/shared'
import { useEffect, useRef, useState } from 'react'
import { colorizeLogLevel } from '@/utils/terminal'
import PageLoading from '../page_loading'
import XTerm from '../xterm'
import type { XTermRef } from '../xterm'
import LogLevelSelect from './log_level_select'
export interface HistoryLogsProps {
list: string[]
onSelect: (name: string) => void
selectedLog?: string
refreshList: () => void
refreshLog: () => void
listLoading?: boolean
logLoading?: boolean
listError?: Error
logContent?: string
}
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const {
list,
onSelect,
selectedLog,
refreshList,
refreshLog,
listLoading,
logContent,
listError,
logLoading
} = props
const Xterm = useRef<XTermRef>(null)
const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error'])
)
const logToColored = (log: string) => {
const logs = log
.split('\n')
.map((line) => {
const colored = colorizeLogLevel(line)
return colored
})
.filter((log) => {
if (logLevel === 'all') {
return true
}
return logLevel.has(log.level)
})
.map((log) => log.content)
.join('\r\n')
return logs
}
const onDownloadLog = () => {
if (!logContent) {
return
}
const blob = new Blob([logContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedLog}.log`
a.click()
URL.revokeObjectURL(url)
}
useEffect(() => {
if (!Xterm.current || !logContent) {
return
}
Xterm.current.clear()
const _logContent = logToColored(logContent)
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ')
}, [logContent, logLevel])
return (
<>
<title> - NapCat WebUI</title>
<Card className="max-w-full h-full bg-opacity-50 backdrop-blur-sm">
<CardHeader className="flex-row justify-start gap-3">
<Select
label="选择日志"
size="sm"
isLoading={listLoading}
errorMessage={listError?.message}
classNames={{
trigger:
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60'
}}
placeholder="选择日志"
onChange={(e) => {
const value = e.target.value
if (!value) {
return
}
onSelect(value)
}}
selectedKeys={[selectedLog || '']}
items={list.map((name) => ({
value: name,
label: name
}))}
>
{(item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
)}
</Select>
<LogLevelSelect
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
/>
<Button className="flex-shrink-0" onPress={onDownloadLog}>
</Button>
<Button onPress={refreshList}></Button>
<Button onPress={refreshLog}></Button>
</CardHeader>
<CardBody className="relative">
<PageLoading loading={logLoading} />
<XTerm className="w-full h-full" ref={Xterm} />
</CardBody>
</Card>
</>
)
}
export default HistoryLogs

View File

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

View File

@@ -0,0 +1,116 @@
import { Button } from '@heroui/button'
import type { Selection } from '@react-types/shared'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { IoDownloadOutline } from 'react-icons/io5'
import { colorizeLogLevelWithTag } from '@/utils/terminal'
import WebUIManager, { Log } from '@/controllers/webui_manager'
import type { XTermRef } from '../xterm'
import XTerm from '../xterm'
import LogLevelSelect from './log_level_select'
const RealTimeLogs = () => {
const Xterm = useRef<XTermRef>(null)
const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error'])
)
const [dataArr, setDataArr] = useState<Log[]>([])
const onDownloadLog = () => {
const logContent = dataArr
.filter((log) => {
if (logLevel === 'all') {
return true
}
return logLevel.has(log.level)
})
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n')
const blob = new Blob([logContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'napcat.log'
a.click()
URL.revokeObjectURL(url)
}
const writeStream = () => {
try {
const _data = dataArr
.filter((log) => {
if (logLevel === 'all') {
return true
}
return logLevel.has(log.level)
})
.slice(-100)
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n')
Xterm.current?.clear()
Xterm.current?.write(_data)
} catch (error) {
console.error(error)
toast.error('获取实时日志失败')
}
}
useEffect(() => {
writeStream()
}, [logLevel, dataArr])
useEffect(() => {
const subscribeLogs = () => {
try {
console.log('subscribeLogs')
const source = WebUIManager.getRealTimeLogs((data) => {
setDataArr((prev) => {
const newData = [...prev, ...data]
if (newData.length > 1000) {
newData.splice(0, newData.length - 1000)
}
return newData
})
})
return () => {
source.close()
}
} catch (error) {
toast.error('获取实时日志失败')
}
}
const close = subscribeLogs()
return () => {
console.log('close')
close?.()
}
}, [])
return (
<>
<title> - NapCat WebUI</title>
<div className="flex items-center gap-2">
<LogLevelSelect
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
/>
<Button
className="flex-shrink-0"
onPress={onDownloadLog}
startContent={<IoDownloadOutline className="text-lg" />}
>
</Button>
</div>
<div className="flex-1 h-full overflow-hidden">
<XTerm ref={Xterm} />
</div>
</>
)
}
export default RealTimeLogs

View File

@@ -0,0 +1,92 @@
import { Button } from '@heroui/button'
import {
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Modal as NextUIModal,
useDisclosure
} from '@heroui/modal'
import React from 'react'
export interface ModalProps {
content: React.ReactNode
title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean
dismissible?: boolean
}
const Modal: React.FC<ModalProps> = React.memo((props) => {
const {
backdrop = 'blur',
title,
content,
size = 'md',
showCancel = true,
dismissible,
onClose,
onConfirm,
onCancel
} = props
const { onClose: onNativeClose } = useDisclosure()
return (
<NextUIModal
defaultOpen
backdrop={backdrop}
isDismissable={dismissible}
onClose={() => {
onClose?.()
onNativeClose()
}}
size={size}
classNames={{
backdrop: 'z-[99999999]',
wrapper: 'z-[999999999]'
}}
>
<ModalContent>
{(nativeClose) => (
<>
{title && (
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
)}
<ModalBody>{content}</ModalBody>
<ModalFooter>
{showCancel && (
<Button
color="danger"
variant="light"
onPress={() => {
onCancel?.()
nativeClose()
}}
>
</Button>
)}
<Button
color="primary"
onPress={() => {
onConfirm?.()
nativeClose()
}}
>
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</NextUIModal>
)
})
Modal.displayName = 'Modal'
export default Modal

View File

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

View File

@@ -0,0 +1,172 @@
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import { ModalBody, ModalFooter } from '@heroui/modal'
import { Select, SelectItem } from '@heroui/select'
import { ReactElement, useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import type {
DefaultValues,
Path,
PathValue,
SubmitHandler
} from 'react-hook-form'
import toast from 'react-hot-toast'
import SwitchCard from '../switch_card'
export type FieldTypes = 'input' | 'select' | 'switch'
type NetworkConfigType = OneBotConfig['network']
export interface Field<T extends keyof OneBotConfig['network']> {
name: keyof NetworkConfigType[T][0]
label: string
type: FieldTypes
options?: Array<{ key: string; value: string }>
placeholder?: string
isRequired?: boolean
isDisabled?: boolean
description?: string
colSpan?: 1 | 2
}
export interface GenericFormProps<T extends keyof NetworkConfigType> {
data?: NetworkConfigType[T][0]
defaultValues: DefaultValues<NetworkConfigType[T][0]>
onClose: () => void
onSubmit: (data: NetworkConfigType[T][0]) => Promise<void>
fields: Array<Field<T>>
}
const GenericForm = <T extends keyof NetworkConfigType>({
data,
defaultValues,
onClose,
onSubmit,
fields
}: GenericFormProps<T>): ReactElement => {
const { control, handleSubmit, formState, setValue, reset } = useForm<
NetworkConfigType[T][0]
>({
defaultValues
})
const submitAction: SubmitHandler<NetworkConfigType[T][0]> = async (data) => {
await onSubmit(data)
onClose()
}
const _onSubmit = handleSubmit(submitAction, (e) => {
for (const error in e) {
toast.error(e[error]?.message as string)
return
}
})
useEffect(() => {
if (data) {
const keys = Object.keys(data) as Path<NetworkConfig[T][0]>[]
for (const key of keys) {
const value = data[key] as PathValue<
NetworkConfig[T][0],
Path<NetworkConfig[T][0]>
>
setValue(key, value)
}
} else {
reset()
}
}, [data, reset, setValue])
return (
<>
<ModalBody>
<div className="grid grid-cols-2 gap-y-4 gap-x-2 w-full">
{fields.map((field) => (
<div
key={field.name as string}
className={field.colSpan === 1 ? 'col-span-1' : 'col-span-2'}
>
<Controller
control={control}
name={field.name as Path<NetworkConfig[T][0]>}
rules={
field.isRequired
? {
required: `请填写${field.label}`
}
: void 0
}
render={({ field: controllerField }) => {
switch (field.type) {
case 'input':
return (
<Input
value={controllerField.value as string}
onChange={controllerField.onChange}
onBlur={controllerField.onBlur}
ref={controllerField.ref}
isRequired={field.isRequired}
isDisabled={field.isDisabled}
label={field.label}
placeholder={field.placeholder}
/>
)
case 'select':
return (
<Select
{...controllerField}
ref={controllerField.ref}
isRequired={field.isRequired}
label={field.label}
placeholder={field.placeholder}
selectedKeys={[controllerField.value as string]}
value={controllerField.value.toString()}
>
{field.options?.map((option) => (
<SelectItem key={option.key} value={option.value}>
{option.value}
</SelectItem>
)) || <></>}
</Select>
)
case 'switch':
return (
<SwitchCard
{...controllerField}
value={controllerField.value as boolean}
description={field.description}
label={field.label}
/>
)
default:
return <></>
}
}}
/>
</div>
))}
</div>
</ModalBody>
<ModalFooter>
<Button
color="danger"
isDisabled={formState.isSubmitting}
variant="light"
onPress={onClose}
>
</Button>
<Button
color="primary"
isLoading={formState.isSubmitting}
onPress={() => _onSubmit()}
>
</Button>
</ModalFooter>
</>
)
}
export default GenericForm

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
import { Modal, ModalContent, ModalHeader } from '@heroui/modal'
import toast from 'react-hot-toast'
import useConfig from '@/hooks/use-config'
import HTTPClientForm from './http_client'
import HTTPServerForm from './http_server'
import WebsocketClientForm from './ws_client'
import WebsocketServerForm from './ws_server'
const modalTitle = {
httpServers: 'HTTP Server',
httpClients: 'HTTP Client',
websocketServers: 'Websocket Server',
websocketClients: 'Websocket Client'
}
export interface NetworkFormModalProps<
T extends keyof OneBotConfig['network']
> {
isOpen: boolean
field: T
data?: OneBotConfig['network'][T][0]
onOpenChange: (isOpen: boolean) => void
}
const NetworkFormModal = <T extends keyof OneBotConfig['network']>(
props: NetworkFormModalProps<T>
) => {
const { isOpen, onOpenChange, field, data } = props
const { createNetworkConfig, updateNetworkConfig } = useConfig()
const isCreate = !data
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
try {
if (isCreate) {
await createNetworkConfig(field, data)
} else {
await updateNetworkConfig(field, data)
}
toast.success('保存配置成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存配置失败: ${msg}`)
throw error
}
}
const renderFormComponent = (onClose: () => void) => {
switch (field) {
case 'httpServers':
return (
<HTTPServerForm
data={data as OneBotConfig['network']['httpServers'][0]}
onClose={onClose}
onSubmit={onSubmit}
/>
)
case 'httpClients':
return (
<HTTPClientForm
data={data as OneBotConfig['network']['httpClients'][0]}
onClose={onClose}
onSubmit={onSubmit}
/>
)
case 'websocketServers':
return (
<WebsocketServerForm
data={data as OneBotConfig['network']['websocketServers'][0]}
onClose={onClose}
onSubmit={onSubmit}
/>
)
case 'websocketClients':
return (
<WebsocketClientForm
data={data as OneBotConfig['network']['websocketClients'][0]}
onClose={onClose}
onSubmit={onSubmit}
/>
)
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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
import { Chip } from '@heroui/chip'
import { Tooltip } from '@heroui/tooltip'
import { motion } from 'motion/react'
import React, { useState } from 'react'
import toast from 'react-hot-toast'
import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb'
import type { LiteralValue, ParsedSchema } from '@/utils/zod'
interface DisplayStructProps {
schema: ParsedSchema | ParsedSchema[]
}
const SchemaType = ({
type,
value
}: {
type: string
value?: LiteralValue
}) => {
let name = type
switch (type) {
case 'union':
name = '联合类型'
break
case 'value':
name = '固定值'
break
}
let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' =
'primary'
switch (type) {
case 'enum':
chipColor = 'warning'
break
case 'union':
chipColor = 'secondary'
break
case 'array':
chipColor = 'danger'
break
case 'object':
chipColor = 'success'
break
}
return (
<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"></div>
{children}
</motion.div>
</div>
)
}
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
if (schema.type === 'object') {
return (
<SchemaContainer schema={schema}>
{schema.children && schema.children.length > 0 ? (
schema.children.map((child, i) => (
<RenderSchema key={child.name || i} schema={child} />
))
) : (
<div>{`{}`}</div>
)}
</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="p-4 bg-content2 rounded-lg bg-opacity-50">
{Array.isArray(schema) ? (
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
) : (
<RenderSchema schema={schema} />
)}
</div>
)
}
export default DisplayStruct

View File

@@ -0,0 +1,87 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import clsx from 'clsx'
import { motion } from 'motion/react'
import { useState } from 'react'
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'
export interface OneBotApiNavListProps {
data: OneBotHttpApi
selectedApi: OneBotHttpApiPath
onSelect: (apiName: OneBotHttpApiPath) => void
openSideBar: boolean
}
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar } = props
const [searchValue, setSearchValue] = useState('')
return (
<motion.div
className={clsx(
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
openSideBar &&
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
)}
initial={{ width: 0 }}
transition={{
type: openSideBar ? 'spring' : 'tween',
stiffness: 150,
damping: 15
}}
animate={{ width: openSideBar ? '16rem' : '0rem' }}
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<div className="w-64 h-full overflow-y-auto px-2 float-right">
<Input
className="sticky top-0 z-10 text-danger-600"
classNames={{
inputWrapper:
'bg-opacity-30 bg-danger-50 backdrop-blur-sm border border-danger-300 mb-2',
input: 'bg-transparent !text-danger-400 !placeholder-danger-400'
}}
radius="full"
placeholder="搜索 API"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
isClearable
onClear={() => setSearchValue('')}
/>
{Object.entries(data).map(([apiName, api]) => (
<Card
key={apiName}
shadow="none"
className={clsx(
'w-full border border-danger-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-danger-400',
{
hidden: !(
apiName.includes(searchValue) ||
api.description?.includes(searchValue)
)
},
{
'!bg-opacity-40 border border-danger-400 bg-danger-50 text-danger-600':
apiName === selectedApi
}
)}
isPressable
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className="font-ubuntu font-bold">{api.description}</h2>
<div
className={clsx('text-sm text-danger-200', {
'!text-danger-400': apiName === selectedApi
})}
>
{apiName}
</div>
</CardBody>
</Card>
))}
</div>
</motion.div>
)
}
export default OneBotApiNavList

View File

@@ -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}/640/`}
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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,151 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import { Snippet } from '@heroui/snippet'
import { motion } from 'motion/react'
import { IoCode } from 'react-icons/io5'
import OneBotDisplayMeta from '@/components/onebot/display_card/meta'
import { getEventName, isOB11Event } from '@/utils/onebot'
import { timestampToDateString } from '@/utils/time'
import type {
AllOB11WsResponse,
OB11AllEvent,
OB11Request
} from '@/types/onebot'
import OneBotMessage from './message'
import OneBotNotice from './notice'
import OneBotDisplayResponse from './response'
const itemVariants = {
hidden: { opacity: 0, scale: 0.8, y: 50 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 300, damping: 20 }
}
}
function RequestComponent({ data: _ }: { data: OB11Request }) {
return <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-opacity-50 backdrop-blur-sm">
<CardHeader className="py-0 text-default-500 flex-row gap-2">
<div className="font-bold">
{isEvent ? getEventName(msg.post_type) : '请求响应'}
</div>
<div className="text-sm">
{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="danger"
variant="flat"
radius="full"
isIconOnly
className="text-medium"
>
<IoCode />
</Button>
</PopoverTrigger>
<PopoverContent>
<Snippet
hideSymbol
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

View File

@@ -0,0 +1,75 @@
import { Button } from '@heroui/button'
import { Chip } from '@heroui/chip'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
import { Snippet } from '@heroui/snippet'
import { getResponseStatusColor, getResponseStatusText } from '@/utils/onebot'
import { RequestResponse } from '@/types/onebot'
export interface OneBotDisplayResponseProps {
data: RequestResponse
}
const OneBotDisplayResponse: React.FC<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="danger"
variant="flat"
radius="full"
className="text-medium"
>
</Button>
</PopoverTrigger>
<PopoverContent>
<Snippet
hideSymbol
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

View File

@@ -0,0 +1,58 @@
import { Select, SelectItem } from '@heroui/select'
import { SharedSelection } from '@heroui/system'
import type { Selection } from '@react-types/shared'
export interface FilterMessageTypeProps {
filterTypes: Selection
onSelectionChange: (keys: SharedSelection) => void
}
const items = [
{ label: '元事件', value: 'meta_event' },
{ label: '消息', value: 'message' },
{ label: '请求', value: 'request' },
{ label: '通知', value: 'notice' },
{ label: '消息发送', value: 'message_sent' }
]
const FilterMessageType: React.FC<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"
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

View File

@@ -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

View File

@@ -0,0 +1,164 @@
import { Image } from '@heroui/image'
import qface from 'qface'
import { FaReply } from 'react-icons/fa6'
import { OB11Segment } from '@/types/onebot'
export const renderMessageContent = (
segments: OB11Segment[],
small = false
): React.ReactElement[] => {
return segments.map((segment, index) => {
switch (segment.type) {
case 'text':
return <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>
}
})
}

View File

@@ -0,0 +1,92 @@
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 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="danger" radius="full" variant="flat">
</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 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100">
<CodeEditor
height="100%"
defaultLanguage="json"
defaultValue={`{
"action": "get_group_list"
}`}
ref={editorRef}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button
color="danger"
onPress={() => handleSendMessage(onClose)}
>
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)
}
export default OneBotSendModal

View File

@@ -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' | 'danger' | '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>
<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="danger" />
}
if (state === ReadyState.CONNECTING) {
return <StatusTag title="连接中" color="warning" />
}
if (state === ReadyState.CLOSING) {
return <StatusTag title="关闭中" color="warning" />
}
return null
}

View File

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

View File

@@ -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-50 flex justify-center items-center backdrop-blur',
{
hidden: !loading
}
)}
>
<Spinner size="lg" />
</div>
)
}
export default PageLoading

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import clsx from 'clsx'
import { motion } from 'motion/react'
import React from 'react'
import { IoMdLogOut } from 'react-icons/io'
import { MdDarkMode, MdLightMode } from 'react-icons/md'
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png'
import type { MenuItem } from '@/config/site'
import { title } from '../primitives'
import Menus from './menus'
interface SideBarProps {
open: boolean
items: MenuItem[]
}
const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items } = props
const { toggleTheme, isDark } = useTheme()
const { revokeAuth } = useAuth()
const dialog = useDialog()
const onRevokeAuth = () => {
dialog.confirm({
title: '退出登录',
content: '确定要退出登录吗?',
onConfirm: revokeAuth
})
}
return (
<motion.div
className={clsx(
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
)}
initial={{ width: 0 }}
animate={{ width: open ? '16rem' : 0 }}
transition={{
type: open ? 'spring' : 'tween',
stiffness: 150,
damping: open ? 15 : 10
}}
style={{ overflow: 'hidden' }}
>
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
<div className="flex justify-center items-center mt-2 gap-2">
<Image height={40} src={logo} className="mb-2" />
<div
className={clsx(
'flex items-center hm-medium',
title({
shadow: true,
color: isDark ? 'violet' : 'pink'
}),
'!text-2xl'
)}
>
WebUI
</div>
</div>
<div className="overflow-y-auto flex flex-col flex-1 px-4">
<Menus items={items} />
<div className="mt-auto mb-10 md:mb-0">
<Button
className="w-full"
color="danger"
radius="full"
variant="light"
onPress={toggleTheme}
startContent={
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
}
>
</Button>
<Button
className="w-full mb-2"
color="danger"
radius="full"
variant="light"
onPress={onRevokeAuth}
startContent={<IoMdLogOut size={16} />}
>
退
</Button>
</div>
</div>
</motion.div>
</motion.div>
)
}
export default SideBar

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import { FaCircleInfo } from 'react-icons/fa6'
import { FaQq } from 'react-icons/fa6'
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
import { RiMacFill } from 'react-icons/ri'
import WebUIManager from '@/controllers/webui_manager'
import packageJson from '../../package.json'
export interface SystemInfoItemProps {
title: string
icon?: React.ReactNode
value?: React.ReactNode
}
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
title,
value = '--',
icon
}) => {
return (
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
{icon}
<div className="w-24">{title}</div>
<div className="text-danger-200">{value}</div>
</div>
)
}
export interface SystemInfoProps {
archInfo?: string
}
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
const { archInfo } = props
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const {
data: qqVersionData,
loading: qqVersionLoading,
error: qqVersionError
} = useRequest(WebUIManager.getQQVersion)
return (
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 overflow-visible flex-1">
<CardHeader className="pb-0 items-center gap-1 text-danger-500 font-extrabold">
<FaCircleInfo className="text-lg" />
<span></span>
</CardHeader>
<CardBody className="flex-1">
<div className="flex flex-col justify-between h-full">
<SystemInfoItem
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
packageData?.version
)
}
/>
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value={packageJson.version}
/>
<SystemInfoItem
title="QQ 版本"
icon={<FaQq className="text-lg" />}
value={
qqVersionError ? (
`错误:${qqVersionError.message}`
) : qqVersionLoading ? (
<Spinner size="sm" />
) : (
qqVersionData
)
}
/>
<SystemInfoItem
title="系统版本"
icon={<RiMacFill className="text-xl" />}
value={archInfo}
/>
</div>
</CardBody>
</Card>
)
}
export default SystemInfo

View File

@@ -0,0 +1,130 @@
import { Card, CardBody } from '@heroui/card'
import { Image } from '@heroui/image'
import clsx from 'clsx'
import { BiSolidMemoryCard } from 'react-icons/bi'
import { GiCpu } from 'react-icons/gi'
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'
import UsagePie from './usage_pie'
export interface SystemStatusItemProps {
title: string
value?: string | number
size?: 'md' | 'lg'
unit?: string
}
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
title,
value = '-',
size = 'md',
unit
}) => {
return (
<div
className={clsx(
'shadow-sm p-2 rounded-md text-sm bg-content1 bg-opacity-30',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
)}
>
<div className="w-24">{title}</div>
<div className="text-default-400">
{value}
{unit}
</div>
</div>
)
}
export interface SystemStatusDisplayProps {
data?: SystemStatus
}
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
const memoryUsage = {
system: 0,
qq: 0
}
if (data) {
const system = Number(data.memory.total) || 1
const systemUsage = Number(data.memory.usage.system)
const qqUsage = Number(data.memory.usage.qq)
memoryUsage.system = (systemUsage / system) * 100
memoryUsage.qq = (qqUsage / system) * 100
}
return (
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 col-span-1 lg:col-span-2 relative overflow-hidden">
<div className="absolute h-full right-0 top-0">
<Image
src={bkg}
alt="background"
className="select-none pointer-events-none !opacity-30 w-full h-full"
classNames={{
wrapper: 'w-full h-full',
img: 'object-contain w-full h-full'
}}
/>
</div>
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
<div className="flex-1 w-full md:max-w-96">
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400">
<GiCpu className="text-xl" />
<span>CPU</span>
</h2>
<div className="grid grid-cols-2 gap-2">
<SystemStatusItem title="型号" value={data?.cpu.model} size="lg" />
<SystemStatusItem title="内核数" value={data?.cpu.core} />
<SystemStatusItem title="主频" value={data?.cpu.speed} unit="GHz" />
<SystemStatusItem
title="使用率"
value={data?.cpu.usage.system}
unit="%"
/>
<SystemStatusItem
title="QQ主线程"
value={data?.cpu.usage.qq}
unit="%"
/>
</div>
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400 mt-2">
<BiSolidMemoryCard className="text-xl" />
<span></span>
</h2>
<div className="grid grid-cols-2 gap-2">
<SystemStatusItem
title="总量"
value={data?.memory.total}
size="lg"
unit="MB"
/>
<SystemStatusItem
title="使用量"
value={data?.memory.usage.system}
unit="MB"
/>
<SystemStatusItem
title="QQ主线程"
value={data?.memory.usage.qq}
unit="MB"
/>
</div>
</div>
<div className="flex flex-row md:flex-col gap-2 flex-shrink-0 w-full justify-center md:w-40 min-h-40 mt-4 md:mt-0 md:mx-auto">
<UsagePie
systemUsage={Number(data?.cpu.usage.system) || 0}
processUsage={Number(data?.cpu.usage.qq) || 0}
title="CPU占用"
/>
<UsagePie
systemUsage={memoryUsage.system}
processUsage={memoryUsage.qq}
title="内存占用"
/>
</div>
</CardBody>
</Card>
)
}
export default SystemStatusDisplay

View File

@@ -0,0 +1,85 @@
import { SwitchProps, useSwitch } from '@heroui/switch'
import { VisuallyHidden } from '@react-aria/visually-hidden'
import clsx from 'clsx'
import { FC, useEffect, useState } from 'react'
import { MoonFilledIcon, SunFilledIcon } from '@/components/icons'
import { useTheme } from '@/hooks/use-theme'
export interface ThemeSwitchProps {
className?: string
classNames?: SwitchProps['classNames']
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames
}) => {
const [isMounted, setIsMounted] = useState(false)
const { theme, toggleTheme } = useTheme()
const onChange = toggleTheme
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps
} = useSwitch({
isSelected: theme === 'light',
onChange
})
useEffect(() => {
setIsMounted(true)
}, [isMounted])
// Prevent Hydration Mismatch
if (!isMounted) return <div className="w-6 h-6" />
return (
<Component
aria-label={isSelected ? 'Switch to dark mode' : 'Switch to light mode'}
{...getBaseProps({
className: clsx(
'px-px transition-opacity hover:opacity-80 cursor-pointer',
className,
classNames?.base
)
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
'w-auto h-auto',
'bg-transparent',
'rounded-lg',
'flex items-center justify-center',
'group-data-[selected=true]:bg-transparent',
'!text-default-500',
'pt-px',
'px-0',
'mx-0'
],
classNames?.wrapper
)
})}
>
{isSelected ? (
<MoonFilledIcon size={22} />
) : (
<SunFilledIcon size={22} />
)}
</div>
</Component>
)
}

View File

@@ -0,0 +1,21 @@
import { Toaster as HotToaster } from 'react-hot-toast'
import { useTheme } from '@/hooks/use-theme'
export const Toaster = () => {
const { isDark } = useTheme()
return (
<HotToaster
toastOptions={{
style: {
borderRadius: '20px',
background: isDark ? '#333' : '#fff',
color: isDark ? '#fff' : '#333'
}
}}
/>
)
}
export default Toaster

View File

@@ -0,0 +1,143 @@
import * as echarts from 'echarts'
import React, { useEffect, useRef } from 'react'
import { useTheme } from '@/hooks/use-theme'
interface UsagePieProps {
systemUsage: number
processUsage: number
title?: string
}
const defaultOption: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: '<center>{b}<br/><b>{d}%</b></center>',
borderRadius: 10,
extraCssText: 'backdrop-filter: blur(10px);'
},
series: [
{
name: '系统占用',
type: 'pie',
radius: ['70%', '90%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: '系统占用',
fontSize: 14
},
itemStyle: {
borderWidth: 1,
borderRadius: 10
},
labelLine: {
show: false
},
data: [
{
value: 100,
name: '系统总量'
}
]
}
]
}
const UsagePie: React.FC<UsagePieProps> = ({
systemUsage,
processUsage,
title
}) => {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
const { theme } = useTheme()
useEffect(() => {
if (chartRef.current) {
chartInstance.current = echarts.init(chartRef.current)
const option = defaultOption
chartInstance.current.setOption(option)
const observer = new ResizeObserver(() => {
chartInstance.current?.resize()
})
observer.observe(chartRef.current)
return () => {
chartInstance.current?.dispose()
observer.disconnect()
}
}
}, [])
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
series: [
{
label: {
formatter: title
}
}
]
})
}
}, [title])
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
darkMode: theme === 'dark',
tooltip: {
backgroundColor:
theme === 'dark'
? 'rgba(0, 0, 0, 0.8)'
: 'rgba(255, 255, 255, 0.8)',
textStyle: {
color: theme === 'dark' ? '#fff' : '#333'
}
},
color:
theme === 'dark'
? ['#D33FF0', '#EF8664', '#E25180']
: ['#D33FF0', '#EA7D9B', '#FFC107'],
series: [
{
itemStyle: {
borderColor: theme === 'dark' ? '#333' : '#F0A9A7'
}
}
]
})
}
}, [theme])
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
series: [
{
data: [
{
value: processUsage,
name: 'QQ占用'
},
{
value: systemUsage - processUsage,
name: '其他进程占用'
},
{
value: 100 - systemUsage,
name: '剩余系统总量'
}
]
}
]
})
}
}, [systemUsage, processUsage])
return <div ref={chartRef} className="w-36 h-36 flex-shrink-0" />
}
export default UsagePie

View File

@@ -1,126 +0,0 @@
<template>
<t-menu theme="light" :width="menuWidth" :collapsed="collapsed" class="sidebar-menu">
<template #logo>
<div class="logo">
<img class="logo-img" :width="collapsed ? 35 : 'auto'" src="@/assets/logo_webui.png" alt="logo" />
<div class="logo-textBox">
<div class="logo-text">{{ collapsed ? '' : 'NapCat' }}</div>
</div>
</div>
</template>
<router-link v-for="item in menuItems" :key="item.value" :to="item.route">
<t-tooltip :disabled="!collapsed" :content="item.label" placement="right">
<t-menu-item :value="item.value" :disabled="item.disabled" class="menu-item">
<template #icon>
<t-icon :name="item.icon" />
</template>
{{ item.label }}
</t-menu-item>
</t-tooltip>
</router-link>
<template #operations>
<t-button
:disabled="disBtn"
class="t-demo-collapse-btn"
variant="text"
shape="square"
@click="changeCollapsed"
>
<template #icon><t-icon :name="iconName" /></template>
</t-button>
</template>
</t-menu>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import emitter from '@/ts/event-bus';
type MenuItem = {
value: string;
label: string;
route: string;
icon?: string;
disabled?: boolean;
};
defineProps<{
menuItems: MenuItem[];
menuWidth: string | number | Array<string | number>;
}>();
const mediaQuery = window.matchMedia('(max-width: 800px)');
const collapsed = ref<boolean>(localStorage.getItem('sidebar-collapsed') === 'true');
const iconName = ref<string>(collapsed.value ? 'menu-unfold' : 'menu-fold');
const disBtn = ref<boolean>(false);
const changeCollapsed = (): void => {
collapsed.value = !collapsed.value;
iconName.value = collapsed.value ? 'menu-unfold' : 'menu-fold';
localStorage.setItem('sidebar-collapsed', collapsed.value.toString());
};
watch(collapsed, (newValue, oldValue) => {
emitter.emit('sendMenu', collapsed.value);
});
onMounted(() => {
emitter.emit('sendMenu', collapsed.value);
const handleMediaChange = (e: MediaQueryListEvent) => {
disBtn.value = e.matches;
if (e.matches) {
collapsed.value = e.matches;
}
};
mediaQuery.addEventListener('change', handleMediaChange);
const event = new Event('change');
Object.defineProperty(event, 'matches', {
value: mediaQuery.matches,
writable: false,
});
mediaQuery.dispatchEvent(event);
return () => {
mediaQuery.removeEventListener('change', handleMediaChange);
};
});
</script>
<style scoped>
.sidebar-menu {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 200px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
.sidebar-menu {
width: 100px; /* 移动端侧边栏宽度 */
}
}
.logo {
display: flex;
width: auto;
height: 100%;
}
.logo-img {
object-fit: contain;
margin-top: 8px;
margin-bottom: 8px;
}
.logo-textBox {
display: flex;
align-items: center;
margin-left: 10px;
}
.logo-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 22px;
font-family: Sotheby, Helvetica, monospace;
}
.menu-item {
margin-bottom: 10px;
}
</style>

View File

@@ -1,35 +0,0 @@
<template>
<t-head-menu theme="light" class="bottom-menu">
<router-link v-for="item in menuItems" :key="item.value" :to="item.route">
<t-tooltip :content="item.label" placement="top">
<t-menu-item :value="item.value" :disabled="item.disabled" class="menu-item">
<template #icon>
<t-icon :name="item.icon" />
</template>
<!-- {{item.label}}-->
</t-menu-item>
</t-tooltip>
</router-link>
</t-head-menu>
</template>
<script setup lang="ts">
type MenuItem = {
value: string;
label: string;
route: string;
icon?: string;
disabled?: boolean;
};
defineProps<{
menuItems: MenuItem[];
}>();
</script>
<style scoped>
.bottom-menu {
display: flex;
justify-content: center;
border-top: 0.8px solid #ddd;
}
</style>

View File

@@ -0,0 +1,137 @@
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import clsx from 'clsx'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTheme } from '@/hooks/use-theme'
import { gradientText } from '@/utils/terminal'
export type XTermRef = {
write: (
...args: Parameters<Terminal['write']>
) => ReturnType<Terminal['write']>
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>
writeln: (
...args: Parameters<Terminal['writeln']>
) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void
}
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Noto Serif SC", monospace' // 添加回退字体
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(new WebLinksAddon())
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
fitAddon.fit()
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
resizeObserver.observe(domRef.current)
const handleFontLoad = () => {
terminal.refresh(0, terminal.rows - 1)
}
document.fonts.addEventListener('loadingdone', handleFontLoad)
return () => {
resizeObserver.disconnect()
document.fonts.removeEventListener('loadingdone', handleFontLoad)
setTimeout(() => {
terminal.dispose()
}, 0)
}
}, [])
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background:
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground: theme === 'dark' ? '#666' : '#ddd',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}),
[]
)
return (
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
}
)
export default XTerm

View File

@@ -0,0 +1,92 @@
import {
BugIcon2,
InfoIcon,
RouteIcon,
SettingsIcon,
SignalTowerIcon,
TerminalIcon
} from '@/components/icons'
export type SiteConfig = typeof siteConfig
export interface MenuItem {
label: string
icon?: React.ReactNode
autoOpen?: boolean
href?: string
items?: MenuItem[]
customIcon?: string
}
export const siteConfig = {
name: 'NapCat WebUI',
description: 'NapCat WebUI.',
navItems: [
{
label: '基础信息',
icon: (
<div className="w-5 h-5">
<RouteIcon />
</div>
),
href: '/'
},
{
label: '网络配置',
icon: (
<div className="w-5 h-5">
<SignalTowerIcon />
</div>
),
href: '/network'
},
{
label: '其他配置',
icon: (
<div className="w-5 h-5">
<SettingsIcon />
</div>
),
href: '/config'
},
{
label: '系统日志',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
</div>
),
href: '/logs'
},
{
label: '接口调试',
icon: (
<div className="w-5 h-5">
<BugIcon2 />
</div>
),
items: [
{
label: 'HTTP',
href: '/debug/http'
},
{
label: 'Websocket',
href: '/debug/ws'
}
]
},
{
label: '关于我们',
icon: (
<div className="w-5 h-5">
<InfoIcon />
</div>
),
href: '/about'
}
] as MenuItem[],
links: {
github: 'https://github.com/NapNeko/NapCatQQ',
docs: 'https://napcat.napneko.icu/'
}
}

View File

@@ -0,0 +1,13 @@
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal'
}
export enum PlayMode {
Loop = 'loop',
Random = 'random',
Single = 'single'
}

View File

@@ -0,0 +1,13 @@
enum key {
token = 'token',
theme = 'theme',
backgroundImage = 'background-image',
musicID = 'music-id',
showMusicTip = 'show-music-tip',
autoPlay = 'auto-play',
customIcons = 'custom-icons',
isCollapsedMusicPlayer = 'is-collapsed-music-player',
sideBarOpen = 'side-bar-open'
}
export default key

View File

@@ -0,0 +1,744 @@
import { z } from 'zod'
import messageNodeSchema from './message/node'
import { baseResponseSchema, commonResponseDataSchema } from './response'
const oneBotHttpApiGroup = {
'/set_group_kick': {
description: '群踢人',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
reject_add_request: z.boolean().describe('拒绝此人的加群请求')
}),
response: baseResponseSchema
},
'/set_group_ban': {
description: '群禁言',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
duration: z.number()
}),
response: baseResponseSchema
},
'/get_group_system_msg': {
description: '获取群系统消息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.object({
InvitedRequest: z
.array(
z
.object({
request_id: z.string().describe('请求 ID'),
invitor_uin: z.string().describe('邀请人 QQ 号'),
invitor_nick: z.string().describe('邀请人昵称'),
group_id: z.string().describe('群号'),
group_name: z.string().describe('群名称'),
checked: z.boolean().describe('是否已处理'),
actor: z.string().describe('处理人 QQ 号')
})
.describe('邀请入群请求')
)
.describe('邀请入群请求列表'),
join_requests: z.array(
z.object({
request_id: z.string().describe('请求 ID'),
requester_uin: z.string().describe('请求人 QQ 号'),
requester_nick: z.string().describe('请求人昵称'),
group_id: z.string().describe('群号'),
group_name: z.string().describe('群名称'),
checked: z.boolean().describe('是否已处理'),
actor: z.string().describe('处理人 QQ 号')
})
)
})
})
},
'/get_essence_msg_list': {
description: '获取精华消息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z
.array(
z
.object({
msg_seq: z.number().describe('消息序号'),
msg_random: z.number().describe('消息随机数'),
sender_id: z.number().describe('发送人 QQ 号'),
sender_nick: z.string().describe('发送人昵称'),
operator_id: z.number().describe('操作人 QQ 号'),
operator_nick: z.string().describe('操作人昵称'),
message_id: z.string().describe('消息 ID'),
operator_time: z.string().describe('操作时间'),
content: z.array(messageNodeSchema)
})
.describe('精华消息')
)
.describe('精华消息列表')
})
},
'/set_group_whole_ban': {
description: '全员禁言',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
enable: z.boolean().describe('是否开启')
}),
response: baseResponseSchema
},
'/set_group_portrait': {
description: '设置群头像',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
file: z.string().describe('图片文件路径,服务器本地路径或远程 URL')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/set_group_admin': {
description: '设置群管理',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
enable: z.boolean().describe('是否设置为管理员')
}),
response: baseResponseSchema
},
'/set_essence_msg': {
description: '设置群精华消息',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息 ID')
}),
response: baseResponseSchema.extend({
data: z.object({
errCode: z.number().describe('错误码'),
errMsg: z.string().describe('错误信息'),
result: z
.object({
wording: z.string().describe('?'),
digestUin: z.string().describe('?QQ号'),
digestTime: z.number().describe('设置时间?'),
msg: z
.object({
groupCode: z.string().describe('群号'),
msgSeq: z.number().describe('消息序号'),
msgRandom: z.number().describe('消息随机数'),
msgContent: z.array(messageNodeSchema).describe('消息内容'),
textSize: z.string().describe('文本大小'),
picSize: z.string().describe('图片大小'),
videoSize: z.string().describe('视频大小'),
senderUin: z.string().describe('发送人 QQ 号'),
senderTime: z.number().describe('发送时间'),
addDigestUin: z.string().describe('添加精华消息人 QQ 号'),
addDigestTime: z.number().describe('添加精华消息时间'),
startTime: z.number().describe('开始时间'),
latestMsgSeq: z.number().describe('最新消息序号'),
opType: z.number().describe('操作类型')
})
.describe('消息内容'),
errorCode: z.number().describe('错误码')
})
.describe('结果')
})
})
},
'/set_group_card': {
description: '设置群成员名片',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
card: z.string().describe('名片')
}),
response: baseResponseSchema
},
'/delete_essence_msg': {
description: '删除群精华消息',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息 ID')
}),
response: baseResponseSchema.extend({
data: z.object({
errCode: z.number().describe('错误码'),
errMsg: z.string().describe('错误信息'),
result: z
.object({
wording: z.string().describe('?'),
digestUin: z.string().describe('?QQ号'),
digestTime: z.number().describe('设置时间?'),
msg: z.object({
groupCode: z.string().describe('群号'),
msgSeq: z.number().describe('消息序号'),
msgRandom: z.number().describe('消息随机数'),
msgContent: z.array(messageNodeSchema).describe('消息内容'),
textSize: z.string().describe('文本大小'),
picSize: z.string().describe('图片大小'),
videoSize: z.string().describe('视频大小'),
senderUin: z.string().describe('发送人 QQ 号'),
senderTime: z.number().describe('发送时间'),
addDigestUin: z.string().describe('添加精华消息人 QQ 号'),
addDigestTime: z.number().describe('添加精华消息时间'),
startTime: z.number().describe('开始时间'),
latestMsgSeq: z.number().describe('最新消息序号'),
opType: z.number().describe('操作类型')
}),
errorCode: z.number().describe('错误码')
})
.describe('结果')
})
})
},
'/set_group_name': {
description: '设置群名称',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
group_name: z.string().describe('群名称')
}),
response: baseResponseSchema
},
'/set_group_leave': {
description: '退出群聊',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema
},
'/_send_group_notice': {
description: '发送群公告',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
content: z.string().describe('公告内容'),
image: z.string().optional().describe('图片地址')
}),
response: baseResponseSchema
},
'/_get_group_notice': {
description: '获取群公告',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.array(
z.object({
notice_id: z.string().describe('公告 ID'),
sender_id: z.number().describe('发送人 QQ 号'),
publish_time: z.number().describe('发布时间'),
message: z.object({
text: z.string().describe('文本内容'),
image: z
.array(
z
.object({
id: z.string().describe('图片 ID'),
height: z.string().describe('高度'),
width: z.string().describe('宽度')
})
.describe('图片信息')
)
.describe('图片内容列表')
})
})
)
})
},
'/set_group_special_title': {
description: '设置群成员专属头衔',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
special_title: z.string().describe('专属头衔内容')
}),
response: baseResponseSchema
},
'/upload_group_file': {
description: '上传群文件',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
file: z.string().describe('文件路径'),
name: z.string().describe('文件名'),
folder_id: z.string().describe('文件夹 ID')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/set_group_add_request': {
description: '处理加群请求',
request: z.object({
flag: z.string().describe('请求ID'),
approve: z.boolean().describe('是否同意'),
reason: z.string().optional().describe('拒绝理由')
}),
response: baseResponseSchema
},
'/get_group_info': {
description: '获取群信息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.object({})
})
},
'/get_group_info_ex': {
description: '获取群信息扩展',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z
.object({
groupCode: z.string().describe('群号'),
resultCode: z.number().describe('结果码'),
extInfo: z
.object({
groupInfoExtSeq: z.number().describe('群信息序列号'),
reserve: z.number().describe('?'),
luckyWordId: z.string().describe('幸运字符ID'),
lightCharNum: z.number().describe('?'),
luckyWord: z.string().describe('幸运字符'),
starId: z.number().describe('?'),
essentialMsgSwitch: z.number().describe('精华消息开关'),
todoSeq: z.number().describe('?'),
blacklistExpireTime: z.number().describe('黑名单过期时间'),
isLimitGroupRtc: z.number().describe('是否限制群视频通话'),
companyId: z.number().describe('公司ID'),
hasGroupCustomPortrait: z.number().describe('是否有群自定义头像'),
bindGuildId: z.string().describe('绑定频道ID'),
groupOwnerId: z
.object({
memberUin: z.string().describe('群主QQ号'),
memberUid: z.string().describe('群主ID'),
memberQid: z.string().describe('群主QID')
})
.describe('群主信息'),
essentialMsgPrivilege: z.number().describe('精华消息权限'),
msgEventSeq: z.string().describe('消息事件序列号'),
inviteRobotSwitch: z.number().describe('邀请机器人开关'),
gangUpId: z.string().describe('?'),
qqMusicMedalSwitch: z.number().describe('QQ音乐勋章开关'),
showPlayTogetherSwitch: z.number().describe('显示一起玩开关'),
groupFlagPro1: z.string()?.describe('群标识1'),
groupBindGuildIds: z
.object({
guildIds: z.array(z.string())
})
.describe('绑定频道ID列表?'),
viewedMsgDisappearTime: z.string().describe('消息消失时间'),
groupExtFlameData: z.object({
switchState: z.number().describe('开关状态'),
state: z.number().describe('状态'),
dayNums: z.array(z.number()).describe('天数列表'),
version: z.number().describe('版本号'),
updateTime: z.string().describe('更新时间'),
isDisplayDayNum: z.boolean().describe('是否显示天数')
}),
groupBindGuildSwitch: z.number().describe('绑定频道开关'),
groupAioBindGuildId: z.string().describe('AIO绑定频道ID'),
groupExcludeGuildIds: z
.object({
guildIds: z.array(z.string()).describe('排除频道ID')
})
.describe('排除频道ID列表?'),
fullGroupExpansionSwitch: z.number().describe('全员群扩容开关'),
fullGroupExpansionSeq: z.string().describe('全员群扩容序列号'),
inviteRobotMemberSwitch: z
.number()
.describe('邀请机器人成员开关'),
inviteRobotMemberExamine: z
.number()
.describe('邀请机器人成员审核'),
groupSquareSwitch: z.number().describe('群广场开关')
})
.describe('扩展信息')
})
.describe('结果')
})
},
'/create_group_file_folder': {
description: '创建群文件夹',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
folder_name: z.string().describe('文件夹名称')
}),
response: baseResponseSchema.extend({
data: z
.object({
result: z
.object({
retCode: z.number().describe('结果码'),
retMsg: z.string().describe('结果信息'),
clientWording: z.string().describe('客户端提示')
})
.describe('结果'),
groupItem: z
.object({
peerId: z.string().describe('?'),
type: z.string().describe('类型'),
folderInfo: z
.object({
folderId: z.string().describe('文件夹 ID'),
parentFolderId: z.string().describe('父文件夹 ID'),
folderName: z.string().describe('文件夹名称'),
createTime: z.number().describe('创建时间'),
modifyTime: z.number().describe('修改时间'),
createUin: z.string().describe('创建人 QQ 号'),
creatorName: z.string().describe('创建人昵称'),
totalFileCount: z.string().describe('文件总数'),
modifyUin: z.string().describe('修改人 QQ 号'),
modifyName: z.string().describe('修改人昵称'),
usedSpace: z.string().describe('已使用空间')
})
.describe('文件夹信息')
})
.describe('群文件夹信息')
})
.describe('数据')
})
},
'/delete_group_file': {
description: '删除群文件',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
file_id: z.string().describe('文件 ID')
}),
response: baseResponseSchema.extend({
data: z
.object({
result: z.number().describe('结果码'),
errMsg: z.string().describe('错误信息'),
transGroupFileResult: z
.object({
result: z
.object({
retCode: z.number().describe('结果码'),
retMsg: z.string().describe('结果信息'),
clientWording: z.string().describe('客户端提示')
})
.describe('结果'),
successFileIdList: z
.array(z.string())
.describe('成功文件 ID 列表'),
failFileIdList: z.array(z.string()).describe('失败文件 ID 列表')
})
.describe('删除群文件结果')
})
.describe('结果')
})
},
'/delete_group_folder': {
description: '删除群文件夹',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
folder_id: z.string().describe('文件夹 ID')
}),
response: baseResponseSchema.extend({
data: z.object({
retCode: z.number().describe('结果码'),
retMsg: z.string().describe('结果信息'),
clientWording: z.string().describe('客户端提示')
})
})
},
'/get_group_file_system_info': {
description: '获取群文件系统信息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.object({
file_count: z.number().describe('文件总数'),
limit_count: z.number().describe('文件总数限制'),
used_space: z.number().describe('已使用空间'),
total_space: z.number().describe('总空间')
})
})
},
'/get_group_root_files': {
description: '获取群根目录文件列表',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.array(
z.object({
files: z
.array(
z
.object({
group_id: z.number().describe('群号'),
file_id: z.string().describe('文件 ID'),
file_name: z.string().describe('文件名'),
busid: z.number().describe('?'),
size: z.number().describe('文件大小'),
upload_time: z.number().describe('上传时间'),
dead_time: z.number().describe('过期时间'),
modify_time: z.number().describe('修改时间'),
download_times: z.number().describe('下载次数'),
uploader: z.number().describe('上传人 QQ 号'),
uploader_name: z.string().describe('上传人昵称')
})
.describe('文件信息')
)
.describe('文件列表'),
folders: z
.array(
z
.object({
group_id: z.number().describe('群号'),
folder_id: z.string().describe('文件夹 ID'),
folder: z.string().describe('文件夹?'),
folder_name: z.string().describe('文件夹名称'),
create_time: z.string().describe('创建时间'),
creator: z.string().describe('创建人 QQ 号'),
creator_name: z.string().describe('创建人昵称'),
total_file_count: z.string().describe('文件总数')
})
.describe('文件夹信息')
)
.describe('文件夹列表')
})
)
})
},
'/get_group_files_by_folder': {
description: '获取群子目录文件列表',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
folder_id: z.string().describe('文件夹 ID'),
file_count: z.number().describe('文件数量')
}),
response: baseResponseSchema.extend({
data: z.object({
files: z
.array(
z
.object({
group_id: z.number().describe('群号'),
file_id: z.string().describe('文件 ID'),
file_name: z.string().describe('文件名'),
busid: z.number().describe('?'),
size: z.number().describe('文件大小'),
upload_time: z.number().describe('上传时间'),
dead_time: z.number().describe('过期时间'),
modify_time: z.number().describe('修改时间'),
download_times: z.number().describe('下载次数'),
uploader: z.number().describe('上传人 QQ 号'),
uploader_name: z.string().describe('上传人昵称')
})
.describe('文件信息')
)
.describe('文件列表'),
folders: z
.array(
z
.object({
group_id: z.number().describe('群号'),
folder_id: z.string().describe('文件夹 ID'),
folder: z.string().describe('文件夹?'),
folder_name: z.string().describe('文件夹名称'),
create_time: z.string().describe('创建时间'),
creator: z.string().describe('创建人 QQ 号'),
creator_name: z.string().describe('创建人昵称'),
total_file_count: z.string().describe('文件总数')
})
.describe('文件夹信息')
)
.describe('文件夹列表')
})
})
},
'/get_group_file_url': {
description: '获取群文件下载链接',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
file_id: z.string().describe('文件 ID')
}),
response: baseResponseSchema.extend({
data: z.object({
url: z.string().describe('下载链接')
})
})
},
'/get_group_list': {
description: '获取群列表',
request: z.object({
next_token: z.string().optional().describe('下一页标识')
}),
response: baseResponseSchema.extend({
data: z.array(z.object({}))
})
},
'/get_group_member_info': {
description: '获取群成员信息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('QQ 号'),
no_cache: z.boolean().describe('是否不使用缓存')
}),
response: baseResponseSchema.extend({
data: z.object({})
})
},
'/get_group_member_list': {
description: '获取群成员列表',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
no_cache: z.boolean().describe('是否不使用缓存')
}),
response: baseResponseSchema.extend({
data: z.array(z.object({}))
})
},
'/get_group_honor_info': {
description: '获取群荣誉',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z
.object({
group_id: z.string().describe('群号'),
current_talkative: z
.object({
user_id: z.number().describe('QQ 号'),
avatar: z.string().describe('头像 URL'),
nickname: z.string().describe('昵称'),
day_count: z.number().describe('天数'),
description: z.string().describe('描述')
})
.describe('当前龙王'),
talkative_list: z
.array(
z.object({
user_id: z.number().describe('QQ 号'),
avatar: z.string().describe('头像 URL'),
nickname: z.string().describe('昵称'),
day_count: z.number().describe('天数'),
description: z.string().describe('描述')
})
)
.describe('龙王榜'),
performer_list: z
.array(
z.object({
user_id: z.number().describe('QQ 号'),
avatar: z.string().describe('头像 URL'),
nickname: z.string().describe('昵称'),
description: z.string().describe('描述')
})
)
.describe('?'),
legend_list: z.array(z.string()).describe('?'),
emotion_list: z.array(z.string()).describe('?'),
strong_newbie_list: z.array(z.string()).describe('?')
})
.describe('群荣誉信息')
})
},
'/get_group_at_all_remain': {
description: '获取群 @全体成员 剩余次数',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.object({
can_at_all: z.boolean().describe('是否可以 @全体成员'),
remain_at_all_count_for_group: z.number().describe('剩余次数(group?)'),
remain_at_all_count_for_uin: z.number().describe('剩余次数(qq?)')
})
})
},
'/get_group_ignored_notifies': {
description: '获取群过滤系统消息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({
data: z.object({
join_requests: z
.array(
z.object({
request_id: z.string().describe('请求 ID'),
requester_uin: z.string().describe('请求人 QQ 号'),
requester_nick: z.string().describe('请求人昵称'),
group_id: z.string().describe('群号'),
group_name: z.string().describe('群名称'),
checked: z.boolean().describe('是否已处理'),
actor: z.string().describe('处理人 QQ 号')
})
)
.describe('入群请求列表')
})
})
},
'/set_group_sign': {
description: '设置群打卡',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema
},
'/send_group_sign': {
description: '发送群打卡',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema
},
'/get_ai_characters': {
description: '获取AI语音人物',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
chat_type: z.union([z.string(), z.number()]).describe('聊天类型')
}),
response: baseResponseSchema.extend({
data: z.array(
z.object({
type: z.string().describe('类型'),
characters: z.array(
z
.object({
character_id: z.string().describe('人物 ID'),
character_name: z.string().describe('人物名称'),
preview_url: z.string().describe('预览音频地址')
})
.describe('人物信息')
)
})
)
})
},
'/send_group_ai_record': {
description: '发送群AI语音',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
character: z.string().describe('人物ID'),
text: z.string().describe('文本内容')
}),
response: baseResponseSchema.extend({
data: z.object({
message_id: z.string().describe('消息 ID')
})
})
},
'/get_ai_record': {
description: '获取AI语音',
request: z.object({
group_id: z.string().describe('群号'),
character: z.string().describe('人物ID'),
text: z.string().describe('文本内容')
}),
response: baseResponseSchema.extend({
data: z.string()
})
}
} as const
export default oneBotHttpApiGroup

View File

@@ -0,0 +1,34 @@
import { ZodSchema } from 'zod'
import oneBotHttpApiGroup from './group'
import oneBotHttpApiMessage from './message'
import oneBotHttpApiSystem from './system'
import oneBotHttpApiUser from './user'
type AllKey =
| keyof typeof oneBotHttpApiUser
| keyof typeof oneBotHttpApiMessage
| keyof typeof oneBotHttpApiGroup
| keyof typeof oneBotHttpApiSystem
export type OneBotHttpApi = Record<
AllKey,
{
description?: string
request: ZodSchema
response: ZodSchema
}
>
const oneBotHttpApi: OneBotHttpApi = {
...oneBotHttpApiUser,
...oneBotHttpApiMessage,
...oneBotHttpApiGroup,
...oneBotHttpApiSystem
} as const
export type OneBotHttpApiPath = keyof OneBotHttpApi
export type OneBotHttpApiContent = OneBotHttpApi[OneBotHttpApiPath]
export default oneBotHttpApi

View File

@@ -0,0 +1,84 @@
import { z } from 'zod'
import type { ZodSchema } from 'zod'
import { baseResponseSchema, commonResponseDataSchema } from '../response'
import messageNodeSchema, { nodeMessage } from './node'
const oneBotHttpApiMessageGroup: Record<
string,
{
description?: string
request: ZodSchema
response: ZodSchema
}
> = {
'/send_group_msg': {
description: '发送群消息',
request: z
.object({
group_id: z
.union([z.string(), z.number()])
.describe('群号')
.describe('群号'),
message: z.array(messageNodeSchema).describe('消息内容')
})
.refine(
(data) => {
const hasReply = data.message.some((item) => item.type === 'reply')
if (hasReply) {
return data.message[0].type === 'reply'
}
return true
},
{
message:
'如果 message 包含 reply 类型的消息,那么只能包含一个,而且排在最前面'
}
),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/send_group_forward_msg': {
description: '发送群合并转发消息',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
messages: z.array(nodeMessage).describe('消息内容'),
news: z
.array(
z.object({
text: z.string()
})
)
.describe('?'),
prompt: z.string().describe('外显'),
summary: z.string().describe('底下文本'),
source: z.string().describe('内容')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/forward_group_single_msg': {
description: '消息转发到群',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
message_id: z.union([z.string(), z.number()]).describe('消息 ID')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/group_poke': {
description: '发送戳一戳',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
user_id: z.union([z.string(), z.number()]).describe('对方QQ号')
}),
response: baseResponseSchema
}
}
export default oneBotHttpApiMessageGroup

View File

@@ -0,0 +1,269 @@
import { z } from 'zod'
import { baseResponseSchema, commonResponseDataSchema } from '../response'
import oneBotHttpApiMessageGroup from './group'
import messageNodeSchema from './node'
import oneBotHttpApiMessagePrivate from './private'
const fileSchema = z
.object({
file: z.string().describe('路径或链接'),
url: z.string().describe('路径或链接'),
file_size: z.string().describe('文件大小'),
file_name: z.string().describe('文件名'),
base64: z.string().describe('文件base64编码')
})
.describe('文件')
const messageSchema = z
.object({
self_id: z.number().describe('自己QQ号'),
user_id: z.number().describe('发送人QQ号'),
time: z.number().describe('发送时间'),
message_id: z.number().describe('消息ID'),
message_seq: z.number().describe('消息序号'),
real_id: z.number().describe('?ID'),
message_type: z.string().describe('消息类型'),
sender: z
.object({
user_id: z.number().describe('发送人QQ号'),
nickname: z.string().describe('昵称'),
sex: z.enum(['male', 'female', 'unknown']).describe('性别'),
age: z.number().describe('年龄'),
card: z.string().describe('名片'),
role: z.enum(['owner', 'admin', 'member']).describe('角色')
})
.describe('发送人信息'),
raw_message: z.string().describe('原始消息'),
font: z.number().describe('字体'),
sub_type: z.string().describe('子类型'),
message: z.array(messageNodeSchema).describe('消息内容'),
message_format: z.string().describe('消息格式'),
post_type: z.string().describe('?'),
message_sent_type: z.string().describe('消息发送类型'),
group_id: z.number().describe('群号')
})
.describe('消息')
const oneBotHttpApiMessage = {
...oneBotHttpApiMessagePrivate,
...oneBotHttpApiMessageGroup,
'/mark_msg_as_read': {
description: '标记消息已读',
request: z
.object({
group_id: z
.union([z.string(), z.number()])
.optional()
.describe('群号,与 user_id 二选一'),
user_id: z
.union([z.string(), z.number()])
.optional()
.describe('用户QQ号与 group_id 二选一')
})
.refine(
(data) =>
(data.group_id && !data.user_id) || (!data.group_id && data.user_id),
{
message: 'group_id 和 user_id 必须二选一,且不能同时存在或同时为空',
path: ['group_id', 'user_id']
}
),
response: baseResponseSchema
},
'/mark_group_msg_as_read': {
description: '标记群消息已读',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema
},
'/mark_private_msg_as_read': {
description: '标记私聊消息已读',
request: z.object({
user_id: z.union([z.string(), z.number()]).describe('用户QQ号')
}),
response: baseResponseSchema
},
'/_mark_all_as_read': {
description: '标记所有消息已读',
request: z.object({}),
response: baseResponseSchema
},
'/delete_msg': {
description: '撤回消息',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息ID')
}),
response: baseResponseSchema
},
'/get_msg': {
description: '获取消息',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息ID')
}),
response: baseResponseSchema.extend({
data: z.object({})
})
},
'/get_image': {
description: '获取图片',
request: z.object({
file_id: z.string().describe('文件ID')
}),
response: baseResponseSchema.extend({
data: fileSchema
})
},
'/get_record': {
description: '获取语音',
request: z.object({
file_id: z.string().describe('文件ID'),
out_format: z
.enum(['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'])
.describe('输出格式')
}),
response: baseResponseSchema.extend({
data: fileSchema
})
},
'/get_file': {
description: '获取文件',
request: z.object({
file_id: z.string().describe('文件ID')
}),
response: baseResponseSchema.extend({
data: fileSchema
})
},
'/get_group_msg_history': {
description: '获取群消息历史',
request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号'),
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
count: z.number().int().positive().describe('获取数量'),
reverseOrder: z.boolean().describe('是否倒序')
}),
response: baseResponseSchema.extend({
data: z.object({
messages: z.array(messageSchema).describe('消息列表')
})
})
},
'/set_msg_emoji_like': {
description: '贴表情',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息ID'),
emoji_id: z.number().describe('表情ID'),
set: z.boolean().describe('?')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/get_friend_msg_history': {
description: '获取好友消息历史',
request: z.object({
user_id: z.union([z.string(), z.number()]).describe('用户QQ号'),
message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
count: z.number().int().positive().describe('获取数量'),
reverseOrder: z.boolean().describe('是否倒序')
}),
response: baseResponseSchema.extend({
data: z.object({
messages: z.array(messageSchema)
})
})
},
'/get_recent_contact': {
description: '最近消息列表',
request: z.object({
count: z.number().int().positive().describe('获取数量')
}),
response: baseResponseSchema.extend({
data: z.array(
z.object({
lastestMsg: messageSchema,
peerUin: z.string().describe('对方QQ号'),
remark: z.string().describe('备注'),
msgTime: z.string().describe('消息时间'),
chatType: z.number().describe('聊天类型'),
msgId: z.string().describe('消息ID'),
sendNickName: z.string().describe('发送人昵称'),
sendMemberName: z.string().describe('发送人?昵称'),
peerName: z.string().describe('对方昵称')
})
)
})
},
'/fetch_emoji_like': {
description: '获取贴表情详情',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息ID'),
emojiId: z.string().describe('表情ID'),
emojiType: z.string().describe('表情类型'),
group_id: z.union([z.string(), z.number()]).optional().describe('群号'),
user_id: z
.union([z.string(), z.number()])
.optional()
.describe('用户QQ号'),
count: z.number().int().positive().optional().describe('获取数量')
}),
response: baseResponseSchema.extend({
data: z.object({
result: z.number().describe('结果'),
errMsg: z.string().describe('错误信息'),
emojiLikesList: z
.array(
z
.object({
tinyId: z.string().describe('表情ID'),
nickName: z.string().describe('昵称?'),
headUrl: z.string().describe('头像?')
})
.describe('表情点赞列表')
)
.describe('表情点赞列表'),
cookie: z.string().describe('cookie'),
isLastPage: z.boolean().describe('是否最后一页'),
isFirstPage: z.boolean().describe('是否第一页')
})
})
},
'/get_forward_msg': {
description: '获取合并转发消息',
request: z.object({
message_id: z.union([z.string(), z.number()]).describe('消息ID')
}),
response: baseResponseSchema.extend({
data: z.object({})
})
},
'/send_forward_msg': {
description: '发送合并转发消息',
request: z.object({
group_id: z.union([z.string(), z.number()]).optional().describe('群号'),
user_id: z
.union([z.string(), z.number()])
.optional()
.describe('用户QQ号'),
messages: z.array(messageNodeSchema).describe('消息内容'),
news: z
.array(
z.object({
text: z.string()
})
)
.describe('?'),
prompt: z.string().describe('外显'),
summary: z.string().describe('底下文字'),
source: z.string().describe('内容')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema.extend({
data: z.object({})
})
})
}
} as const
export default oneBotHttpApiMessage

View File

@@ -0,0 +1,119 @@
import { z } from 'zod'
const messageNode = z.union([
z
.object({
type: z.literal('text'),
data: z.object({
text: z.string()
})
})
.describe('文本消息'),
z
.object({
type: z.literal('at'),
data: z.object({
qq: z.string()
})
})
.describe('@某人'),
z
.object({
type: z.literal('image'),
data: z.object({
file: z.string()
})
})
.describe('图片消息'),
z
.object({
type: z.literal('face'),
data: z.object({
id: z.number()
})
})
.describe('表情消息'),
z
.object({
type: z.literal('json'),
data: z.object({
data: z.string()
})
})
.describe('json 卡片消息'),
z
.object({
type: z.literal('record'),
data: z.object({
file: z.string()
})
})
.describe('语音消息'),
z
.object({
type: z.literal('video'),
data: z.object({
file: z.string()
})
})
.describe('视频消息'),
z
.object({
type: z.literal('reply'),
data: z.object({
id: z.number()
})
})
.describe('回复消息'),
z
.object({
type: z.literal('music'),
data: z.union([
z.object({
type: z.enum(['qq', '163']),
id: z.string()
}),
z.object({
type: z.literal('custom'),
url: z.string(),
audio: z.string(),
title: z.string(),
image: z.string()
})
])
})
.describe('音乐消息'),
z
.object({
type: z.literal('dice')
})
.describe('掷骰子'),
z
.object({
type: z.literal('rps')
})
.describe('猜拳'),
z
.object({
type: z.literal('file'),
data: z.object({
file: z.string().describe('文件路径,服务器本地或者网络文件均可')
})
})
.describe('发送消息')
])
export const nodeMessage = z
.object({
type: z.literal('node'),
data: z.object({
user_id: z.string(),
nickname: z.string(),
content: z.array(messageNode)
})
})
.describe('消息节点')
const messageNodeSchema = z.union([messageNode, nodeMessage])
export default messageNodeSchema

View File

@@ -0,0 +1,80 @@
import { z } from 'zod'
import type { ZodSchema } from 'zod'
import { baseResponseSchema, commonResponseDataSchema } from '../response'
import messageNodeSchema, { nodeMessage } from './node'
const oneBotHttpApiMessagePrivate: Record<
string,
{
description?: string
request: ZodSchema
response: ZodSchema
}
> = {
'/send_private_msg': {
description: '发送私聊消息',
request: z
.object({
user_id: z.union([z.string(), z.number()]).describe('对方QQ号'),
message: z.array(messageNodeSchema).describe('消息内容')
})
.refine(
(data) => {
const hasReply = data.message.some((item) => item.type === 'reply')
if (hasReply) {
return data.message[0].type === 'reply'
}
return true
},
{
message:
'如果 message 包含 reply 类型的消息,那么只能包含一个,而且排在最前面'
}
),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/send_private_forward_msg': {
description: '发送私聊合并转发消息',
request: z.object({
user_id: z.union([z.string(), z.number()]).describe('对方QQ号'),
messages: z.array(nodeMessage).describe('消息内容'),
news: z
.array(
z.object({
text: z.string()
})
)
.describe('?'),
prompt: z.string().describe('外显'),
summary: z.string().describe('底下文本'),
source: z.string().describe('内容')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/forward_friend_single_msg': {
description: '消息转发到私聊',
request: z.object({
user_id: z.union([z.string(), z.number()]).describe('对方QQ号'),
message_id: z.union([z.string(), z.number()]).describe('消息ID')
}),
response: baseResponseSchema.extend({
data: commonResponseDataSchema
})
},
'/group_poke': {
description: '发送私聊戳一戳',
request: z.object({
user_id: z.union([z.string(), z.number()]).describe('对方QQ号')
}),
response: baseResponseSchema
}
}
export default oneBotHttpApiMessagePrivate

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