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

View File

@@ -109,4 +109,4 @@ onUnmounted(() => {
window.removeEventListener('resize', haddingFbars); window.removeEventListener('resize', haddingFbars);
}); });
</script> </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> <template>
<t-layout class="dashboard-container"> <t-layout class="dashboard-container">
<div ref="menuRef"> <div v-if="!mediaQuery.matches">
<SidebarMenu :menu-items="menuItems" class="sidebar-menu" /> <SidebarMenu
:menu-items="menuItems"
class="sidebar-menu"
:menu-width="sidebarWidth"
/>
</div> </div>
<t-layout> <t-layout>
<router-view /> <router-view />
</t-layout> </t-layout>
<div v-if="mediaQuery.matches" class="bottom-menu">
<BottomMenu :menu-items="menuItems" />
</div>
</t-layout> </t-layout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import SidebarMenu from './webui/Nav.vue'; import SidebarMenu from './webui/Nav.vue';
import BottomMenu from './webui/NavBottom.vue';
import emitter from '@/ts/event-bus'; import emitter from '@/ts/event-bus';
const mediaQuery = window.matchMedia('(max-width: 768px)');
const sidebarWidth = ['232px', '64px'];
interface MenuItem { interface MenuItem {
value: string; value: string;
icon: string; icon: string;
@@ -27,13 +37,18 @@ const menuItems = ref<MenuItem[]>([
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' }, { value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' }, { value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
]); ]);
const menuRef = ref<HTMLDivElement | null>(null);
emitter.on('sendMenu', (event) => { emitter.on('sendMenu', (event) => {
emitter.emit('sendWidth', menuRef.value?.offsetWidth); const menuWidth = event ? sidebarWidth[1] : sidebarWidth[0];
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0'); emitter.emit('sendWidth', menuWidth);
localStorage.setItem('menuWidth', menuWidth.toString() || '0');
}); });
onMounted(() => { onMounted(() => {
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0'); if (mediaQuery.matches){
localStorage.setItem('menuWidth', '0');
}
});
onUnmounted(() => {
}); });
</script> </script>
@@ -49,6 +64,12 @@ onMounted(() => {
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.bottom-menu {
position: fixed;
bottom: 0;
width: 100%;
z-index: 2;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.content { .content {
@@ -56,3 +77,19 @@ onMounted(() => {
} }
} }
</style> </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> <template>
<t-card class="layout"> <t-card class="layout" :bordered="false">
<div class="login-container"> <div class="login-container">
<h2 class="sotheby-font">QQ Login</h2> <h2 class="sotheby-font">QQ Login</h2>
<div class="login-methods"> <div class="login-methods">

View File

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

View File

@@ -1,5 +1,5 @@
<template> <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> <template #logo>
<div class="logo"> <div class="logo">
<img class="logo-img" :width="collapsed ? 35 : 'auto'" src="@/assets/logo_webui.png" alt="logo" /> <img class="logo-img" :width="collapsed ? 35 : 'auto'" src="@/assets/logo_webui.png" alt="logo" />
@@ -43,10 +43,11 @@ type MenuItem = {
icon?: string; icon?: string;
disabled?: boolean; disabled?: boolean;
}; };
defineProps<{ defineProps<{
menuItems: MenuItem[]; 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 collapsed = ref<boolean>(localStorage.getItem('sidebar-collapsed') === 'true');
const iconName = ref<string>(collapsed.value ? 'menu-unfold' : 'menu-fold'); const iconName = ref<string>(collapsed.value ? 'menu-unfold' : 'menu-fold');
const disBtn = ref<boolean>(false); const disBtn = ref<boolean>(false);
@@ -57,12 +58,10 @@ const changeCollapsed = (): void => {
localStorage.setItem('sidebar-collapsed', collapsed.value.toString()); localStorage.setItem('sidebar-collapsed', collapsed.value.toString());
}; };
watch(collapsed, (newValue, oldValue) => { watch(collapsed, (newValue, oldValue) => {
setTimeout(() => {
emitter.emit('sendMenu', collapsed.value); emitter.emit('sendMenu', collapsed.value);
}, 300);
}); });
onMounted(() => { onMounted(() => {
const mediaQuery = window.matchMedia('(max-width: 800px)'); emitter.emit('sendMenu', collapsed.value);
const handleMediaChange = (e: MediaQueryListEvent) => { const handleMediaChange = (e: MediaQueryListEvent) => {
disBtn.value = e.matches; disBtn.value = e.matches;
if (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-weight: normal;
font-style: 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, Aside as TAside,
Popconfirm as Tpopconfirm, Popconfirm as Tpopconfirm,
Empty as TEmpty, Empty as TEmpty,
Dropdown as TDropdown,
Typography as TTypographyText,
TreeSelect as TTreeSelect,
Loading as TLoading,
HeadMenu as THeadMenu
} from 'tdesign-vue-next'; } from 'tdesign-vue-next';
import { router } from './router'; import { router } from './router';
import 'tdesign-vue-next/es/style/index.css'; import 'tdesign-vue-next/es/style/index.css';
@@ -84,4 +89,9 @@ app.use(TFooter);
app.use(TAside); app.use(TAside);
app.use(Tpopconfirm); app.use(Tpopconfirm);
app.use(TEmpty); app.use(TEmpty);
app.use(TDropdown);
app.use(TTypographyText);
app.use(TTreeSelect);
app.use(TLoading);
app.use(THeadMenu);
app.mount('#app'); app.mount('#app');

View File

@@ -1,23 +1,97 @@
<template> <template>
<div class="about-us"> <div class="about-us">
<div> <div>
<t-divider content="面板关于信息" align="left" /> <t-divider content="面板关于信息" align="left">
<t-alert theme="success" message="NapCat.WebUi is running" /> <template #content>
<t-list class="list"> <div style="display: flex; justify-content: center; align-items: center">
<t-list-item class="list-item"> <info-circle-icon></info-circle-icon>
<span class="item-label">开发人员:</span> <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"> <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> </span>
</t-list-item> </t-list-item>
<t-list-item class="list-item"> <t-list-item>
<span class="item-label">版本信息:</span> <tips-filled-icon class="item-icon" size="large" />
<span class="item-label">issues:</span>
<span class="item-content"> <span class="item-content">
<t-tag class="tag-item" theme="success"> WebUi: {{ pkg.version }} </t-tag> <t-link class="link-text" href="https://github.com/NapNeko/NapCatQQ/issues">{{
<t-tag class="tag-item" theme="success"> NapCat: {{ napCatVersion }} </t-tag> githubBastData?.open_issues_count
<t-tag class="tag-item" theme="success"> }}</t-link>
TDesign: {{ pkg.dependencies['tdesign-vue-next'] }} </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>
<t-tag class="tag-item td-color"> TDesign: {{ pkg.dependencies['tdesign-vue-next'] }} </t-tag>
</span> </span>
</t-list-item> </t-list-item>
</t-list> </t-list>
@@ -28,6 +102,51 @@
<script setup lang="ts"> <script setup lang="ts">
import pkg from '../../package.json'; import pkg from '../../package.json';
import { napCatVersion } from '../../../src/common/version'; 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> </script>
<style scoped> <style scoped>
@@ -35,23 +154,26 @@ import { napCatVersion } from '../../../src/common/version';
padding: 20px; padding: 20px;
text-align: left; text-align: left;
} }
.label-box {
.list {
display: flex; display: flex;
flex-direction: column; justify-content: center;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: 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 { .item-label {
flex: 1; flex: 1;
font-weight: bold; margin-left: 8px;
box-sizing: border-box;
height: auto;
padding: 0;
border: none;
font-size: 16px;
} }
.item-content { .item-content {
flex: 2; flex: 2;
display: flex; display: flex;
@@ -64,3 +186,37 @@ import { napCatVersion } from '../../../src/common/version';
margin-bottom: 10px; margin-bottom: 10px;
} }
</style> </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> <template>
<div class="log-view"> <div class="title">
<h1>面板日志信息</h1> <t-divider content="日志查看" align="left">
<p>这里显示面板的日志信息</p> <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> </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> </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> <template>
<div ref="headerBox" class="title"> <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-divider align="right">
<t-button @click="addConfig()"> <t-button @click="addConfig()">
<template #icon><add-icon /></template> <template #icon><add-icon /></template>
添加配置</t-button> 添加配置</t-button
>
</t-divider> </t-divider>
</div> </div>
<div v-if="loadPage" ref="setting" class="setting"> <div v-if="loadPage" ref="setting" class="setting">
@@ -16,86 +24,142 @@
<t-tab-panel value="websocketClients" label="WebSocket 客户端"></t-tab-panel> <t-tab-panel value="websocketClients" label="WebSocket 客户端"></t-tab-panel>
</t-tabs> </t-tabs>
</div> </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 v-if="loadPage" class="card-box" :style="{ width: tabsWidth + 'px' }">
<div class="setting-box" :style="{ maxHeight: cardHeight + 'px' }" v-if="cardConfig.length > 0"> <div class="setting-box" :style="{ maxHeight: cardHeight + 'px' }" v-if="cardConfig.length > 0">
<div v-for="(item, index) in cardConfig" :key="index"> <div v-for="(item, index) in cardConfig" :key="index">
<t-card :title="item.name" :description="item.type" :style="{ width: cardWidth + 'px' }" <t-card
:header-bordered="true" class="setting-card"> :title="item.name"
:description="item.type"
:style="{ width: cardWidth + 'px' }"
:header-bordered="true"
class="setting-card"
>
<template #actions> <template #actions>
<t-space> <t-space>
<edit2-icon size="20px" @click="editConfig(item)"></edit2-icon> <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> <delete-icon size="20px"></delete-icon>
</t-popconfirm> </t-popconfirm>
</t-space> </t-space>
</template> </template>
<div class="setting-content"> <div class="setting-content">
<t-card class="card-address" :style="{ <t-card
borderLeft: '7px solid ' + (item.enable ? class="card-address"
'var(--td-success-color)' : :style="{
'var(--td-error-color)') 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> >
<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> <strong class="local">{{ item.host }}:{{ item.port }}</strong>
<copy-icon class="copy-icon" size="20px" @click="copyText(item.host + ':' + item.port)"></copy-icon> <copy-icon
class="copy-icon"
size="20px"
@click="copyText(item.host + ':' + item.port)"
></copy-icon>
</div> </div>
<div class="local-box" v-if="item.url"> <div class="local-box" v-if="item.url">
<server-filled-icon class="local-icon" size="20px"></server-filled-icon> <server-filled-icon class="local-icon" size="20px" @click="toggleProperty(item, 'enable')"></server-filled-icon>
<strong class="local" >{{ item.url }}</strong> <strong class="local">{{ item.url }}</strong>
<copy-icon class="copy-icon" size="20px" @click="copyText(item.url)"></copy-icon> <copy-icon class="copy-icon" size="20px" @click="copyText(item.url)"></copy-icon>
</div> </div>
</t-card> </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-collapse-panel header="基础信息">
<t-descriptions size="small" :layout="infoOneCol ? 'vertical' : 'horizontal'" <t-descriptions
class="setting-base-info"> size="small"
:layout="infoOneCol ? 'vertical' : 'horizontal'"
class="setting-base-info"
>
<t-descriptions-item v-if="item.token" label="连接密钥"> <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> <span>{{ showToken ? item.token : '******' }}</span>
<browse-icon class="browse-icon" v-if="showToken" size="18px" <browse-icon
@click="showToken = false"></browse-icon> class="browse-icon"
<browse-off-icon class="browse-icon" v-else size="18px" v-if="showToken"
@click="showToken = true"></browse-off-icon> 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>
<div v-else> <div v-else>
<t-popup :showArrow="true" trigger="click"> <t-popup :showArrow="true" trigger="click">
<t-tag theme="primary">点击查看</t-tag> <t-tag theme="primary">点击查看</t-tag>
<template #content> <template #content>
<div @click="copyText(item.token)">{{item.token}}</div> <div @click="copyText(item.token)">{{ item.token }}</div>
</template> </template>
</t-popup> </t-popup>
</div> </div>
</t-descriptions-item> </t-descriptions-item>
<t-descriptions-item label="消息格式">{{ item.messagePostFormat }}</t-descriptions-item> <t-descriptions-item label="消息格式">{{
item.messagePostFormat
}}</t-descriptions-item>
</t-descriptions> </t-descriptions>
</t-collapse-panel> </t-collapse-panel>
<t-collapse-panel header="状态信息"> <t-collapse-panel header="状态信息">
<t-descriptions size="small" :layout="infoOneCol ? 'vertical' : 'horizontal'" <t-descriptions
class="setting-base-info"> size="small"
:layout="infoOneCol ? 'vertical' : 'horizontal'"
class="setting-base-info"
>
<t-descriptions-item v-if="item.hasOwnProperty('debug')" label="调试日志"> <t-descriptions-item v-if="item.hasOwnProperty('debug')" label="调试日志">
<t-tag class="tag-item" :theme="item.debug ? 'success' : 'danger'"> <t-tag
{{ item.debug ? '开启' : '关闭' }}</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>
<t-descriptions-item v-if="item.hasOwnProperty('enableWebsocket')" <t-descriptions-item
label="Websocket 功能"> v-if="item.hasOwnProperty('enableWebsocket')"
<t-tag class="tag-item" :theme="item.enableWebsocket ? 'success' : 'danger'"> label="Websocket 功能"
{{ item.enableWebsocket ? '启用' : '禁用' }}</t-tag> >
<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>
<t-descriptions-item v-if="item.hasOwnProperty('enableCors')" label="跨域放行"> <t-descriptions-item
<t-tag class="tag-item" :theme="item.enableCors ? 'success' : 'danger'"> v-if="item.hasOwnProperty('enableCors')"
{{ item.enableCors ? '开启' : '关闭' }}</t-tag> 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>
<t-descriptions-item v-if="item.hasOwnProperty('enableForcePushEvent')" <t-descriptions-item
label="上报自身消息"> v-if="item.hasOwnProperty('enableForcePushEvent')"
<t-tag class="tag-item" :theme="item.reportSelfMessage ? 'success' : 'danger'"> label="上报自身消息"
{{ item.reportSelfMessage ? '开启' : '关闭' }}</t-tag> >
<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>
<t-descriptions-item v-if="item.hasOwnProperty('enableForcePushEvent')" <t-descriptions-item
label="强制推送事件"> v-if="item.hasOwnProperty('enableForcePushEvent')"
<t-tag class="tag-item" label="强制推送事件"
:theme="item.enableForcePushEvent ? 'success' : 'danger'"> >
{{ item.enableForcePushEvent ? '开启' : '关闭' }}</t-tag> <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-item>
</t-descriptions> </t-descriptions>
</t-collapse-panel> </t-collapse-panel>
@@ -109,16 +173,30 @@
<t-empty class="card-none" title="暂无网络配置"> </t-empty> <t-empty class="card-none" title="暂无网络配置"> </t-empty>
</t-card> </t-card>
</div> </div>
<t-dialog v-model:visible="visibleBody" :header="dialogTitle" :destroy-on-close="true" <t-dialog
:show-in-attached-element="true" placement="center" :on-confirm="saveConfig" class=".t-dialog__ctx .t-dialog--defaul"> v-model:visible="visibleBody"
<div slot="body" class="dialog-body" > :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 ref="form" :data="newTab" labelAlign="left" :model="newTab">
<t-form-item style="text-align: left" :rules="[{ required: true, message: '请输入名称', trigger: 'blur' }]" <t-form-item
label="名称" name="name"> style="text-align: left"
:rules="[{ required: true, message: '请输入名称', trigger: 'blur' }]"
label="名称"
name="name"
>
<t-input v-model="newTab.name" /> <t-input v-model="newTab.name" />
</t-form-item> </t-form-item>
<t-form-item style="text-align: left" :rules="[{ required: true, message: '请选择类型', trigger: 'change' }]" <t-form-item
label="类型" name="type"> style="text-align: left"
:rules="[{ required: true, message: '请选择类型', trigger: 'change' }]"
label="类型"
name="type"
>
<t-select v-model="newTab.type" @change="onloadDefault"> <t-select v-model="newTab.type" @change="onloadDefault">
<t-option value="httpServers">HTTP 服务器</t-option> <t-option value="httpServers">HTTP 服务器</t-option>
<t-option value="httpClients">HTTP 客户端</t-option> <t-option value="httpClients">HTTP 客户端</t-option>
@@ -127,8 +205,10 @@
</t-select> </t-select>
</t-form-item> </t-form-item>
<div> <div>
<component :is="resolveDynamicComponent(getComponent(newTab.type as ComponentKey))" <component
:config="newTab.data" /> :is="resolveDynamicComponent(getComponent(newTab.type as ComponentKey))"
:config="newTab.data"
/>
</div> </div>
</t-form> </t-form>
</div> </div>
@@ -136,8 +216,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AddIcon, DeleteIcon, Edit2Icon, ServerFilledIcon, CopyIcon, BrowseOffIcon, BrowseIcon } from 'tdesign-icons-vue-next'; import {
import { onMounted, onUnmounted, ref, resolveDynamicComponent } from 'vue'; 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 emitter from '@/ts/event-bus';
import { import {
mergeNetworkDefaultConfig, mergeNetworkDefaultConfig,
@@ -187,7 +276,7 @@ const operateType = ref<string>('');
//配置项索引 //配置项索引
const configIndex = ref<number>(0); const configIndex = ref<number>(0);
//保存时所用数据 //保存时所用数据
const networkConfig: NetworkConfig & { [key: string]: any; } = { const networkConfig: NetworkConfig & { [key: string]: any } = {
websocketClients: [], websocketClients: [],
websocketServers: [], websocketServers: [],
httpClients: [], httpClients: [],
@@ -235,6 +324,18 @@ const editConfig = (item: any) => {
configIndex.value = networkConfig[newTab.value.type].findIndex((obj: any) => obj.name === item.name); configIndex.value = networkConfig[newTab.value.type].findIndex((obj: any) => obj.name === item.name);
visibleBody.value = true; 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 delConfig = (item: any) => {
const type = getKeyByValue(typeCh, item.type); const type = getKeyByValue(typeCh, item.type);
if (type) { if (type) {
@@ -252,7 +353,6 @@ const selectType = (key: ComponentKey) => {
}; };
const onloadDefault = (key: ComponentKey) => { const onloadDefault = (key: ComponentKey) => {
console.log(key);
newTab.value.data = structuredClone(mergeNetworkDefaultConfig[key]); newTab.value.data = structuredClone(mergeNetworkDefaultConfig[key]);
}; };
//检测重名 //检测重名
@@ -350,22 +450,21 @@ const loadConfig = async () => {
}; };
const copyText = async (text: string) => { const copyText = async (text: string) => {
const input = document.createElement('input'); const textarea = document.createElement('textarea');
input.value = text; textarea.value = text;
document.body.appendChild(input); document.body.appendChild(textarea);
input.select(); textarea.select();
await navigator.clipboard.writeText(text); try {
document.body.removeChild(input); document.execCommand('copy');
MessagePlugin.success('复制成功'); MessagePlugin.success('复制成功');
} catch (err) {
console.error('复制失败', err);
} finally {
document.body.removeChild(textarea);
}
}; };
const handleResize = () => { const handleResize = () => {
// 得根据卡片宽度改,懒得改了;先不管了
// if(window.innerWidth < 540) {
// infoOneCol.value= true
// } else {
// infoOneCol.value= false
// }
tabsWidth.value = window.innerWidth - 41 - menuWidth.value; tabsWidth.value = window.innerWidth - 41 - menuWidth.value;
if (mediumScreen.matches) { if (mediumScreen.matches) {
cardWidth.value = (tabsWidth.value - 20) / 2; cardWidth.value = (tabsWidth.value - 20) / 2;
@@ -375,29 +474,43 @@ const handleResize = () => {
cardWidth.value = tabsWidth.value; cardWidth.value = tabsWidth.value;
} }
loadPage.value = true; loadPage.value = true;
setTimeout(() => { setTimeout(()=>{
cardHeight.value = window.innerHeight - (headerBox.value?.offsetHeight ?? 0) - (setting.value?.offsetHeight ?? 0) - 21; cardHeight.value = window.innerHeight - (headerBox.value?.offsetHeight ?? 0) - (setting.value?.offsetHeight ?? 0) - 21;
}, 300); },300)
}; };
emitter.on('sendWidth', (width) => { emitter.on('sendWidth', (width) => {
if (typeof width === 'number' && !isNaN(width)) { if (typeof width === 'string') {
menuWidth.value = width; const strWidth = width as string;
handleResize(); menuWidth.value = parseInt(strWidth);
} }
}); });
watch(menuWidth, (newValue, oldValue) => {
loadPage.value = false;
setTimeout(()=>{
handleResize();
},300)
});
onMounted(() => { onMounted(() => {
loadConfig(); loadConfig();
const cachedWidth = localStorage.getItem('menuWidth'); const cachedWidth = localStorage.getItem('menuWidth');
if (cachedWidth) { if (cachedWidth) {
menuWidth.value = parseInt(cachedWidth); menuWidth.value = parseInt(cachedWidth);
setTimeout(() => { setTimeout(()=>{
handleResize(); handleResize();
}, 300); },300)
} }
window.addEventListener('resize', handleResize); window.addEventListener('resize', ()=>{
setTimeout(()=>{
handleResize();
},300)
});
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', ()=>{
setTimeout(()=>{
handleResize();
},300)
});
}); });
</script> </script>
@@ -437,7 +550,7 @@ onUnmounted(() => {
display: flex; display: flex;
margin-top: 2px; margin-top: 2px;
} }
.local-icon{ .local-icon {
flex: 1; flex: 1;
} }
.local { .local {
@@ -448,14 +561,12 @@ onUnmounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.copy-icon { .copy-icon {
flex: 1; flex: 1;
cursor: pointer; cursor: pointer;
flex-direction: row; flex-direction: row;
} }
.token-view { .token-view {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -467,11 +578,22 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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; flex: 2;
} }
:global(.t-dialog__ctx .t-dialog--defaul) { :global(.t-dialog__ctx .t-dialog__position) {
margin: 0 20px; padding: 48px 10px;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.setting-box { .setting-box {
@@ -483,7 +605,6 @@ onUnmounted(() => {
.setting-box { .setting-box {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.card-box { .card-box {
@@ -494,9 +615,8 @@ onUnmounted(() => {
line-height: 400px !important; line-height: 400px !important;
} }
.dialog-body { .dialog-body {
max-height: 60vh; max-height: 50vh;
overflow-y: auto; overflow-y: auto;
} }
@@ -515,12 +635,6 @@ onUnmounted(() => {
font-size: 12px; font-size: 12px;
} }
.card-address .t-card__body {
display: flex;
flex-direction: row;
align-items: center;
}
.setting-base-info .t-descriptions__header { .setting-base-info .t-descriptions__header {
font-size: 15px; font-size: 15px;
margin-bottom: 0; margin-bottom: 0;
@@ -530,7 +644,7 @@ onUnmounted(() => {
padding: 0 var(--td-comp-paddingLR-l) !important; 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; text-align: right;
} }

View File

@@ -1,6 +1,13 @@
<template> <template>
<div class="title"> <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> </div>
<t-card class="card"> <t-card class="card">
<div class="other-config-container"> <div class="other-config-container">
@@ -29,11 +36,12 @@ import { ref, onMounted } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { OneBotConfig } from '../../../src/onebot/config/config'; import { OneBotConfig } from '../../../src/onebot/config/config';
import { QQLoginManager } from '@/backend/shell'; import { QQLoginManager } from '@/backend/shell';
import { SettingIcon } from 'tdesign-icons-vue-next';
const otherConfig = ref<Partial<OneBotConfig>>({ const otherConfig = ref<Partial<OneBotConfig>>({
musicSignUrl: '', musicSignUrl: '',
enableLocalFile2Url: false, enableLocalFile2Url: false,
parseMultMsg: true parseMultMsg: true,
}); });
const labelAlign = ref<string>(); const labelAlign = ref<string>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,16 @@
import type { RequestHandler } from 'express'; import type { RequestHandler } from 'express';
// CORS 中间件,跨域用 // CORS 中间件,跨域用
export const cors: RequestHandler = (_, res, next) => { export const cors: RequestHandler = (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); 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-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-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(); next();
}; };