feat(nodes): mobile card list, info modal, and tighter summary layout

NodeList now branches on isMobile: a vertical card list mirrors the
inbound mobile redesign — status dot + name + an Info icon that opens
an a-modal with the full per-node stats (address, status, CPU/mem,
xray version, uptime, latency, last heartbeat). The card head expands
to surface NodeHistoryPanel inline (parity with the desktop expandable
row), and the more-dropdown carries probe/edit/delete.

NodesPage also gets two layout fixes: an 8px vertical gutter between
the summary card and the node list on mobile (was 0), and a 2x2 grid
for the four summary statistics on phones via :xs="12" plus a 16px
inner vertical gutter, so Total/Online/Offline/Avg Latency no longer
crowd each other.
This commit is contained in:
MHSanaei
2026-05-13 23:14:56 +02:00
parent 933567d423
commit e564c9283d
2 changed files with 229 additions and 7 deletions
+223 -1
View File
@@ -9,6 +9,9 @@ import {
ExclamationCircleOutlined,
EyeOutlined,
EyeInvisibleOutlined,
InfoCircleOutlined,
MoreOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import NodeHistoryPanel from './NodeHistoryPanel.vue';
@@ -72,6 +75,25 @@ function formatPct(p) {
if (typeof p !== 'number' || isNaN(p)) return '-';
return `${p.toFixed(1)}%`;
}
const statsNode = ref(null);
function openStats(node) {
statsNode.value = node;
}
function closeStats() {
statsNode.value = null;
}
const expandedIds = ref(new Set());
function toggleExpanded(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function isExpanded(id) {
return expandedIds.value.has(id);
}
</script>
<template>
@@ -85,7 +107,103 @@ function formatPct(p) {
</a-button>
</div>
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="node-cards">
<div v-if="dataSource.length === 0" class="card-empty"></div>
<div v-for="record in dataSource" :key="record.id" class="node-card">
<div class="card-head" @click="toggleExpanded(record.id)">
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
<a-badge
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<span class="node-name">{{ record.name }}</span>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
</a-tooltip>
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu>
<a-menu-item key="probe" @click="emit('probe', record)">
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
</a-menu-item>
<a-menu-item key="edit" @click="emit('edit', record)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div v-if="isExpanded(record.id)" class="card-history">
<NodeHistoryPanel :node="record" />
</div>
</div>
</div>
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
<div v-if="statsNode" class="card-stats">
<div v-if="statsNode.remark" class="stat-row">
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
<span>{{ statsNode.remark }}</span>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showAddress = !showAddress" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
<a-badge
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
<ExclamationCircleOutlined style="color: #faad14" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
<a-tag>
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
<template v-else>-</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
</div>
</div>
</a-modal>
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
size="middle" row-key="id">
<template #expandedRowRender="{ record }">
<NodeHistoryPanel :node="record" />
@@ -240,4 +358,108 @@ function formatPct(p) {
.address-visible {
filter: none;
}
.node-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
.node-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 10px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 8px;
}
:global(body.dark) .node-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
.node-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.row-action-trigger {
font-size: 20px;
cursor: pointer;
}
.card-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
min-width: 96px;
flex-shrink: 0;
}
.card-stats :deep(.ant-tag) {
margin: 0;
}
.card-history {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
}
.danger-item {
color: #ff4d4f;
}
</style>
+6 -6
View File
@@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<!-- Summary statistics card -->
<a-col :span="24">
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="6">
<a-row :gutter="[16, isMobile ? 16 : 12]">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
<template #prefix>
<CloudServerOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
<template #prefix>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
<template #prefix>
<CloseCircleOutlined style="color: #ff4d4f" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
<template #prefix>