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

View File

@@ -1,226 +0,0 @@
<template>
<div class="about-us">
<div>
<t-divider content="面板关于信息" align="left">
<template #content>
<div style="display: flex; justify-content: center; align-items: center">
<info-circle-icon></info-circle-icon>
<div style="margin-left: 5px">面板关于信息</div>
</div>
</template>
</t-divider>
<t-alert theme="success" class="header" message="NapCat.WebUi is running" />
<t-list>
<t-list-item>
<div class="label-box">
<star-filled-icon class="item-icon" size="large" />
<span class="item-label">Star:</span>
</div>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/stargazers">{{
githubBastData?.stargazers_count
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<tips-filled-icon class="item-icon" size="large" />
<span class="item-label">issues:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/issues">{{
githubBastData?.open_issues_count
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<git-pull-request-filled-icon class="item-icon" size="large" />
<span class="item-label">Pull Requests:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/pulls">{{githubPullData?.length
}}</t-link>
</span>
</t-list-item>
<t-list-item >
<bookmark-add-filled-icon class="item-icon" size="large" />
<span class="item-label">Releases:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/releases">{{
githubReleasesData&&githubReleasesData[0]?timeDifference(githubReleasesData[0].published_at) + '前更新':''
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<usergroup-filled-icon class="item-icon" size="large" />
<span class="item-label">Contributors:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/graphs/contributors">{{githubContributorsData?.length}}</t-link>
</span>
</t-list-item>
<t-list-item>
<browse-filled-icon class="item-icon" size="large" />
<span class="item-label">Watchers:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/watchers">{{
githubBastData?.watchers
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<fork-filled-icon class="item-icon" size="large" />
<span class="item-label">Fork:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/fork">{{
githubBastData?.forks_count
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<statue-of-jesus-filled-icon class="item-icon" size="large" />
<span class="item-label">License:</span>
<span class="item-content">
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ#License-1-ov-file">{{
githubBastData?.license.key
}}</t-link>
</span>
</t-list-item>
<t-list-item>
<component-layout-filled-icon class="item-icon" size="large" />
<span class="item-label">Version:</span>
<span class="item-content">
<t-tag class="tag-item pgk-color"> WebUi: {{ pkg.version }} </t-tag>
<t-tag class="tag-item nc-color">
NapCat:
{{ napCatVersion }}
</t-tag>
<t-tag v-if="githubReleasesData&&githubReleasesData[0] ?.tag_name" class="tag-item nc-color">
New NapCat:
{{ githubReleasesData[0].tag_name }}
</t-tag>
<t-tag class="tag-item td-color"> TDesign: {{ pkg.dependencies['tdesign-vue-next'] }} </t-tag>
</span>
</t-list-item>
</t-list>
</div>
</div>
</template>
<script setup lang="ts">
import pkg from '../../package.json';
import { napCatVersion } from '../../../src/common/version';
import {
InfoCircleIcon,
TipsFilledIcon,
StarFilledIcon,
GitPullRequestFilledIcon,
ForkFilledIcon,
StatueOfJesusFilledIcon,
BookmarkAddFilledIcon,
UsergroupFilledIcon,
BrowseFilledIcon,
ComponentLayoutFilledIcon,
} from 'tdesign-icons-vue-next';
import { githubApiManager } from '@/backend/githubApi';
import { onMounted, ref } from 'vue';
const githubApi = new githubApiManager();
const githubBastData = ref<any>(null);
const githubReleasesData = ref<any>(null);
const githubContributorsData = ref<any>(null);
const githubPullData = ref<any>(null);
const getBaseData = async () => {
githubBastData.value = await githubApi.GetBaseData();
githubReleasesData.value = await githubApi.GetReleasesData();
githubContributorsData.value = await githubApi.GetContributors();
githubPullData.value = await githubApi.GetPullsData();
};
const timeDifference = (timestamp: string): string => {
const givenTime = new Date(timestamp);
const currentTime = new Date();
const diffInMilliseconds = currentTime.getTime() - givenTime.getTime();
const seconds = Math.floor(diffInMilliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}小时`;
} else if (minutes > 0) {
return `${minutes}分钟`;
} else {
return `${seconds}`;
}
};
onMounted(() => {
getBaseData();
});
</script>
<style scoped>
.about-us {
padding: 20px;
text-align: left;
}
.label-box {
display: flex;
justify-content: center;
align-items: center;
}
.item-icon {
padding: 5px;
color: #ffffff;
border-radius: 3px;
background-image: linear-gradient(-225deg, #2cd8d5 0%, #c5c1ff 56%, #ffbac3 100%);
}
.item-label {
flex: 1;
margin-left: 8px;
box-sizing: border-box;
height: auto;
padding: 0;
border: none;
font-size: 16px;
}
.item-content {
flex: 2;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
}
.tag-item {
margin-right: 10px;
margin-bottom: 10px;
}
</style>
<style>
.t-list-item {
padding: 5px var(--td-comp-paddingLR-l);
}
.item-label {
flex: 2;
background-image: linear-gradient(to right, #fa709a 0%, #fee140 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.pgk-color {
color: white;
background-image: linear-gradient(-225deg, #9be15d 0%, #00e3ae 100%);
}
.nc-color {
color: white;
background-image: linear-gradient(-225deg, #2cd8d5 0%, #c5c1ff 56%, #ffbac3 100%);
}
.td-color {
color: white;
background-image: linear-gradient(225deg, #0acffe 0%, #495aff 100%);
}
.header {
background-image: linear-gradient(225deg, #dfffcd 0%, #90f9c4 48%, #39f3bb 100%) !important;
}
.link-text{
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(-225deg, #B6CEE8 0%, #F578DC 100%);
font-weight: bold;
}
</style>

View File

@@ -1,6 +0,0 @@
<template>
<div class="basic-info">
<h1>面板基础信息</h1>
<p>这里显示面板的基础信息</p>
</div>
</template>

View File

@@ -1,600 +0,0 @@
<template>
<div class="title">
<t-divider content="日志查看" align="left">
<template #content>
<div style="display: flex; justify-content: center; align-items: center">
<system-log-icon></system-log-icon>
<div style="margin-left: 5px">日志查看</div>
</div>
</template>
</t-divider>
</div>
<div class="tab-box">
<t-tabs default-value="realtime" @change="selectType">
<t-tab-panel value="realtime" label="实时日志"></t-tab-panel>
<t-tab-panel value="history" label="历史日志"></t-tab-panel>
</t-tabs>
</div>
<div class="card-box">
<t-card class="card" :bordered="true">
<template #actions>
<t-row :align="'middle'" justify="center" :style="{ gap: smallScreen.matches ? '5px' : '24px' }">
<t-col flex="auto" style="display: inline-flex; justify-content: center">
<t-tooltip content="清理日志">
<t-button variant="text" shape="square" @click="clearLogs">
<clear-icon></clear-icon>
</t-button>
</t-tooltip>
</t-col>
<t-col flex="auto" style="display: inline-flex; justify-content: center">
<t-tooltip content="下载日志">
<t-button variant="text" shape="square" @click="downloadText">
<download-icon></download-icon>
</t-button>
</t-tooltip>
</t-col>
<t-col
v-if="LogDataType === 'history'"
flex="auto"
style="display: inline-flex; justify-content: center">
<t-tooltip content="历史日志">
<t-button variant="text" shape="square" @click="historyLog">
<history-icon></history-icon>
</t-button>
</t-tooltip>
</t-col>
<t-col flex="auto" style="display: inline-flex; justify-content: center">
<div class="tag-box">
<t-tag class="t-tag" :style="{ backgroundImage: typeKey[optValue.description] }">{{
optValue.content }}</t-tag>
</div>
<t-dropdown :options="options" :min-column-width="112" @click="openTypeList">
<t-button variant="text" shape="square">
<more-icon />
</t-button>
</t-dropdown>
</t-col>
</t-row>
</template>
<template #content>
<div class="content" ref="contentBox">
<div v-for="item in LogDataType === 'realtime'
? realtimeLogHtmlList.get(optValue.description)
: historyLogHtmlList.get(optValue.description)">
<span>{{ item.time }}</span><span :id="item.type">{{ item.content }}</span>
</div>
</div>
</template>
</t-card>
</div>
<t-dialog v-model:visible="visibleBody" header="历史日志" :destroy-on-close="true" :show-in-attached-element="true"
:on-confirm="GetLogList" class=".t-dialog__ctx .t-dialog__position">
<t-select v-model="value" :options="logFileData" placeholder="请选择日志" :multiple="true"
style="text-align: left" />
</t-dialog>
</template>
<script setup lang="ts">
import { MoreIcon, ClearIcon, DownloadIcon, HistoryIcon, SystemLogIcon } from 'tdesign-icons-vue-next';
import { nextTick, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
import { LogManager } from '@/backend/log';
import { MessagePlugin } from 'tdesign-vue-next';
import { EventSourcePolyfill } from 'event-source-polyfill';
const smallScreen = window.matchMedia('(max-width: 768px)');
const LogDataType = ref<string>('realtime');
const visibleBody = ref<boolean>(false);
const contentBox = ref<HTMLElement | null>(null);
let isMouseEntered = false;
const logManager = new LogManager(localStorage.getItem('auth') || '');
const eventSource = ref<EventSourcePolyfill | null>(null);
const intervalId = ref<number | null>(null);
const isPaused = ref(false);
interface OptionItem {
content: string;
value: number;
description: string;
}
const options = ref<OptionItem[]>([
{
content: '全部',
value: 1,
description: 'all',
},
{
content: '调试',
value: 2,
description: 'debug',
},
{
content: '提示',
value: 3,
description: 'info',
},
{
content: '警告',
value: 4,
description: 'warn',
},
{
content: '错误',
value: 5,
description: 'error',
},
{
content: '致命',
value: 5,
description: 'fatal',
},
]);
const typeKey = ref<Record<string, string>>({
all: 'linear-gradient(60deg,#16a085 0%, #f4d03f 100%)',
debug: 'linear-gradient(-225deg, #5271c4 0%, #b19fff 48%, #eca1fe 100%)',
info: 'linear-gradient(-225deg, #22e1ff 0%, #1d8fe1 48%, #625eb1 100%)',
warn: 'linear-gradient(to right, #e14fad 0%, #f9d423 48%, #e37318 100%)',
error: 'linear-gradient(to left, #ffe29f 0%, #ffa99f 48%, #d94541 100%)',
fatal: 'linear-gradient(-225deg, #fd0700, #ec567f)',
});
interface logHtml {
type?: string;
content: string;
color?: string;
time?: string;
}
type LogHtmlMap = Map<string, logHtml[]>;
const realtimeLogHtmlList = ref<LogHtmlMap>(
new Map([
['all', []],
['debug', []],
['info', []],
['warn', []],
['error', []],
['fatal', []],
])
);
const historyLogHtmlList = ref<LogHtmlMap>(
new Map([
['all', []],
['debug', []],
['info', []],
['warn', []],
['error', []],
['fatal', []],
])
);
const logFileData = ref<{ label: string; value: string }[]>([]);
const value = ref([]);
const optValue = ref<OptionItem>({
content: '全部',
value: 1,
description: 'all',
});
const openTypeList = (data: OptionItem) => {
optValue.value = data;
};
const logType = ['debug', 'info', 'warn', 'error', 'fatal'];
//清理log
const clearLogs = () => {
if (LogDataType.value === 'realtime') {
clearAllLogs(realtimeLogHtmlList);
} else {
clearAllLogs(historyLogHtmlList);
}
};
const clearAllLogs = (logList: Ref<Map<string, Array<logHtml>>>) => {
if ((optValue.value && optValue.value.description === 'all') || !optValue.value) {
logList.value = new Map([
['all', []],
['debug', []],
['info', []],
['warn', []],
['error', []],
['fatal', []],
]);
} else {
logList.value.set(optValue.value.description, []);
}
};
//定时清理log
const TimerClear = () => {
clearAllLogs(realtimeLogHtmlList);
};
const startTimer = () => {
if (!isPaused.value) {
intervalId.value = window.setInterval(TimerClear, 0.5 * 60 * 1000);
}
};
const pauseTimer = () => {
if (intervalId.value) {
window.clearInterval(intervalId.value);
isPaused.value = true;
}
};
const resumeTimer = () => {
if (isPaused.value) {
startTimer();
isPaused.value = false;
}
};
const stopTimer = () => {
if (intervalId.value) {
window.clearInterval(intervalId.value);
intervalId.value = null;
}
};
const extractContent = (text: string): string | null => {
const regex = /\[([^\]]+)]/;
const match = regex.exec(text);
if (match && match[1]) {
const extracted = match[1].toLowerCase();
if (logType.includes(extracted)) {
return match[1];
}
}
return null;
};
const loadData = (text: string, loadType: string) => {
const lines = text.split(/\r\n/);
lines.forEach((line) => {
if (loadType === 'realtime') {
let remoteJson = JSON.parse(line) as { message: string, level: string };
const type = remoteJson.level;
const actualType = type || 'other';
const color = actualType && typeKey.value[actualType] ? typeKey.value[actualType] : undefined;
const data: logHtml = {
type: actualType,
content: remoteJson.message,
color: color,
time: '',
};
updateLogList(realtimeLogHtmlList, actualType, data);
} else if (loadType === 'history') {
const type = extractContent(line);
const actualType = type || 'other';
const timeRegex = /(\d{2}-\d{2} \d{2}:\d{2}:\d{2})|(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/;
const match = timeRegex.exec(line);
let time = match ? match[0] : null;
const color = actualType && typeKey.value[actualType] ? typeKey.value[actualType] : undefined;
const data: logHtml = {
type: actualType,
content: line.slice(match ? match[0].length : 0) || '',
color: color,
time: time ? time + ' ' : '',
};
updateLogList(historyLogHtmlList, actualType, data);
}
});
};
const updateLogList = (logList: Ref<Map<string, Array<logHtml>>>, actualType: string, data: logHtml) => {
const allLogs = logList.value.get('all');
if (Array.isArray(allLogs)) {
allLogs.push(data);
}
if (actualType !== 'other') {
const typeLogs = logList.value.get(actualType);
if (Array.isArray(typeLogs)) {
typeLogs.push(data);
}
}
};
const selectType = (key: string) => {
LogDataType.value = key;
};
interface CustomURL extends URL {
recycleObjectURL: (url: string) => void;
}
const isCompatibleWithCustomURL = (obj: any): obj is CustomURL => {
return typeof obj === 'object' && obj !== null && typeof (obj as any).recycleObjectURL === 'function';
};
const recycleURL = (url: string) => {
if (isCompatibleWithCustomURL(window.URL)) {
const customURL = window.URL as CustomURL;
customURL.recycleObjectURL(url);
}
};
const generateTXT = (textContent: string, fileName: string) => {
try {
const blob = new Blob([textContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
recycleURL(url);
} catch (error) {
console.error('下载文本时出现错误:', error);
}
};
const downloadText = () => {
if (LogDataType.value === 'realtime') {
const logs = realtimeLogHtmlList.value.get(optValue.value.description);
if (logs && logs.length > 0) {
const result = logs.map((obj) => obj.content).join('\r\n');
generateTXT(result, '实时日志');
} else {
MessagePlugin.error('暂无可下载日志');
}
} else {
const logs = historyLogHtmlList.value.get(optValue.value.description);
if (logs && logs.length > 0) {
const result = logs.map((obj) => obj.content).join('\r\n');
generateTXT(result, '历史日志');
} else {
MessagePlugin.error('暂无可下载日志');
}
}
};
const historyLog = async () => {
value.value = [];
visibleBody.value = true;
const res = await logManager.GetLogList();
clearAllLogs(historyLogHtmlList);
if (res.length > 0) {
logFileData.value = res.map((ele: string) => {
return { label: ele, value: ele };
});
} else {
logFileData.value = [];
}
};
const GetLogList = async () => {
if (value.value.length > 0) {
for (const ele of value.value) {
try {
const data = await logManager.GetLog(ele);
if (data && data !== 'null') {
loadData(data, 'history');
}
} catch (error) {
console.error(`获取日志 ${ele} 时出现错误:`, error);
}
}
visibleBody.value = false;
} else {
MessagePlugin.error('请选择日志');
}
};
const fetchRealTimeLogs = async () => {
eventSource.value = await logManager.getRealTimeLogs();
if (eventSource.value) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-expect-error
eventSource.value.onmessage = (event: MessageEvent) => {
console.log(event.data)
loadData(event.data, 'realtime');
};
}
};
const closeRealTimeLogs = async () => {
if (eventSource.value) {
eventSource.value.close();
}
};
const scrollToBottom = () => {
if (!isMouseEntered) {
nextTick(() => {
if (contentBox.value) {
contentBox.value.scrollTop = contentBox.value.scrollHeight;
}
});
}
};
const observeDOMChanges = () => {
if (contentBox.value) {
const observer = new MutationObserver(() => {
scrollToBottom();
});
observer.observe(contentBox.value, {
childList: true,
subtree: true,
});
}
};
const showScrollbar = () => {
if (contentBox.value) {
contentBox.value.style.overflow = 'auto';
}
};
const hideScrollbar = () => {
if (contentBox.value) {
contentBox.value.style.overflow = 'hidden';
if (!isMouseEntered) {
scrollToBottom();
}
}
};
watch(
realtimeLogHtmlList,
() => {
if (!isMouseEntered) {
scrollToBottom();
}
},
{ immediate: true }
);
watch(
historyLogHtmlList,
() => {
if (!isMouseEntered) {
scrollToBottom();
}
},
{ immediate: true }
);
onMounted(() => {
fetchRealTimeLogs();
startTimer();
contentBox.value = document.querySelector('.content');
if (contentBox.value) {
contentBox.value.style.overflow = 'hidden';
contentBox.value.addEventListener('mouseenter', () => {
isMouseEntered = true;
showScrollbar();
pauseTimer();
});
contentBox.value.addEventListener('mouseleave', () => {
isMouseEntered = false;
hideScrollbar();
resumeTimer();
setTimeout(() => {
scrollToBottom();
}, 1000);
});
observeDOMChanges();
}
});
onUnmounted(() => {
closeRealTimeLogs();
stopTimer();
});
</script>
<style scoped>
.title {
padding: 20px 20px 0 20px;
}
.tab-box {
margin: 0 20px;
}
.card-box {
margin: 10px 20px;
}
.content {
height: 56vh;
background-image: url('@/assets/logo.png');
border: 1px solid #ddd6d6 !important;
padding: 5px 10px;
text-align: left;
overflow-y: auto;
margin-top: -10px;
font-family: monospace;
font-size: 15px;
line-height: 16px;
}
.content span {
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: break-word;
}
@keyframes fadeInOnce {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOutOnce {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.content div {
animation: fadeInOnce 0.5s forwards;
}
::-webkit-scrollbar {
width: 5px;
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background-color: #888888;
border-radius: 4px;
}
.tag-box {
display: flex;
justify-content: center;
align-items: center;
margin-right: 5px;
}
.t-tag {
min-width: 60px;
text-align: center;
display: flex;
justify-content: center;
color: white;
font-weight: 500;
}
#debug {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(-225deg, #5271c4 0%, #b19fff 48%, #eca1fe 100%);
}
#info {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(-225deg, #22e1ff 0%, #1d8fe1 48%, #625eb1 100%);
}
#warn {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(225deg, #e14fad 0%, #f9d423 48%, #e37318 100%);
}
#error {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to left, #ffe29f 0%, #ffa99f 48%, #d94541 100%);
}
#fatal {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to right, #fd0700, #ec567f);
}
#other {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to top, #3f51b1 0%, #5a55ae 13%, #7b5fac 25%, #8f6aae 38%, #a86aa4 50%, #cc6b8e 62%, #f18271 75%, #f3a469 87%, #f7c978 100%);
}
@media (max-width: 786px) {
.content {
height: 50vh;
font-family: ProtoNerdFontItalic, monospace;
font-size: 12px;
line-height: 14.3px;
}
}
</style>
<style>
.card {
padding: 5px 10px 20px 10px !important;
}
@media (max-width: 786px) {
.card {
padding: 0 !important;
}
}
</style>

View File

@@ -1,605 +0,0 @@
<template>
<div ref="headerBox" class="title">
<t-divider content="网络配置" align="left">
<template #content>
<div style="display: flex; justify-content: center; align-items: center">
<wifi1-icon />
<div style="margin-left: 5px">网络配置</div>
</div>
</template>
</t-divider>
<t-divider align="right">
<t-button @click="addConfig()">
<template #icon><add-icon /></template>
添加配置</t-button>
</t-divider>
</div>
<div v-if="loadPage" ref="setting" class="setting">
<t-tabs ref="tabsRef" :style="{ width: tabsWidth + 'px' }" default-value="all" @change="selectType">
<t-tab-panel value="all" label="全部"></t-tab-panel>
<t-tab-panel value="httpServers" label="HTTP 服务器"></t-tab-panel>
<t-tab-panel value="httpSseServers" label="HTTP SSE 服务器"></t-tab-panel>
<t-tab-panel value="httpClients" label="HTTP 客户端"></t-tab-panel>
<t-tab-panel value="websocketServers" label="WebSocket 服务器"></t-tab-panel>
<t-tab-panel value="websocketClients" label="WebSocket 客户端"></t-tab-panel>
</t-tabs>
</div>
<t-loading attach="#alice" :loading="!loadPage" :showOverlay="false">
<div id="alice" v-if="!loadPage" style="height: 80vh;position: relative"></div>
</t-loading>
<div v-if="loadPage" class="card-box" :style="{ width: tabsWidth + 'px' }">
<div class="setting-box" :style="{ maxHeight: cardHeight + 'px' }" v-if="cardConfig.length > 0">
<div v-for="(item, index) in cardConfig" :key="index">
<t-card :title="item.name" :description="item.type" :style="{ width: cardWidth + 'px' }"
:header-bordered="true" class="setting-card">
<template #actions>
<t-space>
<edit2-icon size="20px" @click="editConfig(item)"></edit2-icon>
<t-popconfirm content="确认删除" @confirm="delConfig(item)">
<delete-icon size="20px"></delete-icon>
</t-popconfirm>
</t-space>
</template>
<div class="setting-content">
<t-card class="card-address" :style="{
borderLeft:
'7px solid ' + (item.enable ? 'var(--td-success-color)' : 'var(--td-error-color)'),
}">
<div class="local-box" v-if="item.host && item.port">
<server-filled-icon class="local-icon" size="20px"
@click="toggleProperty(item, 'enable')"></server-filled-icon>
<strong class="local">{{ item.host }}:{{ item.port }}</strong>
<copy-icon class="copy-icon" size="20px"
@click="copyText(item.host + ':' + item.port)"></copy-icon>
</div>
<div class="local-box" v-if="item.url">
<server-filled-icon class="local-icon" size="20px"
@click="toggleProperty(item, 'enable')"></server-filled-icon>
<strong class="local">{{ item.url }}</strong>
<copy-icon class="copy-icon" size="20px" @click="copyText(item.url)"></copy-icon>
</div>
</t-card>
<t-collapse :default-value="[0]" expand-mutex style="margin-top: 10px" class="info-coll">
<t-collapse-panel header="基础信息">
<t-descriptions size="small" :layout="infoOneCol ? 'vertical' : 'horizontal'"
class="setting-base-info">
<t-descriptions-item v-if="item.token" label="连接密钥">
<div v-if="mediumScreen.matches || largeScreen.matches" class="token-view">
<span>{{ showToken ? item.token : '******' }}</span>
<browse-icon class="browse-icon" v-if="showToken" size="18px"
@click="showToken = false"></browse-icon>
<browse-off-icon class="browse-icon" v-else size="18px"
@click="showToken = true"></browse-off-icon>
</div>
<div v-else>
<t-popup :showArrow="true" trigger="click">
<t-tag theme="primary">点击查看</t-tag>
<template #content>
<div @click="copyText(item.token)">{{ item.token }}</div>
</template>
</t-popup>
</div>
</t-descriptions-item>
<t-descriptions-item label="消息格式">{{
item.messagePostFormat
}}</t-descriptions-item>
</t-descriptions>
</t-collapse-panel>
<t-collapse-panel header="状态信息">
<t-descriptions size="small" :layout="infoOneCol ? 'vertical' : 'horizontal'"
class="setting-base-info">
<t-descriptions-item v-if="item.hasOwnProperty('debug')" label="调试日志">
<t-tag :class="item.debug ? 'tag-item-on' : 'tag-item-off'"
@click="toggleProperty(item, 'debug')">
{{ item.debug ? '开启' : '关闭' }}</t-tag>
</t-descriptions-item>
<t-descriptions-item v-if="item.hasOwnProperty('enableWebsocket')"
label="Websocket 功能">
<t-tag :class="item.enableWebsocket ? 'tag-item-on' : 'tag-item-off'"
@click="toggleProperty(item, 'enableWebsocket')">
{{ item.enableWebsocket ? '启用' : '禁用' }}</t-tag>
</t-descriptions-item>
<t-descriptions-item v-if="item.hasOwnProperty('enableCors')" label="跨域放行">
<t-tag :class="item.enableCors ? 'tag-item-on' : 'tag-item-off'"
@click="toggleProperty(item, 'enableCors')">
{{ item.enableCors ? '开启' : '关闭' }}</t-tag>
</t-descriptions-item>
<t-descriptions-item v-if="item.hasOwnProperty('enableForcePushEvent')"
label="上报自身消息">
<t-tag :class="item.reportSelfMessage ? 'tag-item-on' : 'tag-item-off'"
@click="toggleProperty(item, 'reportSelfMessage')">
{{ item.reportSelfMessage ? '开启' : '关闭' }}</t-tag>
</t-descriptions-item>
<t-descriptions-item v-if="item.hasOwnProperty('enableForcePushEvent')"
label="强制推送事件">
<t-tag class="tag-item"
:class="item.enableForcePushEvent ? 'tag-item-on' : 'tag-item-off'"
@click="toggleProperty(item, 'enableForcePushEvent')">
{{ item.enableForcePushEvent ? '开启' : '关闭' }}</t-tag>
</t-descriptions-item>
</t-descriptions>
</t-collapse-panel>
</t-collapse>
</div>
</t-card>
</div>
<div style="height: 20vh"></div>
</div>
<t-card v-else>
<t-empty class="card-none" title="暂无网络配置"> </t-empty>
</t-card>
</div>
<t-dialog v-model:visible="visibleBody" :header="dialogTitle" :destroy-on-close="true"
:show-in-attached-element="true" :on-confirm="saveConfig" class=".t-dialog__ctx .t-dialog__position">
<div slot="body" class="dialog-body">
<t-form ref="form" :data="newTab" labelAlign="left" :model="newTab">
<t-form-item style="text-align: left" :rules="[{ required: true, message: '请输入名称', trigger: 'blur' }]"
label="名称" name="name">
<t-input v-model="newTab.name" />
</t-form-item>
<t-form-item style="text-align: left" :rules="[{ required: true, message: '请选择类型', trigger: 'change' }]"
label="类型" name="type">
<t-select v-model="newTab.type" @change="onloadDefault">
<t-option value="httpServers">HTTP 服务器</t-option>
<t-option value="httpSseServers">HTTP SSE 服务器</t-option>
<t-option value="httpClients">HTTP 客户端</t-option>
<t-option value="websocketServers">WebSocket 服务器</t-option>
<t-option value="websocketClients">WebSocket 客户端</t-option>
</t-select>
</t-form-item>
<div>
<component
:is="resolveDynamicComponent(getComponent(newTab.type as ComponentKey))"
:config="newTab"
/>
</div>
</t-form>
</div>
</t-dialog>
</template>
<script setup lang="ts">
import {
AddIcon,
DeleteIcon,
Edit2Icon,
ServerFilledIcon,
CopyIcon,
BrowseOffIcon,
BrowseIcon,
Wifi1Icon,
} from 'tdesign-icons-vue-next';
import {
loadConfig as loadConfigOnebot,
NetworkAdapterConfig,
NetworkConfigKey,
OneBotConfig,
} from '../../../src/onebot/config/config';
import HttpServerComponent from '@/pages/network/HttpServerComponent.vue';
import HttpClientComponent from '@/pages/network/HttpClientComponent.vue';
import WebsocketServerComponent from '@/pages/network/WebsocketServerComponent.vue';
import WebsocketClientComponent from '@/pages/network/WebsocketClientComponent.vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { QQLoginManager } from '@/backend/shell';
import { onMounted, onUnmounted, ref, watch, resolveDynamicComponent } from 'vue';
import emitter from '@/ts/event-bus';
import HttpSseServerComponent from './network/HttpSseServerComponent.vue';
const showToken = ref<boolean>(false);
const infoOneCol = ref<boolean>(true);
const tabsWidth = ref<number>(0);
const menuWidth = ref<number>(0);
const cardWidth = ref<number>(0);
const cardHeight = ref<number>(0);
const mediumScreen = window.matchMedia('(min-width: 768px) and (max-width: 1024px)');
const largeScreen = window.matchMedia('(min-width: 1025px)');
const headerBox = ref<HTMLDivElement | null>(null);
const setting = ref<HTMLDivElement | null>(null);
const loadPage = ref<boolean>(false);
const visibleBody = ref<boolean>(false);
const newTab = ref<{ name: string; data: any; type: string }>({ name: '', data: {}, type: '' });
const dialogTitle = ref<string>('');
type ComponentKey = Exclude<NetworkConfigKey, 'plugins'>
const componentMap: Record<
ComponentKey,
| typeof HttpServerComponent
| typeof HttpClientComponent
| typeof WebsocketServerComponent
| typeof WebsocketClientComponent
| typeof HttpSseServerComponent
> = {
httpServers: HttpServerComponent,
httpSseServers: HttpSseServerComponent,
httpClients: HttpClientComponent,
websocketServers: WebsocketServerComponent,
websocketClients: WebsocketClientComponent,
};
//操作类型
const operateType = ref<string>('');
//配置项索引
const configIndex = ref<number>(0);
//保存时所用数据
const networkConfig: { [key: string]: any } = {
websocketClients: [],
websocketServers: [],
httpSseServers: [],
httpClients: [],
httpServers: [],
};
//挂载的数据
const WebConfg = ref(
new Map<string, Array<null>>([
['all', []],
['httpServers', []],
['httpClients', []],
['httpSseServers', []],
['websocketServers', []],
['websocketClients', []],
])
);
const typeCh: Record<ComponentKey, string> = {
httpServers: 'HTTP 服务器',
httpClients: 'HTTP 客户端',
httpSseServers: 'HTTP SSE 服务器',
websocketServers: 'WebSocket 服务器',
websocketClients: 'WebSocket 客户端',
};
const cardConfig = ref<any>([]);
const getComponent = (type: ComponentKey) => {
return componentMap[type];
};
const getKeyByValue = (obj: typeof typeCh, value: string): string | undefined => {
return Object.entries(obj).find(([_, v]) => v === value)?.[0];
};
const addConfig = () => {
dialogTitle.value = '添加配置';
newTab.value = { name: '', data: {}, type: '' };
operateType.value = 'add';
visibleBody.value = true;
};
const editConfig = (item: any) => {
dialogTitle.value = '编辑配置';
newTab.value = { name: item.name, data: { ...item }, type: getKeyByValue(typeCh, item.type) || '' };
operateType.value = 'edit';
visibleBody.value = true;
};
const toggleProperty = async (item: any, tagData: string) => {
const type = getKeyByValue(typeCh, item.type);
const newData = { ...item };
newData[tagData] = !item[tagData];
if (type) {
newTab.value = { name: item.name, data: newData, type: type };
}
operateType.value = 'edit';
configIndex.value = networkConfig[newTab.value.type].findIndex((obj: any) => obj.name === item.name);
await saveConfig();
};
const delConfig = (item: any) => {
const type = getKeyByValue(typeCh, item.type);
if (type) {
newTab.value = { name: item.name, data: item, type: type };
}
configIndex.value = configIndex.value = networkConfig[newTab.value.type].findIndex(
(obj: any) => obj.name === item.name
);
operateType.value = 'delete';
saveConfig();
};
const selectType = (key: ComponentKey) => {
cardConfig.value = WebConfg.value.get(key) || [];
};
const onloadDefault = (key: ComponentKey) => {
newTab.value.data = {};
};
//检测重名
const checkName = (name: string) => {
const allConfigs = WebConfg.value.get('all')?.findIndex((obj: any) => obj.name === name);
if (newTab.value.name === '' || newTab.value.type === '') {
MessagePlugin.error('请填写完整信息');
return false;
} else if (allConfigs === -1 || newTab.value.data.name === name) {
return true;
} else {
MessagePlugin.error('名称已存在');
return false;
}
};
//保存
const saveConfig = async () => {
if (operateType.value == 'add') {
if (!checkName(newTab.value.name)) return;
newTab.value.data.name = newTab.value.name;
networkConfig[newTab.value.type].push(newTab.value.data);
} else if (operateType.value == 'edit') {
if (!checkName(newTab.value.name)) return;
newTab.value.data.name = newTab.value.name;
networkConfig[newTab.value.type][configIndex.value] = newTab.value.data;
} else if (operateType.value == 'delete') {
networkConfig[newTab.value.type].splice(configIndex.value, 1);
}
const userConfig = await getOB11Config();
if (!userConfig) return;
userConfig.network = networkConfig as any;
const success = await setOB11Config(userConfig);
if (success) {
operateType.value = '';
configIndex.value = 0;
MessagePlugin.success('配置保存成功');
await loadConfig();
visibleBody.value = false;
} else {
MessagePlugin.error('配置保存失败');
}
};
const getOB11Config = async (): Promise<OneBotConfig | undefined> => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
console.error('No stored credential found');
return;
}
const loginManager = new QQLoginManager(storedCredential);
return await loginManager.GetOB11Config();
};
const setOB11Config = async (config: OneBotConfig): Promise<boolean> => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
console.error('No stored credential found');
return false;
}
const loginManager = new QQLoginManager(storedCredential);
return await loginManager.SetOB11Config(config);
};
//获取卡片数据
const getAllData = (data: { [key: string]: Array<NetworkAdapterConfig> }) => {
cardConfig.value = [];
WebConfg.value.set('all', []);
for (const key in data) {
const configs = data[key as keyof NetworkAdapterConfig];
if (key in networkConfig) {
networkConfig[key] = [...configs];
const newConfigsArray = configs.map((config: any) => ({
...config,
type: typeCh[key as ComponentKey],
}));
WebConfg.value.set(key, newConfigsArray);
const allConfigs = WebConfg.value.get('all');
if (allConfigs) {
const newAllConfigs = [...allConfigs, ...newConfigsArray];
WebConfg.value.set('all', newAllConfigs);
}
cardConfig.value = WebConfg.value.get('all');
}
}
};
const loadConfig = async () => {
try {
const userConfig = await getOB11Config();
if (!userConfig) return;
const mergedConfig = loadConfigOnebot(userConfig);
getAllData(mergedConfig.network);
} catch (error) {
console.error('Error loading config:', error);
}
};
const copyText = async (text: string) => {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
MessagePlugin.success('复制成功');
} catch (err) {
console.error('复制失败', err);
} finally {
document.body.removeChild(textarea);
}
};
const handleResize = () => {
tabsWidth.value = window.innerWidth - 41 - menuWidth.value;
if (mediumScreen.matches) {
cardWidth.value = (tabsWidth.value - 20) / 2;
} else if (largeScreen.matches) {
cardWidth.value = (tabsWidth.value - 40) / 3;
} else {
cardWidth.value = tabsWidth.value;
}
loadPage.value = true;
setTimeout(() => {
cardHeight.value = window.innerHeight - (headerBox.value?.offsetHeight ?? 0) - (setting.value?.offsetHeight ?? 0) - 21;
}, 300)
};
emitter.on('sendWidth', (width) => {
if (typeof width === 'string') {
const strWidth = width as string;
menuWidth.value = parseInt(strWidth);
}
});
watch(menuWidth, (newValue, oldValue) => {
loadPage.value = false;
setTimeout(() => {
handleResize();
}, 300)
});
onMounted(() => {
loadConfig();
const cachedWidth = localStorage.getItem('menuWidth');
if (cachedWidth) {
menuWidth.value = parseInt(cachedWidth);
setTimeout(() => {
handleResize();
}, 300)
}
window.addEventListener('resize', () => {
setTimeout(() => {
handleResize();
}, 300)
});
});
onUnmounted(() => {
window.removeEventListener('resize', () => {
setTimeout(() => {
handleResize();
}, 300)
});
});
</script>
<style scoped>
.title {
padding: 20px 20px 0 20px;
display: flex;
justify-content: space-between;
}
.setting {
margin: 0 20px;
}
.setting-box {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
overflow-y: auto;
}
.setting-card {
width: 100%;
text-align: left;
}
.setting-content {
width: 100%;
}
.card-address svg {
fill: var(--td-brand-color);
cursor: pointer;
}
.local-box {
display: flex;
margin-top: 2px;
}
.local-icon {
flex: 1;
}
.local {
flex: 6;
margin: 0 10px 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-icon {
flex: 1;
cursor: pointer;
flex-direction: row;
}
.token-view {
display: flex;
align-items: center;
}
.token-view span {
flex: 5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-item-on {
color: white;
cursor: pointer;
background-image: linear-gradient(to top, #0ba360 0%, #3cba92 100%) !important;
}
.tag-item-off {
color: white;
cursor: pointer;
background-image: linear-gradient(to top, rgba(255, 8, 68, 0.93) 0%, #D54941 100%) !important;
}
.browse-icon {
flex: 2;
}
:global(.t-dialog__ctx .t-dialog__position) {
padding: 48px 10px;
}
@media (max-width: 1024px) {
.setting-box {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 786px) {
.setting-box {
grid-template-columns: 1fr;
}
}
.card-box {
margin: 10px 20px 0 20px;
}
.card-none {
line-height: 400px !important;
}
.dialog-body {
max-height: 50vh;
overflow-y: auto;
}
::-webkit-scrollbar {
width: 0;
background: transparent;
}
</style>
<style>
.setting-card .t-card__title {
text-align: left !important;
}
.setting-card .t-card__description {
margin-bottom: 0;
font-size: 12px;
}
.setting-base-info .t-descriptions__header {
font-size: 15px;
margin-bottom: 0;
}
.setting-base-info .t-descriptions__label {
padding: 0 var(--td-comp-paddingLR-l) !important;
}
.setting-base-info tr>td:last-child {
text-align: right;
}
.info-coll .t-collapse-panel__wrapper .t-collapse-panel__content {
padding: var(--td-comp-paddingTB-m) var(--td-comp-paddingLR-l);
}
</style>

View File

@@ -1,156 +0,0 @@
<template>
<div class="title">
<t-divider content="其余配置" align="left">
<template #content>
<div style="display: flex; justify-content: center; align-items: center">
<setting-icon />
<div style="margin-left: 5px">其余配置</div>
</div>
</template>
</t-divider>
</div>
<t-card class="card">
<div class="other-config-container">
<div class="other-config">
<t-form ref="form" :model="otherConfig" :label-align="labelAlign" label-width="auto" colon>
<t-form-item label="音乐签名地址" name="musicSignUrl" class="form-item">
<t-input v-model="otherConfig.musicSignUrl" />
</t-form-item>
<t-form-item label="启用本地文件到URL" name="enableLocalFile2Url" class="form-item">
<t-switch v-model="otherConfig.enableLocalFile2Url" />
</t-form-item>
<t-form-item label="启用上报解析合并消息" name="parseMultMsg" class="form-item">
<t-switch v-model="otherConfig.parseMultMsg" />
</t-form-item>
</t-form>
<div class="button-container">
<t-button @click="saveConfig">保存</t-button>
</div>
</div>
</div>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { OneBotConfig } from '../../../src/onebot/config/config';
import { QQLoginManager } from '@/backend/shell';
import { SettingIcon } from 'tdesign-icons-vue-next';
const otherConfig = ref<Partial<OneBotConfig>>({
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: true,
});
const labelAlign = ref<string>();
const getOB11Config = async (): Promise<OneBotConfig | undefined> => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
console.error('No stored credential found');
return;
}
const loginManager = new QQLoginManager(storedCredential);
return await loginManager.GetOB11Config();
};
const setOB11Config = async (config: OneBotConfig): Promise<boolean> => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
console.error('No stored credential found');
return false;
}
const loginManager = new QQLoginManager(storedCredential);
return await loginManager.SetOB11Config(config);
};
const loadConfig = async () => {
try {
const userConfig = await getOB11Config();
if (userConfig) {
otherConfig.value.musicSignUrl = userConfig.musicSignUrl;
otherConfig.value.enableLocalFile2Url = userConfig.enableLocalFile2Url;
otherConfig.value.parseMultMsg = userConfig.parseMultMsg;
}
} catch (error) {
console.error('Error loading config:', error);
}
};
const saveConfig = async () => {
try {
const userConfig = await getOB11Config();
if (userConfig) {
userConfig.musicSignUrl = otherConfig.value.musicSignUrl || '';
userConfig.enableLocalFile2Url = otherConfig.value.enableLocalFile2Url ?? false;
userConfig.parseMultMsg = otherConfig.value.parseMultMsg ?? true;
const success = await setOB11Config(userConfig);
if (success) {
MessagePlugin.success('配置保存成功');
} else {
MessagePlugin.error('配置保存失败');
}
}
} catch (error) {
console.error('Error saving config:', error);
MessagePlugin.error('配置保存失败');
}
};
onMounted(() => {
loadConfig();
const mediaQuery = window.matchMedia('(max-width: 768px)');
const handleMediaChange = (e: MediaQueryListEvent) => {
if (e.matches) {
labelAlign.value = 'top';
} else {
labelAlign.value = 'left';
}
};
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>
.title {
padding: 20px 20px 0 20px;
}
.card {
margin: 0 20px;
padding-top: 20px;
padding-bottom: 20px;
}
.other-config-container {
display: flex;
justify-content: center;
align-items: flex-start;
box-sizing: border-box;
}
.other-config {
width: 100%;
max-width: 500px;
border-radius: 8px;
}
.form-item {
margin-bottom: 20px;
text-align: left;
}
.button-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,101 @@
import { Chip } from '@heroui/chip'
import { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks'
import clsx from 'clsx'
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons'
import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives'
import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager'
import packageJson from '../../../package.json'
function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return (
<div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
WebUI
</Chip>
}
>
{packageJson.version}
</Chip>
<Chip
startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
NapCat
</Chip>
}
>
{error ? (
error.message
) : loading ? (
<Spinner size="sm" />
) : (
data?.version
)}
</Chip>
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
</Link>
</Tooltip>
</div>
)
}
export default function AboutPage() {
return (
<>
<title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
<div className="flex flex-col md:flex-row items-center">
<Image
alt="logo"
className="flex-shrink-0 w-52 md:w-48 mr-2"
src={logo}
/>
<div className="flex -mt-9 md:mt-0">
<WebUIIcon />
</div>
</div>
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
Created By
<div className="flex scale-80 -ml-5 -mr-5">
<BietiaopIcon />
</div>
</div>
<VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4">
<p
className={clsx(
title({
color: 'cyan',
shadow: true
}),
'!text-3xl'
)}
>
NapCat Contributors
</p>
<Image
className="w-[600px] max-w-full pointer-events-none select-none"
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
alt="Contributors"
/>
</div>
<NapCatRepoInfo />
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,135 @@
import { Tab, Tabs } from '@heroui/tabs'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useMediaQuery } from 'react-responsive'
import key from '@/const/key'
import useConfig from '@/hooks/use-config'
import useMusic from '@/hooks/use-music'
import OneBotConfigCard from './onebot'
import WebUIConfigCard from './webui'
export default function ConfigPage() {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
const {
control: onebotControl,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting: isOnebotSubmitting },
setValue: setOnebotValue
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false
}
})
const {
control: webuiControl,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting: isWebuiSubmitting },
setValue: setWebuiValue
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {}
}
})
const isMediumUp = useMediaQuery({ minWidth: 768 })
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
)
const { setListId, listId } = useMusic()
const resetOneBot = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl)
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
setOnebotValue('parseMultMsg', config.parseMultMsg)
}
const resetWebUI = () => {
setWebuiValue('musicListID', listId)
setWebuiValue('customIcons', customIcons)
setWebuiValue('background', b64img)
}
const onOneBotSubmit = handleOnebotSubmit((data) => {
try {
saveConfigWithoutNetwork(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onWebuiSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID)
setCustomIcons(data.customIcons)
setB64img(data.background)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async () => {
try {
await refreshConfig()
toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
}
}
useEffect(() => {
resetOneBot()
resetWebUI()
}, [config])
return (
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
<Tabs
aria-label="config tab"
fullWidth
className="w-full"
isVertical={isMediumUp}
classNames={{
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
panel: 'w-full',
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
cursor: 'bg-opacity-60 backdrop-blur-sm'
}}
>
<Tab title="OneBot配置" key="onebot">
<OneBotConfigCard
isSubmitting={isOnebotSubmitting}
onRefresh={onRefresh}
onSubmit={onOneBotSubmit}
control={onebotControl}
reset={resetOneBot}
/>
</Tab>
<Tab title="WebUI配置" key="webui">
<WebUIConfigCard
isSubmitting={isWebuiSubmitting}
onRefresh={onRefresh}
onSubmit={onWebuiSubmit}
control={webuiControl}
reset={resetWebUI}
/>
</Tab>
</Tabs>
</section>
)
}

View File

@@ -0,0 +1,70 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import SaveButtons from '@/components/button/save_buttons'
import SwitchCard from '@/components/switch_card'
export interface OneBotConfigCardProps {
control: Control<IConfig['onebot']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
return (
<>
<title>OneBot配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicSignUrl"
render={({ field }) => (
<Input
{...field}
label="音乐签名地址"
placeholder="请输入音乐签名地址"
/>
)}
/>
<Controller
control={control}
name="enableLocalFile2Url"
render={({ field }) => (
<SwitchCard
{...field}
description="启用本地文件到URL"
label="启用本地文件到URL"
/>
)}
/>
<Controller
control={control}
name="parseMultMsg"
render={({ field }) => (
<SwitchCard
{...field}
description="启用上报解析合并消息"
label="启用上报解析合并消息"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
</>
)
}
export default OneBotConfigCard

View File

@@ -0,0 +1,71 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import SaveButtons from '@/components/button/save_buttons'
import ImageInput from '@/components/input/image_input'
import { siteConfig } from '@/config/site'
export interface WebUIConfigCardProps {
control: Control<IConfig['webui']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="background"
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className="flex flex-col gap-2">
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => (
<ImageInput {...field} label={item.label} />
)}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
</>
)
}
export default WebUIConfigCard

View File

@@ -0,0 +1,68 @@
import { Button } from '@heroui/button'
import clsx from 'clsx'
import { motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb'
import oneBotHttpApi from '@/const/ob_api'
import type { OneBotHttpApi } from '@/const/ob_api'
import OneBotApiDebug from '@/components/onebot/api/debug'
import OneBotApiNavList from '@/components/onebot/api/nav_list'
export default function HttpDebug() {
const [selectedApi, setSelectedApi] =
useState<keyof OneBotHttpApi>('/set_qq_profile')
const data = oneBotHttpApi[selectedApi]
const contentRef = useRef<HTMLDivElement>(null)
const [openSideBar, setOpenSideBar] = useState(true)
useEffect(() => {
contentRef?.current?.scrollTo?.({
top: 0,
behavior: 'smooth'
})
}, [selectedApi])
return (
<>
<title>HTTP调试 - NapCat WebUI</title>
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={setSelectedApi}
openSideBar={openSideBar}
/>
<div
ref={contentRef}
className="flex-1 h-full overflow-x-hidden relative"
>
<motion.div
className="sticky top-0 z-20 md:!ml-4"
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
>
<Button
isIconOnly
color="danger"
radius="md"
variant="shadow"
size="sm"
onPress={() => setOpenSideBar(!openSideBar)}
>
<TbSquareRoundedChevronLeftFilled
size={24}
className={clsx(
'transition-transform',
openSideBar ? '' : 'transform rotate-180'
)}
/>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from 'react-router-dom'
export default function DebugPage() {
return <Outlet />
}

View File

@@ -0,0 +1,92 @@
import { Button } from '@heroui/button'
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { useCallback, useState } from 'react'
import toast from 'react-hot-toast'
import ChatInputModal from '@/components/chat_input/modal'
import OneBotMessageList from '@/components/onebot/message_list'
import OneBotSendModal from '@/components/onebot/send_modal'
import WSStatus from '@/components/onebot/ws_status'
import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
export default function WSDebug() {
const url = new URL(window.location.origin).href
const defaultWsUrl = url.replace('http', 'ws').replace(':6099', ':3000')
const [socketConfig, setSocketConfig] = useState({
url: defaultWsUrl,
token: ''
})
const [inputUrl, setInputUrl] = useState(socketConfig.url)
const [inputToken, setInputToken] = useState(socketConfig.token)
const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
useWebSocketDebug(socketConfig.url, socketConfig.token)
const handleConnect = useCallback(() => {
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
toast.error('WebSocket URL 不合法')
return
}
setSocketConfig({
url: inputUrl,
token: inputToken
})
}, [inputUrl, inputToken])
return (
<>
<title>Websocket调试 - NapCat WebUI</title>
<div className="h-[calc(100vh-4rem)] overflow-hidden flex flex-col">
<Card className="mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm">
<CardBody className="gap-2">
<div className="grid gap-2 items-center md:grid-cols-5">
<Input
className="col-span-2"
label="WebSocket URL"
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="输入 WebSocket URL"
/>
<Input
className="col-span-2"
label="Token"
type="text"
value={inputToken}
onChange={(e) => setInputToken(e.target.value)}
placeholder="输入 Token"
/>
<div className="flex-shrink-0 flex gap-2 col-span-2 md:col-span-1">
<Button
color="danger"
onPress={handleConnect}
size="lg"
radius="full"
className="w-full md:w-auto"
>
</Button>
</div>
</div>
<div className="p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]">
<div className="grid gap-2 md:grid-cols-5 items-center md:w-fit">
<WSStatus state={readyState} />
<div className="md:w-64 max-w-full col-span-2">
{FilterMessagesType}
</div>
<OneBotSendModal sendMessage={sendMessage} />
<ChatInputModal />
</div>
</div>
</CardBody>
</Card>
<div className="flex-1 overflow-hidden">
<OneBotMessageList messages={filteredMessages} />
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,118 @@
import { Card, CardBody } from '@heroui/card'
import { useRequest } from 'ahooks'
import { useCallback, useEffect, useState } from 'react'
import { useRef } from 'react'
import toast from 'react-hot-toast'
import NetworkItemDisplay from '@/components/display_network_item'
import Hitokoto from '@/components/hitokoto'
import QQInfoCard from '@/components/qq_info_card'
import SystemInfo from '@/components/system_info'
import SystemStatusDisplay from '@/components/system_status_display'
import useConfig from '@/hooks/use-config'
import QQManager from '@/controllers/qq_manager'
import WebUIManager from '@/controllers/webui_manager'
const Networks: React.FC = () => {
const { config, refreshConfig } = useConfig()
const allNetWorkConfigLength =
config.network.httpClients.length +
config.network.websocketClients.length +
config.network.websocketServers.length +
config.network.httpServers.length
useEffect(() => {
refreshConfig()
}, [])
return (
<div className="grid grid-cols-8 md:grid-cols-3 lg:grid-cols-6 gap-y-2 gap-x-1 md:gap-y-4 md:gap-x-4 py-5">
<NetworkItemDisplay count={allNetWorkConfigLength} label="网络配置" />
<NetworkItemDisplay
count={config.network.httpServers.length}
label="HTTP服务器"
size="sm"
/>
<NetworkItemDisplay
count={config.network.httpClients.length}
label="HTTP客户端"
size="sm"
/>
<NetworkItemDisplay
count={config.network.websocketServers.length}
label="WS服务器"
size="sm"
/>
<NetworkItemDisplay
count={config.network.websocketClients.length}
label="WS客户端"
size="sm"
/>
</div>
)
}
const QQInfo: React.FC = () => {
const { data, loading, error } = useRequest(QQManager.getQQLoginInfo)
return <QQInfoCard data={data} error={error} loading={loading} />
}
export interface SystemStatusCardProps {
setArchInfo: (arch: string | undefined) => void
}
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
const [systemStatus, setSystemStatus] = useState<SystemStatus>()
const isSetted = useRef(false)
const getStatus = useCallback(() => {
try {
const event = WebUIManager.getSystemStatus(setSystemStatus)
return event
} catch (error) {
toast.error('获取系统状态失败')
}
}, [])
useEffect(() => {
const close = getStatus()
return () => {
close?.close()
}
}, [getStatus])
useEffect(() => {
if (systemStatus?.arch && !isSetted.current) {
setArchInfo(systemStatus.arch)
isSetted.current = true
}
}, [systemStatus, setArchInfo])
return <SystemStatusDisplay data={systemStatus} />
}
const DashboardIndexPage: React.FC = () => {
const [archInfo, setArchInfo] = useState<string>()
return (
<>
<title> - NapCat WebUI</title>
<section className="w-full p-2 md:p-4 md:max-w-[1000px] mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
<div className="flex flex-col gap-2">
<QQInfo />
<SystemInfo archInfo={archInfo} />
</div>
<SystemStatusCard setArchInfo={setArchInfo} />
</div>
<Networks />
<Card className="bg-opacity-60 shadow-sm shadow-danger-50">
<CardBody>
<Hitokoto />
</CardBody>
</Card>
</section>
</>
)
}
export default DashboardIndexPage

View File

@@ -0,0 +1,79 @@
import { Tab, Tabs } from '@heroui/tabs'
import { useRequest } from 'ahooks'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import HistoryLogs from '@/components/log_com/history'
import RealTimeLogs from '@/components/log_com/realtime'
import WebUIManager from '@/controllers/webui_manager'
export default function LogsPage() {
const {
data: logList,
loading: logListLoading,
error: logListError,
refresh: refreshLogList
} = useRequest(WebUIManager.getLogList)
const [selectedLog, setSelectedLog] = useState<string | null>(null)
const [logContent, setLogContent] = useState<string | null>(null)
const [logLoading, setLogLoading] = useState<boolean>(false)
const onLogSelect = (name: string) => {
setSelectedLog(name)
}
const onLoadLog = async () => {
if (!selectedLog) {
return
}
setLogLoading(true)
try {
const result = await WebUIManager.getLogContent(selectedLog)
setLogContent(result)
} catch (error) {
const msg = (error as Error).message
toast.error(`加载日志失败: ${msg}`)
} finally {
setLogLoading(false)
}
}
useEffect(() => {
if (logList && logList.length > 0) {
setSelectedLog(logList[0])
}
}, [logList])
useEffect(() => {
if (selectedLog) {
onLoadLog()
}
}, [selectedLog])
return (
<div className="h-[calc(100vh_-_8rem)] flex flex-col gap-4 items-center pt-4 px-2">
<Tabs
aria-label="Logs"
classNames={{
panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4',
base: 'flex-shrink-0 !h-fit',
tabList: 'bg-opacity-50 backdrop-blur-sm',
cursor: 'bg-opacity-60 backdrop-blur-sm'
}}
>
<Tab title="实时日志">
<RealTimeLogs />
</Tab>
<Tab title="历史日志">
<HistoryLogs
list={logList || []}
onSelect={onLogSelect}
selectedLog={selectedLog || undefined}
refreshList={refreshLogList}
refreshLog={onLoadLog}
listLoading={logListLoading}
logLoading={logLoading}
listError={logListError}
logContent={logContent || undefined}
/>
</Tab>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,367 @@
import { Button } from '@heroui/button'
import { useDisclosure } from '@heroui/modal'
import { Tab, Tabs } from '@heroui/tabs'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { IoMdRefresh } from 'react-icons/io'
import AddButton from '@/components/button/add_button'
import HTTPClientDisplayCard from '@/components/display_card/http_client'
import HTTPServerDisplayCard from '@/components/display_card/http_server'
import WebsocketClientDisplayCard from '@/components/display_card/ws_client'
import WebsocketServerDisplayCard from '@/components/display_card/ws_server'
import NetworkFormModal from '@/components/network_edit/modal'
import PageLoading from '@/components/page_loading'
import useConfig from '@/hooks/use-config'
import useDialog from '@/hooks/use-dialog'
export interface SectionProps {
title: string
color?:
| 'violet'
| 'yellow'
| 'blue'
| 'cyan'
| 'green'
| 'pink'
| 'foreground'
icon: React.ReactNode
children: React.ReactNode
}
export interface EmptySectionProps {
isEmpty: boolean
}
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
return (
<div
className={clsx('text-default-400', {
hidden: !isEmpty
})}
>
</div>
)
}
export default function NetworkPage() {
const {
config,
refreshConfig,
deleteNetworkConfig,
enableNetworkConfig,
enableDebugNetworkConfig
} = useConfig()
const [activeField, setActiveField] =
useState<keyof OneBotConfig['network']>('httpServers')
const [activeName, setActiveName] = useState<string>('')
const {
network: { httpServers, httpClients, websocketServers, websocketClients }
} = config
const [loading, setLoading] = useState(false)
const { isOpen, onOpen, onOpenChange } = useDisclosure()
const dialog = useDialog()
const activeData = useMemo(() => {
const findData = config.network[activeField].find(
(item) => item.name === activeName
)
return findData
}, [activeField, activeName, config])
const refresh = async () => {
setLoading(true)
try {
await refreshConfig()
setLoading(false)
} catch (error) {
const msg = (error as Error).message
toast.error(`获取配置失败: ${msg}`)
} finally {
setLoading(false)
}
}
const handleClickCreate = (key: keyof OneBotConfig['network']) => {
setActiveField(key)
setActiveName('')
onOpen()
}
const onDelete = async (
field: keyof OneBotConfig['network'],
name: string
) => {
return new Promise<void>((resolve, reject) => {
dialog.confirm({
title: '删除配置',
content: `确定要删除配置「${name}」吗?`,
onConfirm: async () => {
try {
await deleteNetworkConfig(field, name)
toast.success('删除配置成功')
resolve()
} catch (error) {
const msg = (error as Error).message
toast.error(`删除配置失败: ${msg}`)
reject(error)
}
},
onCancel: () => {
resolve()
}
})
})
}
const onEnable = async (
field: keyof OneBotConfig['network'],
name: string
) => {
try {
await enableNetworkConfig(field, name)
toast.success('更新配置成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`更新配置失败: ${msg}`)
throw error
}
}
const onEnableDebug = async (
field: keyof OneBotConfig['network'],
name: string
) => {
try {
await enableDebugNetworkConfig(field, name)
toast.success('更新配置成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`更新配置失败: ${msg}`)
throw error
}
}
const onEdit = (field: keyof OneBotConfig['network'], name: string) => {
setActiveField(field)
setActiveName(name)
onOpen()
}
const renderCard = <T extends keyof OneBotConfig['network']>(
type: T,
item: OneBotConfig['network'][T][0],
showType = false
) => {
switch (type) {
case 'httpServers':
return (
<HTTPServerDisplayCard
key={item.name}
showType={showType}
data={item as OneBotConfig['network']['httpServers'][0]}
onDelete={async () => {
await onDelete('httpServers', item.name)
}}
onEdit={() => {
onEdit('httpServers', item.name)
}}
onEnable={async () => {
await onEnable('httpServers', item.name)
}}
onEnableDebug={async () => {
await onEnableDebug('httpServers', item.name)
}}
/>
)
case 'httpClients':
return (
<HTTPClientDisplayCard
key={item.name}
showType={showType}
data={item as OneBotConfig['network']['httpClients'][0]}
onDelete={async () => {
await onDelete('httpClients', item.name)
}}
onEdit={() => {
onEdit('httpClients', item.name)
}}
onEnable={async () => {
await onEnable('httpClients', item.name)
}}
onEnableDebug={async () => {
await onEnableDebug('httpClients', item.name)
}}
/>
)
case 'websocketServers':
return (
<WebsocketServerDisplayCard
key={item.name}
showType={showType}
data={item as OneBotConfig['network']['websocketServers'][0]}
onDelete={async () => {
await onDelete('websocketServers', item.name)
}}
onEdit={() => {
onEdit('websocketServers', item.name)
}}
onEnable={async () => {
await onEnable('websocketServers', item.name)
}}
onEnableDebug={async () => {
await onEnableDebug('websocketServers', item.name)
}}
/>
)
case 'websocketClients':
return (
<WebsocketClientDisplayCard
key={item.name}
showType={showType}
data={item as OneBotConfig['network']['websocketClients'][0]}
onDelete={async () => {
await onDelete('websocketClients', item.name)
}}
onEdit={() => {
onEdit('websocketClients', item.name)
}}
onEnable={async () => {
await onEnable('websocketClients', item.name)
}}
onEnableDebug={async () => {
await onEnableDebug('websocketClients', item.name)
}}
/>
)
}
}
const tabs = [
{
key: 'all',
title: '全部',
items: [
...httpServers,
...httpClients,
...websocketServers,
...websocketClients
]
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => {
if (httpServers.find((i) => i.name === item.name)) {
return renderCard(
'httpServers',
item as OneBotConfig['network']['httpServers'][0],
true
)
}
if (httpClients.find((i) => i.name === item.name)) {
return renderCard(
'httpClients',
item as OneBotConfig['network']['httpClients'][0],
true
)
}
if (websocketServers.find((i) => i.name === item.name)) {
return renderCard(
'websocketServers',
item as OneBotConfig['network']['websocketServers'][0],
true
)
}
if (websocketClients.find((i) => i.name === item.name)) {
return renderCard(
'websocketClients',
item as OneBotConfig['network']['websocketClients'][0],
true
)
}
return null
})
},
{
key: 'httpServers',
title: 'HTTP服务器',
items: httpServers.map((item) => renderCard('httpServers', item))
},
{
key: 'httpClients',
title: 'HTTP客户端',
items: httpClients.map((item) => renderCard('httpClients', item))
},
{
key: 'websocketServers',
title: 'Websocket服务器',
items: websocketServers.map((item) =>
renderCard('websocketServers', item)
)
},
{
key: 'websocketClients',
title: 'Websocket客户端',
items: websocketClients.map((item) =>
renderCard('websocketClients', item)
)
}
]
useEffect(() => {
refresh()
}, [])
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<NetworkFormModal
data={activeData}
field={activeField}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
<PageLoading loading={loading} />
<div className="flex mb-6 items-center gap-4">
<AddButton onOpen={handleClickCreate} />
<Button
isIconOnly
color="primary"
radius="full"
variant="flat"
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
<Tabs
aria-label="Network Configs"
className="max-w-full"
items={tabs}
classNames={{
tabList: 'bg-opacity-50 backdrop-blur-sm',
cursor: 'bg-opacity-60 backdrop-blur-sm'
}}
>
{(item) => (
<Tab key={item.key} title={item.title}>
<EmptySection isEmpty={!item.items.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{item.items}
</div>
</Tab>
)}
</Tabs>
</div>
</>
)
}

View File

@@ -0,0 +1,30 @@
import { Route, Routes } from 'react-router-dom'
import DefaultLayout from '@/layouts/default'
import DashboardIndexPage from './dashboard'
import AboutPage from './dashboard/about'
import ConfigPage from './dashboard/config'
import DebugPage from './dashboard/debug'
import HttpDebug from './dashboard/debug/http'
import WSDebug from './dashboard/debug/websocket'
import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network'
export default function IndexPage() {
return (
<DefaultLayout>
<Routes>
<Route element={<DashboardIndexPage />} path="/" />
<Route element={<NetworkPage />} path="/network" />
<Route element={<ConfigPage />} path="/config" />
<Route element={<LogsPage />} path="/logs" />
<Route element={<DebugPage />} path="/debug">
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route element={<AboutPage />} path="/about" />
</Routes>
</DefaultLayout>
)
}

View File

@@ -1,61 +0,0 @@
<template>
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-switch v-model="props.config.data.enable" />
</t-form-item>
<t-form-item label="URL">
<t-input v-model="props.config.data.url" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="props.config.data.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="报告自身消息">
<t-switch v-model="props.config.data.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="props.config.data.token" />
</t-form-item>
<t-form-item label="调试模式">
<t-switch v-model="props.config.data.debug" />
</t-form-item>
</t-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { HttpClientConfig } from '../../../../src/onebot/config/config';
const defaultConfig: HttpClientConfig = {
name: 'http-client',
enable: false,
url: 'http://localhost:8080',
messagePostFormat: 'array',
reportSelfMessage: false,
token: '',
debug: false,
};
const props = defineProps<{
config: { data: HttpClientConfig };
}>();
props.config.data = { ...defaultConfig, ...props.config.data };
const messageFormatOptions = ref([
{ label: 'Array', value: 'array' },
{ label: 'String', value: 'string' },
]);
watch(
() => props.config.data.messagePostFormat,
(newValue) => {
if (newValue !== 'array' && newValue !== 'string') {
props.config.data.messagePostFormat = 'array';
}
}
);
</script>
<style scoped></style>

View File

@@ -1,69 +0,0 @@
<template>
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-switch v-model="props.config.data.enable" />
</t-form-item>
<t-form-item label="端口">
<t-input v-model.number="props.config.data.port" type="number" />
</t-form-item>
<t-form-item label="主机">
<t-input v-model="props.config.data.host" type="text" />
</t-form-item>
<t-form-item label="启用 CORS">
<t-switch v-model="props.config.data.enableCors" />
</t-form-item>
<t-form-item label="启用 WS">
<t-switch v-model="props.config.data.enableWebsocket" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="props.config.data.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="props.config.data.token" type="text" />
</t-form-item>
<t-form-item label="调试模式">
<t-switch v-model="props.config.data.debug" />
</t-form-item>
</t-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { HttpServerConfig } from '../../../../src/onebot/config/config';
const defaultConfig: HttpServerConfig = {
name: 'http-server',
enable: false,
port: 3000,
host: '0.0.0.0',
enableCors: true,
enableWebsocket: true,
messagePostFormat: 'array',
token: '',
debug: false,
};
const props = defineProps<{
config: { data: HttpServerConfig };
}>();
props.config.data = { ...defaultConfig, ...props.config.data };
const messageFormatOptions = ref([
{ label: 'Array', value: 'array' },
{ label: 'String', value: 'string' },
]);
watch(
() => props.config.data.messagePostFormat,
(newValue) => {
if (newValue !== 'array' && newValue !== 'string') {
props.config.data.messagePostFormat = 'array';
}
}
);
</script>
<style scoped></style>

View File

@@ -1,73 +0,0 @@
<template>
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-switch v-model="props.config.data.enable" />
</t-form-item>
<t-form-item label="端口">
<t-input v-model.number="props.config.data.port" type="number" />
</t-form-item>
<t-form-item label="主机">
<t-input v-model="props.config.data.host" type="text" />
</t-form-item>
<t-form-item label="报告自身消息">
<t-switch v-model="props.config.data.reportSelfMessage" />
</t-form-item>
<t-form-item label="启用 CORS">
<t-switch v-model="props.config.data.enableCors" />
</t-form-item>
<t-form-item label="启用 WS">
<t-switch v-model="props.config.data.enableWebsocket" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="props.config.data.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="props.config.data.token" type="text" />
</t-form-item>
<t-form-item label="调试模式">
<t-switch v-model="props.config.data.debug" />
</t-form-item>
</t-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { HttpSseServerConfig } from '../../../../src/onebot/config/config';
const defaultConfig: HttpSseServerConfig = {
name: 'http-sse-server',
enable: false,
port: 3000,
host: '0.0.0.0',
enableCors: true,
enableWebsocket: true,
messagePostFormat: 'array',
token: '',
debug: false,
reportSelfMessage: false,
};
const props = defineProps<{
config: { data: HttpSseServerConfig };
}>();
props.config.data = { ...defaultConfig, ...props.config.data };
const messageFormatOptions = ref([
{ label: 'Array', value: 'array' },
{ label: 'String', value: 'string' },
]);
watch(
() => props.config.data.messagePostFormat,
(newValue) => {
if (newValue !== 'array' && newValue !== 'string') {
props.config.data.messagePostFormat = 'array';
}
}
);
</script>
<style scoped></style>

View File

@@ -1,66 +0,0 @@
<template>
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-switch v-model="props.config.data.enable" />
</t-form-item>
<t-form-item label="URL">
<t-input v-model="props.config.data.url" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="props.config.data.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="报告自身消息">
<t-switch v-model="props.config.data.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="props.config.data.token" />
</t-form-item>
<t-form-item label="调试模式">
<t-switch v-model="props.config.data.debug" />
</t-form-item>
<t-form-item label="心跳间隔">
<t-input v-model.number="props.config.data.heartInterval" type="number" />
</t-form-item>
</t-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WebsocketClientConfig } from '../../../../src/onebot/config/config';
const defaultConfig: WebsocketClientConfig = {
name: 'websocket-client',
enable: false,
url: 'ws://localhost:8082',
messagePostFormat: 'array',
reportSelfMessage: false,
reconnectInterval: 5000,
token: '',
debug: false,
heartInterval: 30000,
};
const props = defineProps<{
config: { data: WebsocketClientConfig };
}>();
props.config.data = { ...defaultConfig, ...props.config.data };
const messageFormatOptions = ref([
{ label: 'Array', value: 'array' },
{ label: 'String', value: 'string' },
]);
watch(
() => props.config.data.messagePostFormat,
(newValue) => {
if (newValue !== 'array' && newValue !== 'string') {
props.config.data.messagePostFormat = 'array';
}
}
);
</script>
<style scoped></style>

View File

@@ -1,73 +0,0 @@
<template>
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-switch v-model="props.config.data.enable" />
</t-form-item>
<t-form-item label="主机">
<t-input v-model="props.config.data.host" />
</t-form-item>
<t-form-item label="端口">
<t-input v-model.number="props.config.data.port" type="number" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="props.config.data.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="上报自身消息">
<t-switch v-model="props.config.data.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="props.config.data.token" />
</t-form-item>
<t-form-item label="强制推送事件">
<t-switch v-model="props.config.data.enableForcePushEvent" />
</t-form-item>
<t-form-item label="调试模式">
<t-switch v-model="props.config.data.debug" />
</t-form-item>
<t-form-item label="心跳间隔">
<t-input v-model.number="props.config.data.heartInterval" type="number" />
</t-form-item>
</t-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WebsocketServerConfig } from '../../../../src/onebot/config/config';
const defaultConfig: WebsocketServerConfig = {
name: 'websocket-server',
enable: false,
host: '0.0.0.0',
port: 3001,
messagePostFormat: 'array',
reportSelfMessage: false,
token: '',
enableForcePushEvent: true,
debug: false,
heartInterval: 30000,
};
const props = defineProps<{
config: { data: WebsocketServerConfig };
}>();
props.config.data = { ...defaultConfig, ...props.config.data };
const messageFormatOptions = ref([
{ label: 'Array', value: 'array' },
{ label: 'String', value: 'string' },
]);
watch(
() => props.config.data.messagePostFormat,
(newValue) => {
if (newValue !== 'array' && newValue !== 'string') {
props.config.data.messagePostFormat = 'array';
}
}
);
</script>
<style scoped></style>

View File

@@ -0,0 +1,176 @@
import { Button } from '@heroui/button'
import { CardBody, CardHeader } from '@heroui/card'
import { Image } from '@heroui/image'
import { Tab, Tabs } from '@heroui/tabs'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
import HoverEffectCard from '@/components/effect_card'
import { title } from '@/components/primitives'
import QrCodeLogin from '@/components/qr_code_login'
import QuickLogin from '@/components/quick_login'
import type { QQItem } from '@/components/quick_login'
import { ThemeSwitch } from '@/components/theme-switch'
import logo from '@/assets/images/logo.png'
import QQManager from '@/controllers/qq_manager'
import PureLayout from '@/layouts/pure'
export default function QQLoginPage() {
const navigate = useNavigate()
const [uinValue, setUinValue] = useState<string>('')
const [isLoading, setIsLoading] = useState<boolean>(false)
const [qrcode, setQrcode] = useState<string>('')
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([])
const [refresh, setRefresh] = useState<boolean>(false)
const firstLoad = useRef<boolean>(true)
const onSubmit = async () => {
if (!uinValue) {
toast.error('请选择快捷登录的QQ')
return
}
setIsLoading(true)
try {
await QQManager.setQuickLogin(uinValue)
} catch (error) {
const msg = (error as Error).message
toast.error(`快速登录QQ失败: ${msg}`)
} finally {
setTimeout(() => {
setIsLoading(false)
}, 1000)
}
}
const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true)
try {
const data = await QQManager.checkQQLoginStatusWithQrcode()
if (firstLoad.current) {
setIsLoading(false)
firstLoad.current = false
}
if (data.isLogin) {
toast.success('QQ登录成功')
navigate('/', { replace: true })
} else {
setQrcode(data.qrcodeurl)
}
} catch (error) {
const msg = (error as Error).message
toast.error(`获取二维码失败: ${msg}`)
}
}
const onUpdateQQList = async () => {
setRefresh(true)
try {
const data = await QQManager.getQQQuickLoginListNew()
setQQList(data)
} catch (error) {
try {
const data = await QQManager.getQQQuickLoginList()
const qqList = data.map((item) => ({
uin: item
}))
setQQList(qqList)
} catch (error) {
const msg = (error as Error).message
toast.error(`获取QQ列表失败: ${msg}`)
}
} finally {
setRefresh(false)
}
}
const handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
e
) => {
setUinValue(e.target.value)
}
useEffect(() => {
const timer = setInterval(() => {
onUpdateQrCode()
}, 3000)
onUpdateQrCode()
onUpdateQQList()
return () => clearInterval(timer)
}, [])
return (
<>
<title>QQ登录 - NapCat WebUI</title>
<PureLayout>
<div className="w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden">
<HoverEffectCard
className="items-center gap-4 pt-0 pb-6 bg-default-50"
maxXRotation={3}
maxYRotation={3}
>
<CardHeader className="inline-block max-w-lg text-center justify-center">
<div className="flex items-center justify-center w-full gap-2 pt-10">
<Image alt="logo" height="7em" src={logo} />
<div>
<span className={title()}>Web&nbsp;</span>
<span className={title({ color: 'violet' })}>
Login&nbsp;
</span>
</div>
</div>
<ThemeSwitch className="absolute right-4 top-4" />
</CardHeader>
<CardBody className="flex gap-5 p-10 pt-0">
<Tabs
fullWidth
classNames={{
tabList: 'shadow-sm dark:shadow-none'
}}
isDisabled={isLoading}
size="lg"
>
<Tab key="shortcut" title="快速登录">
<QuickLogin
handleSelectionChange={handleSelectionChange}
isLoading={isLoading}
qqList={qqList}
refresh={refresh}
selectedQQ={uinValue}
onSubmit={onSubmit}
onUpdateQQList={onUpdateQQList}
/>
</Tab>
<Tab key="qrcode" title="扫码登录">
<QrCodeLogin qrcode={qrcode} />
</Tab>
</Tabs>
<Button
className="w-fit mx-auto"
variant="light"
color="primary"
onPress={() => {
navigate('/web_login', {
replace: true
})
}}
>
Web Login
</Button>
</CardBody>
</HoverEffectCard>
</div>
</PureLayout>
</>
)
}

View File

@@ -0,0 +1,142 @@
import { Button } from '@heroui/button'
import { CardBody, CardHeader } from '@heroui/card'
import { Image } from '@heroui/image'
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { IoKeyOutline } from 'react-icons/io5'
import { useNavigate } from 'react-router-dom'
import key from '@/const/key'
import HoverEffectCard from '@/components/effect_card'
import { title } from '@/components/primitives'
import { ThemeSwitch } from '@/components/theme-switch'
import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager'
import PureLayout from '@/layouts/pure'
export default function WebLoginPage() {
const urlSearchParams = new URLSearchParams(window.location.search)
const token = urlSearchParams.get('token')
const navigate = useNavigate()
const [tokenValue, setTokenValue] = useState<string>(token || '')
const [isLoading, setIsLoading] = useState<boolean>(false)
const [, setLocalToken] = useLocalStorage<string>(key.token, '')
const onSubmit = async () => {
if (!tokenValue) {
toast.error('请输入token')
return
}
setIsLoading(true)
try {
const data = await WebUIManager.loginWithToken(tokenValue)
if (data) {
setLocalToken(data)
navigate('/qq_login', { replace: true })
}
} catch (error) {
toast.error((error as Error).message)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (token) {
onSubmit()
}
}, [])
return (
<>
<title>WebUI登录 - NapCat WebUI</title>
<PureLayout>
<div className="w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden">
<HoverEffectCard
className="items-center gap-4 pt-0 pb-6 bg-default-50"
maxXRotation={3}
maxYRotation={3}
>
<CardHeader className="inline-block max-w-lg text-center justify-center">
<div className="flex items-center justify-center w-full gap-2 pt-10">
<Image alt="logo" height="7em" src={logo} />
<div>
<span className={title()}>Web&nbsp;</span>
<span className={title({ color: 'violet' })}>
Login&nbsp;
</span>
</div>
</div>
<ThemeSwitch className="absolute right-4 top-4" />
</CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
<Button
className="mx-10 mt-10 text-lg py-7"
color="primary"
isLoading={isLoading}
radius="full"
size="lg"
variant="shadow"
onPress={onSubmit}
>
{!isLoading && (
<Image
alt="logo"
classNames={{
wrapper: '-ml-8'
}}
height="2em"
src={logo}
/>
)}
</Button>
</CardBody>
</HoverEffectCard>
</div>
</PureLayout>
</>
)
}