Merge pull request #604 from Ander-pixe/webui-new

feat:实时日志、关于and部分样式优化
This commit is contained in:
手瓜一十雪
2024-12-05 21:10:08 +08:00
committed by GitHub
23 changed files with 1284 additions and 171 deletions

View File

@@ -5,12 +5,13 @@
"type": "module",
"scripts": {
"webui:lint": "eslint --fix src/**/*.{js,ts,vue}",
"webui:dev": "vite",
"webui:dev": "vite --host",
"webui:build": "vite build",
"webui:preview": "vite preview"
},
"dependencies": {
"eslint-plugin-prettier": "^5.2.1",
"event-source-polyfill": "^1.0.31",
"qrcode": "^1.5.4",
"tdesign-icons-vue-next": "^0.3.3",
"tdesign-vue-next": "^1.10.3",
@@ -20,6 +21,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-vue": "^5.1.4",

View File

@@ -109,4 +109,4 @@ onUnmounted(() => {
window.removeEventListener('resize', haddingFbars);
});
</script>
<style scoped></style>
<style></style>

Binary file not shown.

View File

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

View File

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

View File

@@ -1,18 +1,28 @@
<template>
<t-layout class="dashboard-container">
<div ref="menuRef">
<SidebarMenu :menu-items="menuItems" class="sidebar-menu" />
<div v-if="!mediaQuery.matches">
<SidebarMenu
:menu-items="menuItems"
class="sidebar-menu"
:menu-width="sidebarWidth"
/>
</div>
<t-layout>
<router-view />
</t-layout>
<div v-if="mediaQuery.matches" class="bottom-menu">
<BottomMenu :menu-items="menuItems" />
</div>
</t-layout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import SidebarMenu from './webui/Nav.vue';
import BottomMenu from './webui/NavBottom.vue';
import emitter from '@/ts/event-bus';
const mediaQuery = window.matchMedia('(max-width: 768px)');
const sidebarWidth = ['232px', '64px'];
interface MenuItem {
value: string;
icon: string;
@@ -27,13 +37,18 @@ const menuItems = ref<MenuItem[]>([
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
]);
const menuRef = ref<HTMLDivElement | null>(null);
emitter.on('sendMenu', (event) => {
emitter.emit('sendWidth', menuRef.value?.offsetWidth);
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0');
const menuWidth = event ? sidebarWidth[1] : sidebarWidth[0];
emitter.emit('sendWidth', menuWidth);
localStorage.setItem('menuWidth', menuWidth.toString() || '0');
});
onMounted(() => {
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0');
if (mediaQuery.matches){
localStorage.setItem('menuWidth', '0');
}
});
onUnmounted(() => {
});
</script>
@@ -49,6 +64,12 @@ onMounted(() => {
position: relative;
z-index: 2;
}
.bottom-menu {
position: fixed;
bottom: 0;
width: 100%;
z-index: 2;
}
@media (max-width: 768px) {
.content {
@@ -56,3 +77,19 @@ onMounted(() => {
}
}
</style>
<style>
@media (max-width: 768px) {
.t-head-menu__inner .t-menu:first-child {
margin-left: 0;
}
.t-head-menu__inner{
width: 100%;
}
.t-head-menu .t-menu{
justify-content: space-evenly;
}
.t-menu__content{
display: none;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<t-card class="layout">
<t-card class="layout" :bordered="false">
<div class="login-container">
<h2 class="sotheby-font">QQ Login</h2>
<div class="login-methods">

View File

@@ -1,5 +1,5 @@
<template>
<t-card class="layout">
<t-card class="layout" :bordered="false">
<div class="login-container">
<h2 class="sotheby-font">WebUi Login</h2>
<t-form ref="form" :data="formData" colon :label-width="0" @submit="onSubmit">

View File

@@ -1,5 +1,5 @@
<template>
<t-menu theme="light" default-value="2-1" :collapsed="collapsed" class="sidebar-menu">
<t-menu theme="light" :width="menuWidth" :collapsed="collapsed" class="sidebar-menu">
<template #logo>
<div class="logo">
<img class="logo-img" :width="collapsed ? 35 : 'auto'" src="@/assets/logo_webui.png" alt="logo" />
@@ -43,10 +43,11 @@ type MenuItem = {
icon?: string;
disabled?: boolean;
};
defineProps<{
menuItems: MenuItem[];
menuWidth: string | number | Array<string | number>;
}>();
const mediaQuery = window.matchMedia('(max-width: 800px)');
const collapsed = ref<boolean>(localStorage.getItem('sidebar-collapsed') === 'true');
const iconName = ref<string>(collapsed.value ? 'menu-unfold' : 'menu-fold');
const disBtn = ref<boolean>(false);
@@ -57,12 +58,10 @@ const changeCollapsed = (): void => {
localStorage.setItem('sidebar-collapsed', collapsed.value.toString());
};
watch(collapsed, (newValue, oldValue) => {
setTimeout(() => {
emitter.emit('sendMenu', collapsed.value);
}, 300);
emitter.emit('sendMenu', collapsed.value);
});
onMounted(() => {
const mediaQuery = window.matchMedia('(max-width: 800px)');
emitter.emit('sendMenu', collapsed.value);
const handleMediaChange = (e: MediaQueryListEvent) => {
disBtn.value = e.matches;
if (e.matches) {

View File

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

View File

@@ -4,3 +4,10 @@
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'ProtoNerdFontItalic';
src: url('../assets/0xProtoNerdFont-Italic.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}

View File

@@ -40,6 +40,11 @@ import {
Aside as TAside,
Popconfirm as Tpopconfirm,
Empty as TEmpty,
Dropdown as TDropdown,
Typography as TTypographyText,
TreeSelect as TTreeSelect,
Loading as TLoading,
HeadMenu as THeadMenu
} from 'tdesign-vue-next';
import { router } from './router';
import 'tdesign-vue-next/es/style/index.css';
@@ -84,4 +89,9 @@ app.use(TFooter);
app.use(TAside);
app.use(Tpopconfirm);
app.use(TEmpty);
app.use(TDropdown);
app.use(TTypographyText);
app.use(TTreeSelect);
app.use(TLoading);
app.use(THeadMenu);
app.mount('#app');

View File

@@ -1,23 +1,97 @@
<template>
<div class="about-us">
<div>
<t-divider content="面板关于信息" align="left" />
<t-alert theme="success" message="NapCat.WebUi is running" />
<t-list class="list">
<t-list-item class="list-item">
<span class="item-label">开发人员:</span>
<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 href="mailto:nanaeonn@outlook.com">Mlikiowa</t-link>
<t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/stargazers">{{
githubBastData?.stargazers_count
}}</t-link>
</span>
</t-list-item>
<t-list-item class="list-item">
<span class="item-label">版本信息:</span>
<t-list-item>
<tips-filled-icon class="item-icon" size="large" />
<span class="item-label">issues:</span>
<span class="item-content">
<t-tag class="tag-item" theme="success"> WebUi: {{ pkg.version }} </t-tag>
<t-tag class="tag-item" theme="success"> NapCat: {{ napCatVersion }} </t-tag>
<t-tag class="tag-item" theme="success">
TDesign: {{ pkg.dependencies['tdesign-vue-next'] }}
<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:
{{ githubReleasesData&&githubReleasesData[0] ?.tag_name ? githubReleasesData[0].tag_name : napCatVersion }}
</t-tag>
<t-tag class="tag-item td-color"> TDesign: {{ pkg.dependencies['tdesign-vue-next'] }} </t-tag>
</span>
</t-list-item>
</t-list>
@@ -28,6 +102,51 @@
<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>
@@ -35,23 +154,26 @@ import { napCatVersion } from '../../../src/common/version';
padding: 20px;
text-align: left;
}
.list {
.label-box {
display: flex;
flex-direction: column;
}
.list-item {
display: flex;
justify-content: space-between;
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;
font-weight: bold;
margin-left: 8px;
box-sizing: border-box;
height: auto;
padding: 0;
border: none;
font-size: 16px;
}
.item-content {
flex: 2;
display: flex;
@@ -64,3 +186,37 @@ import { napCatVersion } from '../../../src/common/version';
margin-bottom: 10px;
}
</style>
<style>
.t-list-item {
padding: 5px var(--td-comp-paddingLR-l);
}
.item-label {
flex: 2;
background-image: linear-gradient(to right, #fa709a 0%, #fee140 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.pgk-color {
color: white;
background-image: linear-gradient(-225deg, #9be15d 0%, #00e3ae 100%);
}
.nc-color {
color: white;
background-image: linear-gradient(-225deg, #2cd8d5 0%, #c5c1ff 56%, #ffbac3 100%);
}
.td-color {
color: white;
background-image: linear-gradient(225deg, #0acffe 0%, #495aff 100%);
}
.header {
background-image: linear-gradient(225deg, #dfffcd 0%, #90f9c4 48%, #39f3bb 100%) !important;
}
.link-text{
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(-225deg, #B6CEE8 0%, #F578DC 100%);
font-weight: bold;
}
</style>

View File

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

View File

@@ -1,10 +1,18 @@
<template>
<div ref="headerBox" class="title">
<t-divider content="网络配置" align="left" />
<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-button
>
</t-divider>
</div>
<div v-if="loadPage" ref="setting" class="setting">
@@ -16,86 +24,142 @@
<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">
<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 theme="danger" content="确认删除" @confirm="delConfig(item)">
<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"></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"></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
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 :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
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">
<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>
<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>
<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-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
size="small"
:layout="infoOneCol ? 'vertical' : 'horizontal'"
class="setting-base-info"
>
<t-descriptions-item v-if="item.hasOwnProperty('debug')" label="调试日志">
<t-tag class="tag-item" :theme="item.debug ? 'success' : 'danger'">
{{ item.debug ? '开启' : '关闭' }}</t-tag>
<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="tag-item" :theme="item.enableWebsocket ? 'success' : 'danger'">
{{ item.enableWebsocket ? '启用' : '禁用' }}</t-tag>
<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="tag-item" :theme="item.enableCors ? 'success' : 'danger'">
{{ item.enableCors ? '开启' : '关闭' }}</t-tag>
<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="tag-item" :theme="item.reportSelfMessage ? 'success' : 'danger'">
{{ item.reportSelfMessage ? '开启' : '关闭' }}</t-tag>
<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"
:theme="item.enableForcePushEvent ? 'success' : 'danger'">
{{ item.enableForcePushEvent ? '开启' : '关闭' }}</t-tag>
<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>
@@ -105,20 +169,34 @@
</div>
<div style="height: 20vh"></div>
</div>
<t-card v-else>
<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" placement="center" :on-confirm="saveConfig" class=".t-dialog__ctx .t-dialog--defaul">
<div slot="body" class="dialog-body" >
<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-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-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="httpClients">HTTP 客户端</t-option>
@@ -127,8 +205,10 @@
</t-select>
</t-form-item>
<div>
<component :is="resolveDynamicComponent(getComponent(newTab.type as ComponentKey))"
:config="newTab.data" />
<component
:is="resolveDynamicComponent(getComponent(newTab.type as ComponentKey))"
:config="newTab.data"
/>
</div>
</t-form>
</div>
@@ -136,8 +216,17 @@
</template>
<script setup lang="ts">
import { AddIcon, DeleteIcon, Edit2Icon, ServerFilledIcon, CopyIcon, BrowseOffIcon, BrowseIcon } from 'tdesign-icons-vue-next';
import { onMounted, onUnmounted, ref, resolveDynamicComponent } from 'vue';
import {
AddIcon,
DeleteIcon,
Edit2Icon,
ServerFilledIcon,
CopyIcon,
BrowseOffIcon,
BrowseIcon,
Wifi1Icon,
} from 'tdesign-icons-vue-next';
import { onMounted, onUnmounted, ref, resolveDynamicComponent, watch } from 'vue';
import emitter from '@/ts/event-bus';
import {
mergeNetworkDefaultConfig,
@@ -187,7 +276,7 @@ const operateType = ref<string>('');
//配置项索引
const configIndex = ref<number>(0);
//保存时所用数据
const networkConfig: NetworkConfig & { [key: string]: any; } = {
const networkConfig: NetworkConfig & { [key: string]: any } = {
websocketClients: [],
websocketServers: [],
httpClients: [],
@@ -235,6 +324,18 @@ const editConfig = (item: any) => {
configIndex.value = networkConfig[newTab.value.type].findIndex((obj: any) => obj.name === item.name);
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) {
@@ -252,7 +353,6 @@ const selectType = (key: ComponentKey) => {
};
const onloadDefault = (key: ComponentKey) => {
console.log(key);
newTab.value.data = structuredClone(mergeNetworkDefaultConfig[key]);
};
//检测重名
@@ -350,22 +450,21 @@ const loadConfig = async () => {
};
const copyText = async (text: string) => {
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
await navigator.clipboard.writeText(text);
document.body.removeChild(input);
MessagePlugin.success('复制成功');
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 = () => {
// 得根据卡片宽度改,懒得改了;先不管了
// if(window.innerWidth < 540) {
// infoOneCol.value= true
// } else {
// infoOneCol.value= false
// }
tabsWidth.value = window.innerWidth - 41 - menuWidth.value;
if (mediumScreen.matches) {
cardWidth.value = (tabsWidth.value - 20) / 2;
@@ -375,29 +474,43 @@ const handleResize = () => {
cardWidth.value = tabsWidth.value;
}
loadPage.value = true;
setTimeout(() => {
setTimeout(()=>{
cardHeight.value = window.innerHeight - (headerBox.value?.offsetHeight ?? 0) - (setting.value?.offsetHeight ?? 0) - 21;
}, 300);
},300)
};
emitter.on('sendWidth', (width) => {
if (typeof width === 'number' && !isNaN(width)) {
menuWidth.value = width;
handleResize();
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(() => {
setTimeout(()=>{
handleResize();
}, 300);
},300)
}
window.addEventListener('resize', handleResize);
window.addEventListener('resize', ()=>{
setTimeout(()=>{
handleResize();
},300)
});
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', ()=>{
setTimeout(()=>{
handleResize();
},300)
});
});
</script>
@@ -437,7 +550,7 @@ onUnmounted(() => {
display: flex;
margin-top: 2px;
}
.local-icon{
.local-icon {
flex: 1;
}
.local {
@@ -448,14 +561,12 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
.copy-icon {
flex: 1;
cursor: pointer;
flex-direction: row;
}
.token-view {
display: flex;
align-items: center;
@@ -467,11 +578,22 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
}
.browse-icon{
.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--defaul) {
margin: 0 20px;
:global(.t-dialog__ctx .t-dialog__position) {
padding: 48px 10px;
}
@media (max-width: 1024px) {
.setting-box {
@@ -483,7 +605,6 @@ onUnmounted(() => {
.setting-box {
grid-template-columns: 1fr;
}
}
.card-box {
@@ -494,9 +615,8 @@ onUnmounted(() => {
line-height: 400px !important;
}
.dialog-body {
max-height: 60vh;
max-height: 50vh;
overflow-y: auto;
}
@@ -515,12 +635,6 @@ onUnmounted(() => {
font-size: 12px;
}
.card-address .t-card__body {
display: flex;
flex-direction: row;
align-items: center;
}
.setting-base-info .t-descriptions__header {
font-size: 15px;
margin-bottom: 0;
@@ -530,7 +644,7 @@ onUnmounted(() => {
padding: 0 var(--td-comp-paddingLR-l) !important;
}
.setting-base-info tr>td:last-child {
.setting-base-info tr > td:last-child {
text-align: right;
}

View File

@@ -1,6 +1,13 @@
<template>
<div class="title">
<t-divider content="其余配置" align="left" />
<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">
@@ -29,11 +36,12 @@ 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
parseMultMsg: true,
});
const labelAlign = ref<string>();

View File

@@ -2,7 +2,7 @@
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-checkbox v-model="config.enable" />
<t-switch v-model="config.enable" />
</t-form-item>
<t-form-item label="URL">
<t-input v-model="config.url" />
@@ -11,13 +11,13 @@
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="报告自身消息">
<t-checkbox v-model="config.reportSelfMessage" />
<t-switch v-model="config.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="config.token" />
</t-form-item>
<t-form-item label="调试模式">
<t-checkbox v-model="config.debug" />
<t-switch v-model="config.debug" />
</t-form-item>
</t-form>
</div>

View File

@@ -2,7 +2,7 @@
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-checkbox v-model="config.enable" />
<t-switch v-model="config.enable" />
</t-form-item>
<t-form-item label="端口">
<t-input v-model.number="config.port" type="number" />
@@ -11,10 +11,10 @@
<t-input v-model="config.host" type="text" />
</t-form-item>
<t-form-item label="启用 CORS">
<t-checkbox v-model="config.enableCors" />
<t-switch v-model="config.enableCors" />
</t-form-item>
<t-form-item label="启用 WS">
<t-checkbox v-model="config.enableWebsocket" />
<t-switch v-model="config.enableWebsocket" />
</t-form-item>
<t-form-item label="消息格式">
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
@@ -23,7 +23,7 @@
<t-input v-model="config.token" type="text" />
</t-form-item>
<t-form-item label="调试模式">
<t-checkbox v-model="config.debug" />
<t-switch v-model="config.debug" />
</t-form-item>
</t-form>
</div>

View File

@@ -2,7 +2,7 @@
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-checkbox v-model="config.enable" />
<t-switch v-model="config.enable" />
</t-form-item>
<t-form-item label="URL">
<t-input v-model="config.url" />
@@ -11,13 +11,13 @@
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="报告自身消息">
<t-checkbox v-model="config.reportSelfMessage" />
<t-switch v-model="config.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="config.token" />
</t-form-item>
<t-form-item label="调试模式">
<t-checkbox v-model="config.debug" />
<t-switch v-model="config.debug" />
</t-form-item>
<t-form-item label="心跳间隔">
<t-input v-model.number="config.heartInterval" type="number" />

View File

@@ -2,7 +2,7 @@
<div>
<t-form labelAlign="left">
<t-form-item label="启用">
<t-checkbox v-model="config.enable" />
<t-switch v-model="config.enable" />
</t-form-item>
<t-form-item label="主机">
<t-input v-model="config.host" />
@@ -14,16 +14,16 @@
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
</t-form-item>
<t-form-item label="上报自身消息">
<t-checkbox v-model="config.reportSelfMessage" />
<t-switch v-model="config.reportSelfMessage" />
</t-form-item>
<t-form-item label="Token">
<t-input v-model="config.token" />
</t-form-item>
<t-form-item label="强制推送事件">
<t-checkbox v-model="config.enableForcePushEvent" />
<t-switch v-model="config.enableForcePushEvent" />
</t-form-item>
<t-form-item label="调试模式">
<t-checkbox v-model="config.debug" />
<t-switch v-model="config.debug" />
</t-form-item>
<t-form-item label="心跳间隔">
<t-input v-model.number="config.heartInterval" type="number" />

View File

@@ -13,7 +13,8 @@
"dev:shell": "vite build --mode shell",
"dev:webui": "cd napcat.webui && npm run webui:dev",
"lint": "eslint --fix src/**/*.{js,ts,vue}",
"depend": "cd dist && npm install --omit=dev"
"depend": "cd dist && npm install --omit=dev",
"dev:depend": "npm i && cd napcat.webui && npm i"
},
"devDependencies": {
"@babel/preset-typescript": "^7.24.7",
@@ -25,6 +26,7 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@types/cors": "^2.8.17",
"@sinclair/typebox": "^0.34.9",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^22.0.1",
@@ -48,8 +50,7 @@
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
"winston": "^3.17.0",
"@sinclair/typebox": "^0.34.9"
"winston": "^3.17.0"
},
"dependencies": {
"express": "^5.0.0",

View File

@@ -18,13 +18,16 @@ export const LogListHandler: RequestHandler = async (_, res) => {
const logList = await WebUiConfigWrapper.GetLogsList();
return sendSuccess(res, logList);
};
// 实时日志SSE
export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(log + '\n');
res.write(`data: ${log}\n\n`);
} catch (error) {
// ignore
console.error('向客户端写入日志数据时出错:', error);
}
};
logSubscription.subscribe(listener);

View File

@@ -1,9 +1,16 @@
import type { RequestHandler } from 'express';
// CORS 中间件,跨域用
export const cors: RequestHandler = (_, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
export const cors: RequestHandler = (req, res, next) => {
const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
};