新增列表的排序功能

This commit is contained in:
hi2hi 2025-12-09 17:27:19 +08:00
parent 93f66cb42c
commit 586f1dd063
9 changed files with 5834 additions and 3107 deletions

5187
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

@ -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,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

@ -30,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"
@ -122,11 +127,17 @@ 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();
const windowWidth = ref(window.innerWidth);
@ -272,6 +283,16 @@ const listTypeOptions = computed(() => [{
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 = {};
@ -332,6 +353,7 @@ const filterServerList = computed(() => {
return true;
});
list.sort((a, b) => serverSortHandler(a, b, sortData.value.prop, sortData.value.order));
return {
fields,
list,

3097
yarn.lock

File diff suppressed because it is too large Load Diff