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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user