Compare commits

...

8 Commits

Author SHA1 Message Date
hi2hi
5c347cc0eb 🚀 0.9.0 2025-12-10 10:48:21 +08:00
hi2hi
881c9a05e5 🛠️ 增强代码健壮性,添加错误处理和状态常量 2025-12-10 10:47:07 +08:00
hi2hi
586f1dd063 新增列表的排序功能 2025-12-09 17:27:19 +08:00
hi2hi
93f66cb42c 🪄 优化不同尺寸下的server-status处理;移动端环境下的server-status替代card模式采用尽可能的最小化处理 2025-12-09 01:38:03 +08:00
hi2hi
0b9da8fe01 🪄 重建组件路径 2025-12-09 00:40:51 +08:00
hi2hi
90884c2730 🪄 优化表格的显示 2025-12-09 00:08:40 +08:00
hi2hi
69ab11babc ServerStatus风格的表格模式 2025-12-08 23:57:28 +08:00
hi2hi
33f1625ab1 💥 重新优化监控表的计算;修复之前百分百成功的错误计算 2025-12-08 13:53:39 +08:00
37 changed files with 7503 additions and 3238 deletions

5187
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "nazhua",
"version": "0.8.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -8,15 +8,16 @@
"build:cdn": "cross-env VITE_SARASA_TERM_SC_USE_CDN=1 VITE_USE_CDN=1 vite build",
"build:nazhua": "cross-env VITE_BASE_PATH=/nazhua/ VITE_NEZHA_VERSION=v0 VITE_SARASA_TERM_SC_USE_CDN=1 VITE_USE_CDN=1 vite build",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"axios": "^1.7.7",
"axios": "^1.13.2",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"flag-icons": "^7.2.3",
"font-logos": "^1.3.0",
"remixicon": "^4.6.0",
"remixicon": "^4.7.0",
"uniqolor": "^1.1.1",
"vue": "^3.5.12",
"vue-echarts": "^7.0.3",
@ -24,19 +25,19 @@
"vuex": "^4.1.0"
},
"devDependencies": {
"@babel/core": "^7.24.9",
"@babel/core": "^7.28.5",
"@babel/eslint-parser": "^7.24.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-airbnb": "^7.0.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"eslint": "^8.34.0",
"eslint-plugin-vue": "^9.9.0",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.33.0",
"sass": "^1.81.0",
"vite": "^5.4.10",
"vite-plugin-babel": "^1.2.0",
"vite": "^6.4.1",
"vite-plugin-babel": "^1.3.2",
"vite-plugin-eslint": "^1.8.1",
"vite-svg-loader": "^5.1.0"
},

View File

@ -11,7 +11,8 @@ window.$$nazhuaConfig = {
// showLantern: true, // 是否显示灯笼
enableInnerSearch: true, // 启用内部搜索
// listServerItemTypeToggle: true, // 服务器列表项类型切换
// listServerItemType: 'row', // 服务器列表项类型 card/row row列表模式移动端自动切换至card
listServerItemType: 'card', // 服务器列表项类型 card/row/server-status row列表模式移动端自动切换至card
// serverStatusColumnsTpl: null, // 服务器状态列配置模板
// listServerStatusType: 'progress', // 服务器状态类型--列表
// listServerRealTimeShowLoad: true, // 列表显示服务器实时负载
// detailServerStatusType: 'progress', // 服务器状态类型--详情页
@ -30,6 +31,7 @@ window.$$nazhuaConfig = {
// hideListItemBill: false, // 隐藏列表项的账单信息
hideListItemLink: true, // 隐藏列表项的购买链接
// hideFilter: false, // 隐藏筛选
// hideSort: false, // 隐藏排序
// hideTag: false, // 隐藏标签
// hideDotBG: true, // 隐藏框框里面的点点背景
// monitorRefreshTime: 10, // 监控刷新时间间隔单位s, 0为不刷新为保证不频繁请求源站最低生效值为10s

View File

@ -15,6 +15,7 @@ import {
watch,
provide,
onMounted,
onUnmounted,
} from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
@ -24,6 +25,7 @@ import config, {
import sleep from '@/utils/sleep';
import LayoutMain from './layout/main.vue';
import { WS_CONNECTION_STATUS } from './ws/service';
import activeWebsocketService, {
wsService,
restart,
@ -39,9 +41,16 @@ provide('currentTime', currentTime);
/**
* 刷新当前时间
* 使用 requestAnimationFrame 持续更新时间但只在秒级变化时更新值以减少不必要的响应式更新
*/
let lastUpdateTime = 0;
function refreshTime() {
currentTime.value = Date.now();
const now = Date.now();
//
if (Math.floor(now / 1000) !== Math.floor(lastUpdateTime / 1000)) {
currentTime.value = now;
lastUpdateTime = now;
}
window.requestAnimationFrame(refreshTime);
}
refreshTime();
@ -108,17 +117,22 @@ onMounted(async () => {
console.log('ws connected');
store.dispatch('watchWsMsg');
});
window.addEventListener('focus', () => {
const handleFocus = () => {
// ws
// -1
if ([-1].includes(wsService.connected)) {
//
if (wsService.connected === WS_CONNECTION_STATUS.CLOSED) {
restart();
}
});
};
window.addEventListener('focus', handleFocus);
/**
* 激活websocket服务
*/
activeWebsocketService();
onUnmounted(() => {
window.removeEventListener('focus', handleFocus);
});
});
window.addEventListener('unhandledrejection', (event) => {

View File

@ -28,6 +28,10 @@
--conn-udp-color: #2ca9e1;
--load-color: #90f2ff;
--process-color: #f5b199;
--cpu-text-color: #89c3eb;
--mem-text-color: #2ca9e1;
--disk-text-color: #90f2ff;
--swap-text-color: #f5b199;
--list-item-price-color: #eee;
--list-item-buy-link-color: #ffc300;

View File

@ -33,6 +33,14 @@ if (config.nazhua.nezhaVersion) {
config.init = true;
}
function handle$$serverStatus() {
if (window.$$serverStatus) {
config.nazhua.listServerItemType = 'server-status';
config.nazhua.homeWorldMapPosition = 'bottom';
}
}
handle$$serverStatus();
function setColorMode() {
if (config.nazhua.simpleColorMode) {
document.body.classList.add('simple-color-mode');
@ -64,6 +72,7 @@ export function mergeNazhuaConfig(customConfig) {
});
replaceFavicon();
setColorMode();
handle$$serverStatus();
}
// 暴露合并配置方法
window.$mergeNazhuaConfig = mergeNazhuaConfig;

View File

@ -187,6 +187,10 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keydown', handleEscKey);
if (handleSearchTimer) {
clearTimeout(handleSearchTimer);
handleSearchTimer = null;
}
});
</script>

View File

@ -32,6 +32,7 @@
import {
ref,
computed,
onUnmounted,
} from 'vue';
import config from '@/config';
import Fireworks from '@/components/fireworks.vue';
@ -73,8 +74,14 @@ const enableInnerSearch = computed(() => {
return config.nazhua.enableInnerSearch;
});
window.addEventListener('resize', () => {
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
window.addEventListener('resize', handleResize);
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>

View File

@ -30,7 +30,7 @@ function useCdnCss(item) {
if (import.meta.env.VITE_USE_CDN) {
Object.entries({
remixicon: {
jsdelivr: 'https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css',
jsdelivr: 'https://cdn.jsdelivr.net/npm/remixicon@4.7.0/fonts/remixicon.css',
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.2.0/remixicon.css',
},
flagIcons: {

View File

@ -35,7 +35,13 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
if (!configStr) {
return null;
}
const remoteConfig = JSON.parse(unescaped(configStr));
let remoteConfig;
try {
remoteConfig = JSON.parse(unescaped(configStr));
} catch (error) {
console.error('Failed to parse nezha config:', error);
return null;
}
if (remoteConfig?.servers) {
remoteConfig.servers = remoteConfig.servers.map((i) => {
const item = {
@ -43,7 +49,8 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
};
try {
item.PublicNote = JSON.parse(i.PublicNote);
} catch {
} catch (error) {
console.warn('Failed to parse PublicNote for server:', i.ID || i.id, error);
item.PublicNote = {};
}
return item;
@ -51,7 +58,10 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
return remoteConfig;
}
return null;
}).catch(() => null);
}).catch((error) => {
console.error('Failed to load nezha config:', error);
return null;
});
/**
* 获取标签列表

View File

@ -21,7 +21,10 @@ export const loadServerGroup = async () => request({
});
}
return null;
}).catch(() => null);
}).catch((error) => {
console.error('Failed to load server group:', error);
return null;
});
/**
* 加载网站配置
@ -37,7 +40,10 @@ export const loadSetting = async () => request({
return res.data?.data || {};
}
return null;
}).catch(() => null);
}).catch((error) => {
console.error('Failed to load setting:', error);
return null;
});
/**
* 加载个人信息
@ -53,4 +59,7 @@ export const loadProfile = async (check) => request({
return res.data?.data || {};
}
return null;
}).catch(() => null);
}).catch((error) => {
console.error('Failed to load profile:', error);
return null;
});

View File

@ -122,10 +122,11 @@ export default function (v1Data) {
try {
v0Data.PublicNote = JSON.parse(v1Data.public_note);
} catch (e) {
v1Data.PublicNote = null;
console.warn('Failed to parse public_note for server:', v1Data.id, e);
v0Data.PublicNote = null;
}
} else {
v1Data.PublicNote = null;
v0Data.PublicNote = null;
}
return v0Data;
}

View File

@ -261,7 +261,9 @@ const monitorChartType = computed(() => {
//
const nowServerTime = computed(() => store.state.serverTime || Date.now());
const accpetShowTime = computed(() => (Math.floor(nowServerTime.value / 60000) - minute.value) * 60000);
// const nowServerTime = computed(() => Date.now());
// console.log(store.state.serverTime);
const acceptShowTime = computed(() => (Math.floor(nowServerTime.value / 60000) - minute.value) * 60000);
const minuteActiveArrowStyle = computed(() => {
const index = minutes.findIndex((i) => i.value === minute.value);
@ -281,109 +283,161 @@ const monitorChartData = computed(() => {
* - name {String}: 监控名称
* - data {Array}: [时间戳, 平均延迟] 对的数组
*/
const cateList = [];
const cateMap = {};
const dateSet = new Set();
let valueList = [];
monitorData.value.forEach((i) => {
const dateMap = {};
if (!cateMap[i.monitor_name]) {
cateMap[i.monitor_name] = {
id: i.monitor_id,
dateMap,
avgs: [],
const dateMap = new Map();
const {
monitor_name,
monitor_id,
created_at,
avg_delay,
} = i;
if (!cateMap[monitor_name]) {
cateMap[monitor_name] = {
id: monitor_id,
};
}
const showAvgDelay = [];
const showCreateTime = [];
const accpeTimeMap = {};
i.created_at.forEach((o, index) => {
const status = o >= accpetShowTime.value;
const cateDelayMap = new Map();
const cateAcceptTimeMap = new Map();
const cateCreateTime = new Set();
//
let earliestTimestamp = nowServerTime.value;
created_at.forEach((time, index) => {
if (time < earliestTimestamp) {
earliestTimestamp = time;
}
const status = time >= acceptShowTime.value;
// cateAcceptTime
if (status) {
accpeTimeMap[o] = i.avg_delay[index];
if (import.meta.env.VITE_MONITOR_DEBUG === '1' && cateAcceptTimeMap.has(time)) {
console.log(`${monitor_name} ${time} 重复,值对比: ${avg_delay[index]} vs ${cateAcceptTimeMap.get(time)}`);
}
cateAcceptTimeMap.set(time, avg_delay[index]);
}
});
const allMintues = Math.floor((Date.now() - accpetShowTime.value) / 60000);
for (let j = 0; j < allMintues; j += 1) {
const time = accpetShowTime.value + j * 60000;
showCreateTime.push(time);
const timeProp = accpeTimeMap[time];
if (timeProp) {
showAvgDelay.push(timeProp);
} else {
showAvgDelay.push(null);
}
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
console.log(`${monitor_name} created_at`, earliestTimestamp);
console.log(`${monitor_name} created_at`, JSON.parse(JSON.stringify(created_at)));
console.log(`${monitor_name} avg_delay`, JSON.parse(JSON.stringify(avg_delay)));
}
//
const actualStartTime = Math.max(
acceptShowTime.value,
earliestTimestamp,
);
//
const allMintues = Math.floor((Date.now() - actualStartTime) / 60000);
//
for (let j = 0; j < allMintues; j += 1) {
const time = actualStartTime + j * 60000;
//
cateCreateTime.add(time);
//
const timeProp = cateAcceptTimeMap.get(time);
cateDelayMap.set(time, timeProp ?? undefined);
}
//
const {
median,
tolerancePercent,
} = peakShaving.value ? getThreshold(showAvgDelay) : {};
showCreateTime.forEach((o, index) => {
if (Object.prototype.hasOwnProperty.call(dateMap, o)) {
return;
}
const avgDelay = showAvgDelay[index];
// 0
if (avgDelay === null || avgDelay === 0) {
dateMap[o] = undefined;
return;
}
} = peakShaving.value ? getThreshold(Array.from(cateDelayMap.values())) : {};
//
cateCreateTime.values().forEach((time) => {
const avgDelay = cateDelayMap.get(time) * 1;
//
if (peakShaving.value) {
//
const threshold = median * tolerancePercent;
//
if (Math.abs(avgDelay - median) > threshold) {
dateMap[o] = undefined;
dateMap.set(time, null);
return;
}
}
dateMap[o] = (avgDelay).toFixed(2) * 1;
// undefined
if (Number.isNaN(avgDelay)) {
dateMap.set(time, undefined);
} else {
dateMap.set(time, (avgDelay).toFixed(2) * 1);
}
});
});
let dateList = [];
let valueList = [];
const cateList = [];
Object.keys(cateMap).forEach((i) => {
const {
id,
dateMap,
avgs,
} = cateMap[i];
Object.entries(dateMap).forEach(([key, value]) => {
const time = parseInt(key, 10);
avgs.push([time, value]);
dateList.push(time);
const lineData = [];
const validatedData = [];
const overValidatedData = [];
let delayTotal = 0;
dateMap.forEach((val, key) => {
const time = parseInt(key, 10); //
lineData.push([time, val || null]);
if (val) {
dateSet.add(time);
validatedData.push([time, val]);
delayTotal += val;
}
if (val !== undefined) {
overValidatedData.push([time, val]);
}
});
const color = getLineColor(id);
if (avgs.length) {
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
cateMap[monitor_name].origin = {
cateCreateTime,
cateDelayMap,
cateAcceptTimeMap,
dateMap,
lineData,
validatedData,
overValidatedData,
delayTotal,
};
}
const id = monitor_id;
//
const avgDelay = delayTotal / validatedData.length || 0;
if (lineData && lineData.length) {
if (!validate.hasOwn(showCates.value, id)) {
showCates.value[id] = true;
}
//
// (undefined)
const realAvgs = avgs.filter((a) => a[1] !== undefined);
const validAvgs = realAvgs.filter((a) => a[1] !== 0 && a[1] !== null);
const avg = validAvgs.reduce((a, b) => a + b[1], 0) / validAvgs.length;
const over = validAvgs.length / realAvgs.length;
const color = getLineColor(id);
// = /
const over = overValidatedData.length > 0 ? overValidatedData.length / lineData.length : 0;
const validRate = 1 - ((validatedData.length > 0 && overValidatedData.length > 0)
? validatedData.length / overValidatedData.length : 0);
const cateItem = {
id,
name: i,
name: monitor_name,
color,
avg: avg.toFixed(2) * 1,
avg: avgDelay.toFixed(2) * 1,
over: (over * 100).toFixed(2) * 1,
validRate: (validRate * 100).toFixed(2) * 1,
};
if (Number.isNaN(cateItem.avg)) {
cateItem.avg = 0;
}
const titles = [
cateItem.name,
cateItem.avg === 0 ? '' : `平均延迟:${cateItem.avg}ms`,
`成功率:${cateItem.over}%`,
];
if (peakShaving.value) {
titles.push(`削峰率: ${cateItem.validRate}%`);
}
cateItem.title = titles.filter((s) => s).join('\n');
cateList.push(cateItem);
valueList.push({
id,
name: i,
data: avgs,
name: monitor_name,
data: lineData,
itemStyle: {
color,
},
@ -393,8 +447,15 @@ const monitorChartData = computed(() => {
});
}
});
dateList = dateList.sort((a, b) => a - b);
const dateList = Array.from(dateSet).sort((a, b) => a - b);
valueList = valueList.filter((i) => showCates.value[i.id]);
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
window._cateMap = cateMap;
console.log(window._cateMap);
console.log(dateList, cateList, valueList);
}
return {
dateList,
cateList,

View File

@ -162,12 +162,24 @@ const show = computed(() => {
align-items: center;
justify-content: space-between;
gap: 20px;
height: 40px;
border-bottom-left-radius: var(--list-item-border-radius);
border-bottom-right-radius: var(--list-item-border-radius);
background: rgba(#000, 0.3);
box-shadow: 0 -2px 4px rgba(#000, 0.5);
--list-item-bill-height: 40px;
--list-item-bill-font-size: 14px;
--list-item-bill-icon-font-size: 16px;
height: var(--list-item-bill-height);
font-size: var(--list-item-bill-font-size);
@media screen and (max-width: 720px) {
--list-item-bill-height: 30px;
--list-item-bill-font-size: 12px;
--list-item-bill-icon-font-size: 14px;
}
&.dot-dot-box--hide {
box-shadow: none;
border-top: 1px solid rgba(#ddd, 0.1);
@ -186,23 +198,27 @@ const show = computed(() => {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
width: calc(var(--list-item-bill-height) * 0.75);
height: calc(var(--list-item-bill-height) * 0.75);
line-height: 1;
font-size: 16px;
font-size: var(--list-item-bill-icon-font-size);
color: #74dbef;
}
.text {
display: flex;
align-items: center;
line-height: 30px;
line-height: var(--list-item-bill-height);
color: #ddd;
}
.value-text {
color: #74dbef;
}
@media screen and (max-width: 720px) {
padding-left: 6px;
}
}
.tag-list {

View File

@ -1,7 +1,7 @@
<template>
<div
class="server-list-item-status"
:class="'type--' + componentName"
:class="classNames"
>
<component
:is="componentMaps[componentName]"
@ -21,6 +21,10 @@
* 服务器状态盒子
*/
import {
computed,
} from 'vue';
import config from '@/config';
import handleServerStatus from '@/views/composable/server-status';
@ -39,10 +43,13 @@ const componentMaps = {
progress: ServerStatusProgress,
};
const componentName = [
'donut',
'progress',
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
const componentName = computed(() => {
const name = [
'donut',
'progress',
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
return config.nazhua.listServerItemType === 'server-status' ? 'progress' : name;
});
const {
serverStatusList,
@ -51,6 +58,13 @@ const {
statusListTpl: 'cpu,mem,disk',
statusListItemContent: false,
});
const classNames = computed(() => {
const names = {};
names[`type--${componentName.value}`] = true;
names[`len--${serverStatusList.value?.length}`] = true;
return names;
});
</script>
<style lang="scss" scoped>
@ -63,11 +77,16 @@ const {
flex-wrap: wrap;
gap: 10px;
--progress-bar-width: calc(50% - 5px);
--progress-bar-height: 20px;
@media screen and (max-width: 350px) {
@media screen and (max-width: 400px) {
--progress-bar-height: 16px;
padding: 0 15px;
padding: 0 10px;
}
&.len--3 {
--progress-bar-width: calc((100% - 20px) / 3);
}
}

View File

@ -90,7 +90,7 @@ const { cpuAndMemAndDisk } = handleServerInfo({
const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));
const serverRealTimeListTpls = computed(() => {
if (config.nazhua?.listServerRealTimeShowLoad) {
if (config.nazhua?.listServerRealTimeShowLoad || config.nazhua.listServerItemType === 'server-status') {
return 'D-A-T,T-A-U,L-A-P,I-A-O';
}
return 'duration,transfer,inSpeed,outSpeed';
@ -114,12 +114,12 @@ function openDetail() {
transition: 0.3s;
.server-info-group {
--list-item-head-height: 50px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 15px;
height: 50px;
border-top-left-radius: var(--list-item-border-radius);
border-top-right-radius: var(--list-item-border-radius);
background: rgba(#000, 0.3);
@ -128,6 +128,7 @@ function openDetail() {
@media screen and (max-width: 768px) {
cursor: default;
--list-item-head-height: 40px;
}
&.dot-dot-box--hide {
@ -138,6 +139,7 @@ function openDetail() {
&.server-list-item-head {
flex-wrap: wrap;
overflow: hidden;
height: var(--list-item-head-height, 50px);
}
.left-box,
@ -196,6 +198,8 @@ function openDetail() {
--real-time-text-font-size: 12px;
--real-time-label-font-size: 14px;
font-size: var(--real-time-label-font-size);
@media screen and (max-width: 1280px) {
padding: 10px 0 15px;
@ -210,8 +214,12 @@ function openDetail() {
--real-time-value-font-size: 20px;
}
@media screen and (max-width: 680px) {
@media screen and (max-width: 720px) {
--real-time-value-font-size: 24px;
--real-time-text-font-size: 12px;
--real-time-label-font-size: 12px;
padding: 5px 0;
}
@media screen and (max-width: 320px) {

View File

@ -7,6 +7,7 @@
:class="{
'server-list--row': showListRow,
'server-list--card': showListCard,
'server-list--status': showListByServerStatus,
}"
>
<slot />
@ -17,6 +18,7 @@
:class="{
'server-list--row': showListRow,
'server-list--card': showListCard,
'server-list--status': showListByServerStatus,
}"
>
<slot />
@ -41,6 +43,10 @@ defineProps({
type: Boolean,
default: false,
},
showListByServerStatus: {
type: Boolean,
default: false,
},
});
</script>
@ -94,6 +100,18 @@ defineProps({
margin: auto;
}
.server-list-container.server-list--status {
--list-padding: 20px;
--list-gap-size: 12px;
position: relative;
display: flex;
flex-direction: column;
gap: var(--list-gap-size);
width: var(--list-container-width);
padding: 0 var(--list-padding);
margin: auto;
}
.list-move,
.list-enter-active,
.list-leave-active {

View File

@ -49,7 +49,7 @@ const props = defineProps({
type: Array,
default: () => [],
},
accpetEmpty: {
acceptEmpty: {
type: Boolean,
default: true,
},
@ -74,7 +74,7 @@ const activeValue = computed({
function toggleModelValue(item) {
if (activeValue.value === item.value) {
if (props.accpetEmpty) {
if (props.acceptEmpty) {
activeValue.value = '';
}
} else {

View File

@ -0,0 +1,341 @@
<template>
<div
class="server-sort-box"
:class="{
'server-sort-box--light-background': lightBackground,
'server-sort-box--mobile-hide': !mobileShow,
}"
>
<div
ref="triggerRef"
class="sort-select-wrapper"
@click="toggleDropdown"
>
<div class="sort-select-selected">
<span class="sort-select-selected-value">{{ selectedLabel }}</span>
<span
class="sort-select-selected-icon"
@click.stop="toggleOrder"
>
<span
v-if="activeValue.order === 'desc'"
class="ri-arrow-down-line"
/>
<span
v-else
class="ri-arrow-up-line"
/>
</span>
</div>
</div>
<!-- 下拉菜单 -->
<Teleport to="body">
<server-sort-dropdown-menu
ref="dropdownMenuRef"
:visible="isDropdownOpen"
:options="options"
:active-value="activeValue.prop"
:dropdown-style="dropdownStyle"
:light-background="lightBackground"
:is-mobile="isMobile"
@select="handleSelectItem"
/>
</Teleport>
</div>
</template>
<script setup>
/**
* 过滤栏
*/
import {
computed,
ref,
onMounted,
onUnmounted,
nextTick,
} from 'vue';
import config from '@/config';
import ServerSortDropdownMenu from './server-sort-dropdown-menu.vue';
const props = defineProps({
modelValue: {
type: Object,
default: () => ({
prop: 'DisplayIndex',
order: 'desc',
}),
},
options: {
type: Array,
default: () => [],
},
acceptEmpty: {
type: Boolean,
default: true,
},
mobileShow: {
type: Boolean,
default: true,
},
});
const emits = defineEmits([
'update:modelValue',
'change',
]);
const lightBackground = computed(() => config.nazhua.lightBackground);
//
const isMobile = ref(window.innerWidth < 768);
// PC
const isDropdownOpen = ref(false);
const triggerRef = ref(null);
const dropdownMenuRef = ref(null);
const dropdownStyle = ref({});
const activeValue = computed({
get: () => props.modelValue,
set: (val) => {
emits('update:modelValue', val);
emits('change', val);
},
});
// label
const selectedLabel = computed(() => {
const selectedOption = props.options.find((opt) => opt.value === activeValue.value.prop);
return selectedOption ? selectedOption.label : '排序';
});
//
function updateDropdownPosition() {
if (!triggerRef.value || !dropdownMenuRef.value) return;
// 使 nextTick DOM
nextTick(() => {
const dropdownRef = dropdownMenuRef.value?.dropdownRef;
if (!dropdownRef) return;
//
if (isMobile.value) {
dropdownStyle.value = {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
visibility: 'visible',
};
return;
}
//
const triggerRect = triggerRef.value.getBoundingClientRect();
//
let top = triggerRect.bottom + 8;
let { left } = triggerRect;
//
dropdownStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
visibility: 'hidden', //
};
// 使 nextTick
nextTick(() => {
const dropdownRect = dropdownRef.getBoundingClientRect();
//
if (left + dropdownRect.width > window.innerWidth) {
left = window.innerWidth - dropdownRect.width - 10;
}
//
if (top + dropdownRect.height > window.innerHeight) {
top = triggerRect.top - dropdownRect.height - 8;
}
//
if (left < 10) {
left = 10;
}
//
dropdownStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
visibility: 'visible',
};
});
});
}
//
function toggleDropdown(event) {
event.stopPropagation(); // handleDocumentClick
isDropdownOpen.value = !isDropdownOpen.value;
if (isDropdownOpen.value) {
nextTick(() => {
updateDropdownPosition();
});
}
}
// /
function toggleOrder(event) {
event.stopPropagation(); //
if (!activeValue.value.prop) return; //
activeValue.value = {
prop: activeValue.value.prop,
order: activeValue.value.order === 'desc' ? 'asc' : 'desc',
};
emits('change', activeValue.value);
}
// PC
function handleSelectItem(item) {
if (activeValue.value.prop === item.value) {
if (props.acceptEmpty) {
activeValue.value = {
prop: '',
order: 'desc',
};
}
} else {
activeValue.value = {
prop: item.value,
order: activeValue.value.order || 'desc',
};
}
isDropdownOpen.value = false;
emits('change', activeValue.value);
}
//
function handleDocumentClick(event) {
if (!isDropdownOpen.value) return;
const dropdownRef = dropdownMenuRef.value?.dropdownRef;
if (
triggerRef.value
&& !triggerRef.value.contains(event.target)
&& dropdownRef
&& !dropdownRef.contains(event.target)
) {
isDropdownOpen.value = false;
}
}
// resize
function handleResize() {
isMobile.value = window.innerWidth < 768;
//
if (isDropdownOpen.value) {
nextTick(() => {
updateDropdownPosition();
});
}
}
onMounted(() => {
window.addEventListener('resize', handleResize);
document.addEventListener('click', handleDocumentClick);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
document.removeEventListener('click', handleDocumentClick);
});
</script>
<style lang="scss" scoped>
.server-sort-box {
display: flex;
flex-wrap: wrap;
padding: 0 var(--list-padding);
gap: 8px;
position: relative;
@media screen and (max-width: 768px) {
&--mobile-hide {
display: none;
}
}
// PC
.sort-select-wrapper {
position: relative;
@media screen and (min-width: 768px) {
cursor: pointer;
}
}
.sort-select-selected {
display: flex;
align-items: center;
height: 36px;
padding: 0 15px;
line-height: 1.2;
border-radius: 6px;
background: rgba(#000, 0.3);
transition: all 0.3s linear;
@media screen and (min-width: 768px) {
cursor: pointer;
}
@media screen and (max-width: 768px) {
height: 30px;
padding: 0 10px;
border-radius: 3px;
background-color: rgba(#000, 0.8);
}
.sort-select-selected-value {
color: #fff;
font-weight: bold;
}
.sort-select-selected-icon {
margin-left: 8px;
color: #fff;
display: flex;
align-items: center;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s linear;
@media screen and (min-width: 768px) {
cursor: pointer;
&:hover {
background: rgba(#fff, 0.1);
}
&:active {
background: rgba(#fff, 0.2);
}
}
}
}
// PC
&--light-background {
.sort-select-selected {
background: rgba(#000, 0.5);
}
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<div
v-show="visible"
ref="dropdownRef"
class="server-sort-select-dropdown"
:class="{
'server-sort-select-dropdown--light-background': lightBackground,
'server-sort-select-dropdown--mobile': isMobile,
}"
:style="dropdownStyle"
>
<div class="sort-select-options">
<div
v-for="item in options"
:key="item.value"
class="server-sort-item"
:class="{
active: activeValue === item.value,
}"
:title="item?.title || false"
@click.stop="handleSelect(item, $event)"
>
<span class="option-label">{{ item.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
defineProps({
visible: {
type: Boolean,
default: false,
},
options: {
type: Array,
default: () => [],
},
activeValue: {
type: String,
default: '',
},
dropdownStyle: {
type: Object,
default: () => ({}),
},
lightBackground: {
type: Boolean,
default: false,
},
isMobile: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['select']);
const dropdownRef = ref(null);
function handleSelect(item, event) {
event.stopPropagation();
emits('select', item);
}
defineExpose({
dropdownRef,
});
</script>
<style lang="scss" scoped>
.server-sort-select-dropdown {
z-index: 500;
background: rgba(#000, 0.8);
border-radius: 6px;
padding: 10px;
min-width: 150px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
//
&--mobile {
min-width: 280px;
max-width: 90vw;
max-height: 70vh;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
}
.sort-select-options {
display: flex;
flex-direction: column;
gap: 4px;
}
.server-sort-item {
display: flex;
align-items: center;
height: 36px;
padding: 0 15px;
line-height: 1.2;
border-radius: 6px;
background: rgba(#000, 0.3);
transition: all 0.3s linear;
cursor: pointer;
.option-label {
color: #fff;
font-weight: bold;
transition: all 0.3s linear;
}
&:hover {
.option-label {
color: var(--option-high-color);
}
}
&.active {
background: var(--option-high-color-active);
.option-label {
color: #fff;
}
}
}
//
.server-sort-select-dropdown--light-background {
.server-sort-item {
background: rgba(#000, 0.5);
&:hover {
background: rgba(#000, 0.8);
}
&.active {
background: var(--option-high-color-active);
}
}
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<dot-dot-box
v-if="tableData"
border-radius="6px"
class="server-status"
>
<table class="server-status-table">
<thead class="server-status-table-header">
<tr class="server-status-table-header-row">
<template
v-for="column in tableData.columnProps"
:key="`th_${column.prop}`"
>
<template v-if="['billing', 'remainingTime'].includes(column.prop)">
<server-status-th
v-if="tableData.showBilling && column.prop === 'billing'"
:column="column"
/>
<server-status-th
v-if="tableData.showRemainingTime && column.prop === 'remainingTime'"
:column="column"
/>
</template>
<template v-else>
<server-status-th
:column="column"
/>
</template>
</template>
</tr>
</thead>
<tbody class="server-status-table-body">
<tr
v-for="itemData in tableData.list"
:key="itemData.info.ID"
class="server-status-table-body-row"
:class="{
'server-status-table-body-row--offline': itemData.info?.online === -1,
'server-status-table-body-row--online': itemData.info?.online === 1,
[`server-item--${itemData.info?.ID}`]: true,
}"
@click="openDetail(itemData.info)"
>
<template
v-for="column in itemData.columnData"
:key="`td_${itemData.info?.ID}_${column.prop}`"
>
<template v-if="['billing', 'remainingTime'].includes(column.prop)">
<server-status-td
v-if="tableData.showBilling && column.prop === 'billing'"
:column="column"
/>
<server-status-td
v-if="tableData.showRemainingTime && column.prop === 'remainingTime'"
:column="column"
/>
</template>
<template v-else>
<server-status-td
:column="column"
/>
</template>
</template>
</tr>
</tbody>
</table>
</dot-dot-box>
</template>
<script setup>
/**
* ServerStatus风格的列表
*/
import {
computed,
} from 'vue';
import {
useRouter,
} from 'vue-router';
import config from '@/config';
import {
handleServerListColumn,
} from './server-status';
import ServerStatusTh from './table/th.vue';
import ServerStatusTd from './table/td.vue';
const props = defineProps({
serverList: {
type: Array,
default: () => [],
},
});
const router = useRouter();
const tableData = computed(() => {
const result = handleServerListColumn(props.serverList, config.nazhua.serverStatusColumnsTpl);
return result;
});
function openDetail(info) {
router.push({
name: 'ServerDetail',
params: {
serverId: info.ID,
},
});
}
</script>
<style lang="scss" scoped>
.server-status {
--server-status-cell-padding: 0 5px;
--server-status-td-height: 32px;
--progress-bar-height: 18px;
}
.server-status-table {
width: 100%;
border-collapse: collapse;
.server-status-table-body-row {
@media screen and (min-width: 1025px) {
cursor: pointer;
background-color: rgba(255, 255, 255, 0);
transition: background-color 500ms ease-in-out;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&--offline td:not(.server-status-td--status) {
filter: grayscale(1);
opacity: 0.75;
}
}
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<div class="conn-group">
<div class="conn--tcp">
{{ tcpConnCount }}
</div>
<div class="split-line">
|
</div>
<div class="conn--udp">
{{ udpConnCount }}
</div>
</div>
</template>
<script setup>
/**
* 连接信息
*/
import {
computed,
} from 'vue';
const props = defineProps({
realTimeData: {
type: Object,
default: () => ({}),
},
});
const tcpConnCount = computed(() => {
const { item } = props.realTimeData?.conns || {};
const { value } = item?.data?.tcp || {};
return value || '-';
});
const udpConnCount = computed(() => {
const { item } = props.realTimeData?.conns || {};
const { value } = item?.data?.udp || {};
return value || '-';
});
</script>
<style lang="scss" scoped>
.conn-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
width: 100%;
.conn--tcp {
flex: 1;
text-align: right;
color: var(--conn-tcp-color);
}
.conn--udp {
flex: 1;
text-align: left;
color: var(--conn-udp-color);
}
.split-line {
width: 6px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<div class="country-content">
<server-flag :info="info" />
<span class="country-label">
{{ countryLabel }}
</span>
</div>
</template>
<script setup>
/**
* 地区信息
*/
import {
computed,
} from 'vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const countryLabel = computed(() => props.info?.Host?.CountryCode?.toUpperCase() || 'UN');
</script>
<style lang="scss" scoped>
.country-content {
display: flex;
align-items: center;
gap: 5px;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="net-speed-group">
<div class="net-speed--in">
{{ inSpeed }}
</div>
<div class="split-line">
|
</div>
<div class="net-speed--out">
{{ outSpeed }}
</div>
</div>
</template>
<script setup>
/**
* 网速信息
*/
import {
computed,
} from 'vue';
const props = defineProps({
realTimeData: {
type: Object,
default: () => ({}),
},
});
const inSpeed = computed(() => {
const { item } = props.realTimeData?.speeds || {};
if (item?.data?.in) {
const { value, unit } = item.data.in;
return `${value}${unit}`;
}
return '-';
});
const outSpeed = computed(() => {
const { item } = props.realTimeData?.speeds || {};
if (item?.data?.out) {
const { value, unit } = item.data.out;
return `${value}${unit}`;
}
return '-';
});
</script>
<style lang="scss" scoped>
.net-speed-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
width: 100%;
.net-speed--in {
flex: 1;
text-align: right;
color: var(--net-speed-in-color);
}
.net-speed--out {
flex: 1;
text-align: left;
color: var(--net-speed-out-color);
}
.split-line {
width: 6px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<div class="status-icon-box">
<div
class="status-icon"
:class="{
online: info.online === 1,
offline: info.online === -1,
}"
/>
</div>
</template>
<script setup>
/**
* 状态图标
*/
defineProps({
info: {
type: Object,
default: () => ({}),
},
});
</script>
<style lang="scss" scoped>
.status-icon-box {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-icon.online {
background-image: linear-gradient(rgba(77, 133, 58, 1) 0, rgba(54, 126, 54, 1) 100%);
}
.status-icon.offline {
background-image: linear-gradient(rgba(155, 37, 34, 1) 0, rgba(161, 38, 35, 1) 100%);
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="system-os-content">
<span class="system-icon">
<span :class="platformLogoIconClassName" />
</span>
<span class="system-label">
{{ systemOSLabel }}
</span>
</div>
</template>
<script setup>
/**
* 系统信息
*/
import {
computed,
} from 'vue';
import * as hostUtils from '@/utils/host';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));
const systemOSLabel = computed(() => hostUtils.getSystemOSLabel(props.info?.Host?.Platform, true));
</script>
<style lang="scss" scoped>
.system-os-content {
display: flex;
align-items: center;
gap: 5px;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="transfer-group">
<div class="transfer--in">
{{ transferIn }}
</div>
<div class="split-line">
|
</div>
<div class="transfer--out">
{{ transferOut }}
</div>
</div>
</template>
<script setup>
/**
* 流量信息
*/
import {
computed,
} from 'vue';
const props = defineProps({
realTimeData: {
type: Object,
default: () => ({}),
},
});
const transferIn = computed(() => {
const { item } = props.realTimeData?.transfer || {};
if (item?.data?.in) {
const { value, unit } = item.data.in;
return `${value}${unit}`;
}
return '-';
});
const transferOut = computed(() => {
const { item } = props.realTimeData?.transfer || {};
if (item?.data?.out) {
const { value, unit } = item.data.out;
return `${value}${unit}`;
}
return '-';
});
</script>
<style lang="scss" scoped>
.transfer-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
width: 100%;
.transfer--in {
flex: 1;
text-align: right;
color: var(--transfer-in-color);
}
.transfer--out {
flex: 1;
text-align: left;
color: var(--transfer-out-color);
}
.split-line {
width: 6px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,446 @@
/**
* ServerStatus风格的列表列配置
*/
import {
h,
} from 'vue';
// import * as hostUtils from '@/utils/host';
import handleServerStatus from '@/views/composable/server-status';
import handleServerInfo from '@/views/composable/server-info';
import handleServerRealTime from '@/views/composable/server-real-time';
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
import ServerStatusProgress from '@/views/components/server/server-status-progress.vue';
import StatusIcon from '@/views/components/server-list/server-status/server-info/status-icon.vue';
import SystemOS from '@/views/components/server-list/server-status/server-info/system-os.vue';
import Country from '@/views/components/server-list/server-status/server-info/country.vue';
import NetSpeed from '@/views/components/server-list/server-status/server-info/net-speed.vue';
import Transfer from '@/views/components/server-list/server-status/server-info/transfer.vue';
import Conns from '@/views/components/server-list/server-status/server-info/conns.vue';
const COLUMN_MAP = Object.freeze({
status: {
label: '状态',
width: 40,
},
name: {
label: '名称',
minWidth: 100,
align: 'left',
},
config: {
label: '规格',
width: 80,
align: 'left',
},
system: {
label: '系统',
width: 90,
align: 'left',
},
country: {
label: '地区',
width: 60,
align: 'left',
},
duration: {
label: '在线',
width: 60,
align: 'left',
},
load: {
label: '负载',
width: 45,
align: 'center',
},
speeds: {
label: '网速',
width: 122,
align: 'center',
},
inSpeed: {
label: '入网',
width: 60,
align: 'left',
},
outSpeed: {
label: '出网',
width: 60,
align: 'left',
},
transfer: {
label: '流量',
width: 122,
align: 'center',
},
inTransfer: {
label: '入网流量',
width: 60,
align: 'left',
},
outTransfer: {
label: '出网流量',
width: 60,
align: 'left',
},
conns: {
label: '连接',
width: 72,
align: 'center',
},
tcp: {
label: 'TCP',
width: 40,
align: 'left',
},
udp: {
label: 'UDP',
width: 40,
align: 'left',
},
cpu: {
label: 'CPU',
width: 80,
align: 'center',
},
cpuText: {
valProp: 'cpu',
label: 'CPU',
width: 40,
align: 'center',
},
mem: {
label: '内存',
width: 80,
align: 'center',
},
memText: {
valProp: 'mem',
label: '内存',
width: 40,
align: 'center',
},
swap: {
label: '交换',
width: 80,
align: 'center',
},
swapText: {
valProp: 'swap',
label: '交换',
width: 40,
align: 'center',
},
disk: {
label: '硬盘',
width: 80,
align: 'center',
},
diskText: {
valProp: 'disk',
label: '硬盘',
width: 40,
align: 'center',
},
billing: {
label: '价格',
width: 100,
align: 'right',
},
remainingTime: {
label: '剩余',
width: 70,
align: 'right',
},
});
/**
* 默认列配置
*/
// eslint-disable-next-line max-len, vue/max-len
const DEFAULT_COLUMNS = 'status,name,country,system,config,duration,speeds,transfer,load,cpu,mem,disk,billing,remainingTime';
/**
* 需要实时更新的数据
*/
const RELD_TIME_DATA = [
'speeds', 'inSpeed', 'outSpeed',
'transfer', 'inTransfer', 'outTransfer',
'conns', 'tcp', 'udp',
'duration', 'load',
];
/**
* 获取列配置
* @param {string} columnsTpls 列配置模板
* @returns {Object} 列配置
* @property {Array} columns 列配置
*/
export const getColumnPropsConfig = (tpls = DEFAULT_COLUMNS) => {
const tplList = tpls.split(',');
const columnList = [];
tplList.forEach((tpl) => {
if (COLUMN_MAP[tpl]) {
columnList.push({
prop: tpl,
...COLUMN_MAP[tpl],
});
}
});
return columnList;
};
/**
* 将服务器数据转换为表格数据
* @param {Object} server 服务器数据
* @returns {Object} 表格数据
*/
export const handleServerItemData = (params) => {
const {
column,
server,
realTimeData,
progressData,
billAndPlan,
} = params || {};
switch (column.prop) {
case 'status':
return {
type: 'component',
component: h(StatusIcon, { info: server }),
originalData: params,
};
case 'name':
return {
type: 'text',
value: server.Name,
originalData: params,
};
case 'config':
{
const { cpuAndMemAndDisk } = handleServerInfo({
props: {
info: server,
},
originalData: params,
});
return {
type: 'text',
value: cpuAndMemAndDisk,
originalData: params,
};
}
case 'system':
return {
type: 'component',
component: h(SystemOS, { info: server }),
originalData: params,
};
case 'country':
return {
type: 'component',
component: h(Country, { info: server }),
originalData: params,
};
case 'speeds':
return {
type: 'component',
component: h(NetSpeed, { realTimeData }),
originalData: params,
};
case 'transfer':
return {
type: 'component',
component: h(Transfer, { realTimeData }),
originalData: params,
};
case 'conns':
return {
type: 'component',
component: h(Conns, { realTimeData }),
originalData: params,
};
case 'cpu':
case 'mem':
case 'disk':
case 'swap':
{
const progressItem = progressData[column.prop];
return {
type: 'component',
component: h(ServerStatusProgress, {
type: column.prop,
used: progressItem?.used || 0,
colors: progressItem?.colors || {},
valText: progressItem?.valPercent || '',
}),
originalData: params,
};
}
case 'cpuText':
case 'memText':
case 'diskText':
case 'swapText':
{
const progressItem = progressData[column.valProp];
return {
prop: column.prop,
type: 'text',
value: parseFloat(progressItem?.used || 0).toFixed(1),
unit: '%',
text: progressItem?.valPercent || '',
originalData: params,
};
}
case 'billing':
{
const item = billAndPlan?.value?.billing;
const texts = [];
if (item?.value) {
texts.push(item.value || '-');
}
if (item?.cycleLabel) {
texts.push(item.cycleLabel);
}
return {
prop: column.prop,
type: 'text',
text: texts.length ? texts.join('/') : '-',
originalData: params,
};
}
case 'remainingTime':
{
const item = billAndPlan?.value?.remainingTime;
return {
prop: column.prop,
type: 'text',
text: item?.value || '-',
originalData: params,
};
}
default: {
if (RELD_TIME_DATA.includes(column.prop) && realTimeData[column.prop]) {
const item = realTimeData[column.prop];
return {
prop: column.prop,
type: 'text',
text: item?.text,
value: item?.value,
unit: item?.unit,
originalData: params,
};
}
return {
prop: column.prop,
type: 'text',
value: '-',
originalData: params,
};
}
}
};
/**
* 将服务器数据转换为表格数据
* @param {Object} server 服务器数据
* @param {Array} columns 列配置
* @returns {Array} 表格数据
*/
export const handleServerListColumn = (serverList, columnTpls = DEFAULT_COLUMNS) => {
const columnProps = getColumnPropsConfig(columnTpls);
const tpls = columnProps.map((column) => column.valProp || column.prop).join(',');
const hasBilling = columnTpls.includes('billing');
const hasRemainingTime = columnTpls.includes('remainingTime');
let showBilling = false;
let showRemainingTime = false;
const list = serverList.map((server) => {
// 负载\网速\流量\在线等
const realTimeResult = handleServerRealTime({
props: {
info: server,
},
serverRealTimeListTpls: tpls,
});
const realTimeData = {};
realTimeResult?.serverRealTimeList?.value?.forEach?.((item) => {
if (item.show) {
const text = [item.value];
if (item.unit) {
text.push(item.unit);
}
realTimeData[item.key] = {
value: item.value,
unit: item.unit,
text: text.join(''),
item,
};
} else {
realTimeData[item.key] = {
text: '-',
item,
};
}
});
// CPU\内存\硬盘\交换 进度条
const {
serverStatusList,
} = handleServerStatus({
props: {
info: server,
},
statusListTpl: tpls,
statusListItemContent: false,
});
const progressData = {};
serverStatusList.value?.forEach?.((item) => {
progressData[item.type] = item;
});
let billAndPlan = null;
if (hasBilling || hasRemainingTime) {
const result = handleServerBillAndPlan({
props: {
info: server,
},
});
billAndPlan = result.billAndPlan;
if (billAndPlan?.value?.billing) {
showBilling = true;
}
if (billAndPlan?.value?.remainingTime) {
showRemainingTime = true;
}
}
const columnData = [];
columnProps.forEach((columnItem) => {
columnData.push({
...columnItem,
data: handleServerItemData({
column: columnItem,
server,
realTimeData,
progressData,
billAndPlan,
}),
});
});
return {
info: server,
columnData,
computedData: {
realTimeData,
progressData,
billAndPlan,
},
};
});
return {
list,
columnProps,
showBilling,
showRemainingTime,
};
};

View File

@ -0,0 +1,209 @@
<template>
<td
class="server-status-td server-status-body-td"
:class="columnClass"
:style="columnStyle"
>
<div
class="server-status-td-content"
:class="'server-status-td-content--' + tdContent.prop"
>
<template
v-if="tdContent.type === 'text'"
>
<span
v-if="isSet(tdContent.value)"
class="text--value"
>
{{ tdContent.value }}
</span>
<span
v-if="isSet(tdContent.unit)"
class="text--unit"
>
{{ tdContent.unit }}
</span>
<span
v-if="!isSet(tdContent.value) && isSet(tdContent.text)"
class="text"
>
{{ tdContent.text }}
</span>
</template>
<template
v-if="tdContent.type === 'component'"
>
<component :is="tdContent.component" />
</template>
</div>
</td>
</template>
<script setup>
/**
* 自定义TD组件
*/
import {
computed,
} from 'vue';
const props = defineProps({
column: {
type: Object,
default: () => ({}),
},
});
// css
const getCssLengthUnit = (value) => {
if (typeof value === 'number') {
return `${value}px`;
}
return value;
};
const columnClass = computed(() => {
const className = {
[`server-status-td--${props.column.prop}`]: true,
};
if (props.column.align) {
className[`server-status-td--align-${props.column.align}`] = true;
}
return className;
});
const columnStyle = computed(() => {
const style = {};
if (props.column.width) {
style.width = getCssLengthUnit(props.column.width);
}
if (props.column.minWidth) {
style.minWidth = getCssLengthUnit(props.column.minWidth);
}
return style;
});
const tdContent = computed(() => {
if (['text', 'component'].includes(props.column.data.type)) {
return props.column.data;
}
return '';
});
function isSet(value) {
return value !== undefined && value !== null && value !== '';
}
</script>
<style lang="scss" scoped>
.server-status-td {
height: var(--server-status-td-height);
padding: var(--server-status-cell-padding);
--td-content-justify-content: center;
&--align-center {
--td-content-justify-content: center;
}
&--align-right {
--td-content-justify-content: flex-end;
}
&--align-left {
--td-content-justify-content: flex-start;
}
.server-status-td-content {
display: flex;
align-items: center;
justify-content: var(--td-content-justify-content);
width: 100%;
line-height: var(--server-status-td-height);
&--transfer {
.text--value {
color: var(--transfer-color);
}
}
&--inTransfer {
.text--value {
color: var(--transfer-in-color);
}
}
&--outTransfer {
.text--value {
color: var(--transfer-out-color);
}
}
&--inSpeed {
.text--value {
color: var(--net-speed-in-color);
}
}
&--outSpeed {
.text--value {
color: var(--net-speed-out-color);
}
}
&--tcp {
.text--value {
color: var(--conn-tcp-color);
}
}
&--udp {
.text--value {
color: var(--conn-udp-color);
}
}
&--load {
.text--value {
color: var(--load-color);
}
}
&--duration {
.text--value {
color: var(--duration-color);
}
}
&--cpuText {
.text--value {
color: var(--cpu-text-color);
}
}
&--memText {
.text--value {
color: var(--mem-text-color);
}
}
&--swapText {
.text--value {
color: var(--swap-text-color);
}
}
&--diskText {
.text--value {
color: var(--disk-text-color);
}
}
&--billing {
font-size: 12px;
}
&--remainingTime {
font-size: 12px;
}
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<th
class="server-status-th"
:class="columnClass"
:style="columnStyle"
>
{{ column.label }}
</th>
</template>
<script setup>
/**
* 自定义TH组件
*/
import {
computed,
} from 'vue';
const props = defineProps({
column: {
type: Object,
default: () => ({}),
},
});
// css
const getCssLengthUnit = (value) => {
if (typeof value === 'number') {
return `${value}px`;
}
return value;
};
const columnClass = computed(() => {
const className = {};
if (props.column.align) {
className[`server-status-th--align-${props.column.align}`] = true;
}
return className;
});
const columnStyle = computed(() => {
const style = {};
if (props.column.width) {
style.width = getCssLengthUnit(props.column.width);
}
if (props.column.minWidth) {
style.minWidth = getCssLengthUnit(props.column.minWidth);
}
return style;
});
</script>
<style lang="scss" scoped>
.server-status-th {
padding: var(--server-status-cell-padding);
text-align: center;
&--align-center {
text-align: center;
}
&--align-right {
text-align: right;
}
&--align-left {
text-align: left;
}
}
</style>

View File

@ -12,7 +12,10 @@
class="progress-bar-label"
:title="label + '使用' + used + '%'"
>
<span class="server-status-label">
<span
v-if="label"
class="server-status-label"
>
{{ label }}:
</span>
<span class="server-status-val-text">
@ -96,13 +99,13 @@ const progressStyle = computed(() => {
@media screen and (max-width: 480px) {
flex: none;
width: calc(50% - 5px);
width: var(--progress-bar-width, calc(50% - 5px));
}
@media screen and (max-width: 350px) {
flex: none;
width: 100%;
}
// @media screen and (max-width: 350px) {
// flex: none;
// width: 100%;
// }
.progress-bar-box {
position: relative;

View File

@ -128,7 +128,13 @@ export default (params) => {
value: 0,
unit: '',
};
if (inStats.g > 1) {
if (inStats.p > 1) {
result.value = (inStats.p).toFixed(1) * 1;
result.unit = 'P';
} else if (inStats.t > 1) {
result.value = (inStats.t).toFixed(1) * 1;
result.unit = 'T';
} else if (inStats.g > 1) {
result.value = (inStats.g).toFixed(1) * 1;
result.unit = 'G';
} else if (inStats.m > 1) {
@ -147,7 +153,13 @@ export default (params) => {
value: 0,
unit: '',
};
if (outStats.g > 1) {
if (outStats.p > 1) {
result.value = (outStats.p).toFixed(1) * 1;
result.unit = 'P';
} else if (outStats.t > 1) {
result.value = (outStats.t).toFixed(1) * 1;
result.unit = 'T';
} else if (outStats.g > 1) {
result.value = (outStats.g).toFixed(1) * 1;
result.unit = 'G';
} else if (outStats.m > 1) {
@ -221,6 +233,18 @@ export default (params) => {
value: transfer.value?.value,
unit: transfer.value?.unit,
show: validate.isSet(transfer.value?.value),
data: {
in: {
value: inTransfer.value?.value,
unit: inTransfer.value?.unit,
show: validate.isSet(inTransfer.value?.value),
},
out: {
value: outTransfer.value?.value,
unit: outTransfer.value?.unit,
show: validate.isSet(outTransfer.value?.value),
},
},
};
case 'inTransfer':
return {
@ -263,6 +287,18 @@ export default (params) => {
`${netOutSpeed.value?.value}${netOutSpeed.value?.unit}`,
].join('|'),
show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),
data: {
in: {
value: netInSpeed.value?.value,
unit: netInSpeed.value?.unit,
show: validate.isSet(netInSpeed.value?.value),
},
out: {
value: netOutSpeed.value?.value,
unit: netOutSpeed.value?.unit,
show: validate.isSet(netOutSpeed.value?.value),
},
},
};
case 'load':
return {
@ -271,12 +307,49 @@ export default (params) => {
value: (props.info.State?.Load1 || 0).toFixed(2),
show: validate.isSet(props.info.State?.Load1),
};
case 'loads':
{
const loads = [];
loads.push((props.info.State?.Load1 || 0).toFixed(2));
loads.push((props.info.State?.Load5 || 0).toFixed(2));
loads.push((props.info.State?.Load15 || 0).toFixed(2));
return {
key,
label: '负载',
value: loads.join(','),
show: loads.some((load) => validate.isSet(load)),
data: {
load1: {
value: (props.info.State?.Load1 || 0).toFixed(2),
show: validate.isSet(props.info.State?.Load1),
},
load5: {
value: (props.info.State?.Load5 || 0).toFixed(2),
show: validate.isSet(props.info.State?.Load5),
},
load15: {
value: (props.info.State?.Load15 || 0).toFixed(2),
show: validate.isSet(props.info.State?.Load15),
},
},
};
}
case 'conns':
return {
key,
label: '连接',
value: `${props.info.State?.TcpConnCount || 0}|${props.info.State?.UdpConnCount || 0}`,
show: true,
data: {
tcp: {
value: props.info.State?.TcpConnCount || 0,
show: validate.isSet(props.info.State?.TcpConnCount),
},
udp: {
value: props.info.State?.UdpConnCount || 0,
show: validate.isSet(props.info.State?.UdpConnCount),
},
},
};
case 'tcp':
return {
@ -292,6 +365,7 @@ export default (params) => {
value: props.info.State?.UdpConnCount || 0,
show: validate.isSet(props.info.State?.UdpConnCount),
};
// 入网和出网
case 'I-A-O':
return {
key,
@ -314,6 +388,7 @@ export default (params) => {
],
show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),
};
// 负载和进程
case 'L-A-P':
return {
key,
@ -334,6 +409,7 @@ export default (params) => {
],
show: validate.isSet(props.info.State?.Load1) || validate.isSet(props.info.State?.ProcessCount),
};
// 连接 TCP和UDP
case 'T-A-U':
return {
key,
@ -354,6 +430,7 @@ export default (params) => {
],
show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount),
};
// 在线和流量
case 'D-A-T':
return {
key,

View File

@ -0,0 +1,128 @@
/**
* 服务器排序选项
*/
export const serverSortOptions = () => [{
label: '排序值',
value: 'DisplayIndex',
}, {
label: '主机名称',
value: 'Name',
}, {
label: '国家地区',
value: 'Host.CountryCode',
}, {
label: '系统平台',
value: 'Host.Platform',
}, {
label: '在线时长',
value: 'Host.BootTime',
}, {
label: '入网速度',
value: 'State.NetInSpeed',
}, {
label: '出网速度',
value: 'State.NetOutSpeed',
}, {
label: '入网流量',
value: 'State.NetInTransfer',
}, {
label: '出网流量',
value: 'State.NetOutTransfer',
}, {
label: '合计流量',
value: '$.TotalTransfer',
}, {
label: 'TCP连接',
value: 'State.TcpConnCount',
}, {
label: 'UDP连接',
value: 'State.UdpConnCount',
}, {
label: '总连接数',
value: '$.TotalConnCount',
}, {
label: '1分钟负载',
value: 'State.Load1',
}, {
label: 'CPU占用',
value: 'State.CPU',
}, {
label: '核心数量',
value: '$.CPU',
}, {
label: '内存占用',
value: 'State.MemUsed',
}, {
label: '内存大小',
value: 'Host.MemTotal',
}, {
label: '交换占用',
value: 'State.SwapUsed',
}, {
label: '交换大小',
value: 'Host.SwapTotal',
}, {
label: '硬盘占用',
value: 'State.DiskUsed',
}, {
label: '硬盘大小',
value: 'Host.DiskTotal',
}];
/**
* 服务器排序处理
*/
export function serverSortHandler(a, b, sortby, order) {
let aValue;
let bValue;
const hasDot = sortby.includes('.');
if (!hasDot) {
aValue = a[sortby];
bValue = b[sortby];
} else {
const [sortby1, sortby2] = sortby.split('.');
if (sortby1 !== '$') {
switch (sortby2) {
case 'BootTime':
{
const currentTime = Date.now();
aValue = currentTime - a.Host.BootTime * 1000;
bValue = currentTime - b.Host.BootTime * 1000;
break;
}
default:
{
aValue = a[sortby1][sortby2];
bValue = b[sortby1][sortby2];
break;
}
}
} else {
switch (sortby2) {
case 'TotalTransfer':
{
aValue = a.State.NetInTransfer + a.State.NetOutTransfer;
bValue = b.State.NetInTransfer + b.State.NetOutTransfer;
break;
}
case 'TotalConnCount':
{
aValue = a.State.TcpConnCount + a.State.UdpConnCount;
bValue = b.State.TcpConnCount + b.State.UdpConnCount;
break;
}
case 'CPU':
{
aValue = a.Host.CPU.length;
bValue = b.Host.CPU.length;
break;
}
default:
}
}
}
if (order === 'desc') {
return bValue - aValue;
}
return aValue - bValue;
}

View File

@ -1,5 +1,8 @@
<template>
<div class="index-container">
<div
class="index-container"
:class="indexContainerClass"
>
<div class="scroll-container">
<div
v-if="worldMapPosition === 'top' && showWorldMap"
@ -12,10 +15,11 @@
</div>
<div
v-if="showFilter"
class="fitler-group"
class="filter-group"
:class="{
'list-is-row': showListRow,
'list-is-card': showListCard,
'list-is--row': showListRow,
'list-is--card': showListCard,
'list-is--server-status': showListRowByServerStatus,
}"
>
<div class="left-box">
@ -26,6 +30,11 @@
/>
</div>
<div class="right-box">
<server-sort-box
v-if="showSort"
v-model="sortData"
:options="sortOptions"
/>
<server-option-box
v-if="onlineOptions.length"
v-model="filterFormData.online"
@ -35,11 +44,12 @@
v-if="config.nazhua.listServerItemTypeToggle"
v-model="listType"
:options="listTypeOptions"
:accpet-empty="false"
:accept-empty="false"
:mobile-show="false"
/>
</div>
</div>
<!-- 列表模式 -->
<server-list-warp
v-if="showListRow"
:show-transition="showTransition"
@ -51,6 +61,17 @@
:info="item"
/>
</server-list-warp>
<!-- ServerStatus模式 -->
<server-list-warp
v-if="showListRowByServerStatus"
:show-transition="showTransition"
:show-list-by-server-status="showListRowByServerStatus"
>
<server-status-main
:server-list="filterServerList.list"
/>
</server-list-warp>
<!-- 卡片模式 -->
<server-list-warp
v-if="showListCard"
:show-transition="showTransition"
@ -106,9 +127,16 @@ import validate from '@/utils/validate';
import WorldMap from '@/components/world-map/world-map.vue';
import ServerOptionBox from './components/server-list/server-option-box.vue';
import ServerSortBox from './components/server-list/server-sort-box.vue';
import ServerListWarp from './components/server-list/server-list-warp.vue';
import ServerCardItem from './components/server-list/card/server-list-item.vue';
import ServerRowItem from './components/server-list/row/server-list-item.vue';
import ServerStatusMain from './components/server-list/server-status/main.vue';
import {
serverSortOptions,
serverSortHandler,
} from './composable/server-sort';
const store = useStore();
const worldMapWidth = ref();
@ -137,16 +165,39 @@ const showListRow = computed(() => {
}
return false;
});
const showListRowByServerStatus = computed(() => {
if (windowWidth.value > 1024) {
if (config.nazhua.listServerItemTypeToggle) {
return listType.value === 'server-status';
}
return config.nazhua.listServerItemType === 'server-status';
}
return false;
});
const showListCard = computed(() => {
if (windowWidth.value > 1024) {
if (config.nazhua.listServerItemTypeToggle) {
return listType.value !== 'row';
return listType.value === 'card';
}
return config.nazhua.listServerItemType !== 'row';
return config.nazhua.listServerItemType === 'card';
}
return true;
});
const indexContainerClass = computed(() => {
const className = {};
if (showListRow.value) {
className['list-is--row'] = true;
}
if (showListCard.value) {
className['list-is--card'] = true;
}
if (showListRowByServerStatus.value) {
className['list-is--server-status'] = true;
}
return className;
});
const showFilter = computed(() => config.nazhua.hideFilter !== true);
const filterFormData = ref({
tag: '',
@ -217,16 +268,31 @@ watch(() => serverCount.value, () => {
const listTypeOptions = computed(() => [{
key: 'card',
label: '卡片',
label: '卡片模式',
value: 'card',
icon: 'ri-gallery-view-2',
}, {
key: 'row',
label: '列表',
label: '列表模式',
value: 'row',
icon: 'ri-list-view',
}, {
key: 'server-status',
label: 'ServerStatus模式',
value: 'server-status',
icon: 'ri-server-line',
}]);
/**
* 排序处理
*/
const showSort = computed(() => config.nazhua.hideSort !== true);
const sortData = ref({
prop: 'DisplayIndex',
order: 'desc',
});
const sortOptions = computed(() => serverSortOptions());
const filterServerList = computed(() => {
const fields = {};
const locationMap = {};
@ -287,6 +353,7 @@ const filterServerList = computed(() => {
return true;
});
list.sort((a, b) => serverSortHandler(a, b, sortData.value.prop, sortData.value.order));
return {
fields,
list,
@ -345,7 +412,10 @@ const worldMapPosition = computed(() => {
* 处理窗口大小变化
*/
function handleResize() {
worldMapWidth.value = document.querySelector('.server-list-container').clientWidth - 40;
const serverListContainer = document.querySelector('.server-list-container');
if (serverListContainer) {
worldMapWidth.value = serverListContainer.clientWidth - 40;
}
windowWidth.value = window.innerWidth;
}
@ -382,6 +452,7 @@ onActivated(() => {
.index-container {
width: 100%;
height: 100%;
overflow: hidden;
.scroll-container {
display: flex;
@ -400,9 +471,23 @@ onActivated(() => {
.bottom-world-map {
margin-top: 30px;
}
&.list-is--server-status {
--list-container-width: 1300px;
// 1440px
@media screen and (max-width: 1440px) {
--list-container-width: 1300px;
}
// 1280px
@media screen and (max-width: 1280px) {
--list-container-width: 1200px;
}
}
}
.fitler-group {
.filter-group {
display: flex;
flex-wrap: wrap;
justify-content: space-between;

View File

@ -2,7 +2,7 @@ import config from '@/config';
import MessageSubscribe from '@/utils/subscribe';
import v1TransformV0 from '@/utils/transform-v1-2-v0';
import WSService from './service';
import WSService, { WS_CONNECTION_STATUS } from './service';
/**
* 获取不同版本的WebSocket路径
@ -50,7 +50,7 @@ const wsService = new WSService({
});
function restart() {
if (wsService.connected !== 0) {
if (wsService.connected !== WS_CONNECTION_STATUS.DISCONNECTED) {
wsService.close();
}
wsService.active();
@ -63,7 +63,7 @@ export {
};
export default (actived) => {
if (wsService.connected === 2) {
if (wsService.connected === WS_CONNECTION_STATUS.CONNECTED) {
if (actived) {
actived();
}
@ -75,7 +75,7 @@ export default (actived) => {
}
});
// 如果已经连接中,则不再连接
if (wsService.connected === 1) {
if (wsService.connected === WS_CONNECTION_STATUS.CONNECTING) {
return;
}
wsService.active();

View File

@ -1,3 +1,11 @@
// WebSocket 连接状态常量
export const WS_CONNECTION_STATUS = {
DISCONNECTED: 0, // 未连接
CONNECTING: 1, // 连接中
CONNECTED: 2, // 已连接
CLOSED: -1, // 已关闭
};
class WSService {
constructor(options) {
const {
@ -23,16 +31,15 @@ class WSService {
messageError: onMessageError || (() => {}),
};
// 单例模式 遇到重复的ws服务不再允许建立新的ws消息处理如果遇到问题等待用户自行刷新页面破罐子破摔解决方法
// 单例模式:防止重复创建 WebSocket 连接
// 如果检测到已有实例,触发错误回调并返回,避免资源浪费
if (WSService.instance) {
// 抛出错误,防止重复创建 WebSocket 连接
this.$on.error(new Error('WebSocket connection already exists'));
return;
}
WSService.instance = this;
// 0: 未连接1: 连接中2: 已连接,-1: 已关闭
this.connected = 0;
this.connected = WS_CONNECTION_STATUS.DISCONNECTED;
this.ws = undefined;
this.evt = (event) => {
if (this.debug) {
@ -52,18 +59,18 @@ class WSService {
}
get isConnected() {
return this.connected === 2;
return this.connected === WS_CONNECTION_STATUS.CONNECTED;
}
active() {
// 如果已经连接中或已连接,则不再连接
if (this.connected > 0) {
if (this.connected > WS_CONNECTION_STATUS.DISCONNECTED) {
console.warn('WebSocket connection already exists or is connecting');
return;
}
// 标记为正在连接中
this.connected = 1;
this.connected = WS_CONNECTION_STATUS.CONNECTING;
// 创建 WebSocket 连接
this.ws = new WebSocket(this.$wsUrl);
@ -71,14 +78,14 @@ class WSService {
if (this.debug) {
console.log('socket connected', event);
}
this.connected = 2;
this.connected = WS_CONNECTION_STATUS.CONNECTED;
this.$on.connect(event);
});
this.ws.addEventListener('close', (event) => {
if (this.debug) {
console.log('socket closed', event);
}
this.connected = -1;
this.connected = WS_CONNECTION_STATUS.CLOSED;
WSService.instance = null; // 清除实例引用
this.$on.close(event);
});

3097
yarn.lock

File diff suppressed because it is too large Load Diff