mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
feat: 新版webui
This commit is contained in:
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<div class="basic-info">
|
||||
<h1>面板基础信息</h1>
|
||||
<p>这里显示面板的基础信息。</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
101
napcat.webui/src/pages/dashboard/about.tsx
Normal file
101
napcat.webui/src/pages/dashboard/about.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
135
napcat.webui/src/pages/dashboard/config/index.tsx
Normal file
135
napcat.webui/src/pages/dashboard/config/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
napcat.webui/src/pages/dashboard/config/onebot.tsx
Normal file
70
napcat.webui/src/pages/dashboard/config/onebot.tsx
Normal 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
|
||||
71
napcat.webui/src/pages/dashboard/config/webui.tsx
Normal file
71
napcat.webui/src/pages/dashboard/config/webui.tsx
Normal 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
|
||||
68
napcat.webui/src/pages/dashboard/debug/http/index.tsx
Normal file
68
napcat.webui/src/pages/dashboard/debug/http/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
napcat.webui/src/pages/dashboard/debug/index.tsx
Normal file
5
napcat.webui/src/pages/dashboard/debug/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function DebugPage() {
|
||||
return <Outlet />
|
||||
}
|
||||
92
napcat.webui/src/pages/dashboard/debug/websocket/index.tsx
Normal file
92
napcat.webui/src/pages/dashboard/debug/websocket/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
118
napcat.webui/src/pages/dashboard/index.tsx
Normal file
118
napcat.webui/src/pages/dashboard/index.tsx
Normal 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
|
||||
79
napcat.webui/src/pages/dashboard/logs.tsx
Normal file
79
napcat.webui/src/pages/dashboard/logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
367
napcat.webui/src/pages/dashboard/network.tsx
Normal file
367
napcat.webui/src/pages/dashboard/network.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
napcat.webui/src/pages/index.tsx
Normal file
30
napcat.webui/src/pages/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
176
napcat.webui/src/pages/qq_login.tsx
Normal file
176
napcat.webui/src/pages/qq_login.tsx
Normal 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 </span>
|
||||
<span className={title({ color: 'violet' })}>
|
||||
Login
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
142
napcat.webui/src/pages/web_login.tsx
Normal file
142
napcat.webui/src/pages/web_login.tsx
Normal 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 </span>
|
||||
<span className={title({ color: 'violet' })}>
|
||||
Login
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user