mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
fix(index): improve mobile dashboard layout
- Move System History action from the 3X-UI card into the System Load card's #extra slot so the chart opener sits next to live load values. - Fix card widths on mobile by switching :sm="24" to :xs="24"; the sm breakpoint only kicks in at >=576px, so phones in portrait had no span set and cards shrank to content width. - Restore vertical spacing between cards (vertical gutter was 0 on mobile) and reduce content padding on small screens, reserving 64px top so the sidebar drawer handle no longer overlaps the StatusCard. - Wrap the 3X-UI link tags in a flex container so version/Telegram/docs chips wrap with consistent spacing on narrow widths. - Make Sparkline's viewBox track its actual rendered pixel width via ResizeObserver so X-axis time labels stop being squashed horizontally by preserveAspectRatio="none" on narrow containers. - Make the SystemHistory modal width responsive (95vw on mobile, was a fixed 900px that overflowed phone viewports). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
439f4cf1e8
commit
b885a1f8a6
4 changed files with 90 additions and 31 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: { type: Array, required: true },
|
data: { type: Array, required: true },
|
||||||
|
|
@ -36,8 +36,37 @@ const props = defineProps({
|
||||||
|
|
||||||
const hoverIdx = ref(-1);
|
const hoverIdx = ref(-1);
|
||||||
|
|
||||||
const viewBoxAttr = computed(() => `0 0 ${props.vbWidth} ${props.height}`);
|
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||||
const drawWidth = computed(() => Math.max(1, props.vbWidth - props.paddingLeft - props.paddingRight));
|
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||||
|
// stretches the X axis and squashes axis text horizontally on narrow
|
||||||
|
// containers (mobile). Falls back to the prop until the first measure.
|
||||||
|
const svgRef = ref(null);
|
||||||
|
const measuredWidth = ref(0);
|
||||||
|
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||||
|
|
||||||
|
let resizeObserver = null;
|
||||||
|
function measure() {
|
||||||
|
const el = svgRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const w = el.getBoundingClientRect?.().width || 0;
|
||||||
|
if (w > 0) measuredWidth.value = Math.round(w);
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(measure);
|
||||||
|
resizeObserver.observe(svgRef.value);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
|
else window.removeEventListener('resize', measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||||
|
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||||
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||||
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||||
|
|
||||||
|
|
@ -164,7 +193,7 @@ function onMouseMove(evt) {
|
||||||
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||||
const rect = evt.currentTarget.getBoundingClientRect();
|
const rect = evt.currentTarget.getBoundingClientRect();
|
||||||
const px = evt.clientX - rect.left;
|
const px = evt.clientX - rect.left;
|
||||||
const x = (px / rect.width) * props.vbWidth;
|
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||||
const n = nPoints.value;
|
const n = nPoints.value;
|
||||||
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||||
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||||
|
|
@ -192,6 +221,7 @@ const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
width="100%"
|
width="100%"
|
||||||
:height="height"
|
:height="height"
|
||||||
:viewBox="viewBoxAttr"
|
:viewBox="viewBoxAttr"
|
||||||
|
|
|
||||||
|
|
@ -123,18 +123,18 @@ async function openConfig() {
|
||||||
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
||||||
<div v-if="!fetched" class="loading-spacer" />
|
<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, 12]">
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<StatusCard :status="status" :is-mobile="isMobile" />
|
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
||||||
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
||||||
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('menu.link')" hoverable>
|
<a-card :title="t('menu.link')" hoverable>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-space class="action" @click="logsOpen = true">
|
<a-space class="action" @click="logsOpen = true">
|
||||||
|
|
@ -153,7 +153,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card title="3X-UI" hoverable>
|
<a-card title="3X-UI" hoverable>
|
||||||
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
||||||
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
||||||
|
|
@ -164,6 +164,7 @@ async function openConfig() {
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="link-tags">
|
||||||
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
|
||||||
<a-tag color="green">v{{ displayVersion }}</a-tag>
|
<a-tag color="green">v{{ displayVersion }}</a-tag>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -173,14 +174,11 @@ async function openConfig() {
|
||||||
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
||||||
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
||||||
</a>
|
</a>
|
||||||
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
</div>
|
||||||
<AreaChartOutlined />
|
|
||||||
{{ t('pages.index.systemHistoryTitle') }}
|
|
||||||
</a-tag>
|
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||||
<a-tag :color="status.xray.color">
|
<a-tag :color="status.xray.color">
|
||||||
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
||||||
|
|
@ -189,8 +187,14 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
||||||
|
<template #extra>
|
||||||
|
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
||||||
|
<AreaChartOutlined />
|
||||||
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
||||||
<a-tag color="green">
|
<a-tag color="green">
|
||||||
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
||||||
|
|
@ -199,7 +203,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('usage')" hoverable>
|
<a-card :title="t('usage')" hoverable>
|
||||||
<a-tag color="green">
|
<a-tag color="green">
|
||||||
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
||||||
|
|
@ -210,7 +214,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
|
|
@ -235,7 +239,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.totalData')" hoverable>
|
<a-card :title="t('pages.index.totalData')" hoverable>
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
|
|
@ -258,7 +262,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
||||||
|
|
@ -285,7 +289,7 @@ async function openConfig() {
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
|
|
@ -354,6 +358,13 @@ async function openConfig() {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 12px;
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spacer {
|
.loading-spacer {
|
||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
@ -376,6 +387,21 @@ async function openConfig() {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags a {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags :deep(.ant-tag) {
|
||||||
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-toggle-icon {
|
.ip-toggle-icon {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||||
<a-card hoverable>
|
<a-card hoverable>
|
||||||
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
||||||
<!-- CPU + Memory -->
|
<!-- CPU + Memory -->
|
||||||
<a-col :sm="24" :md="12">
|
<a-col :xs="24" :md="12">
|
||||||
<a-row>
|
<a-row>
|
||||||
<a-col :span="12" class="text-center">
|
<a-col :span="12" class="text-center">
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
|
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
|
||||||
|
|
@ -57,7 +57,7 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<!-- Swap + Disk -->
|
<!-- Swap + Disk -->
|
||||||
<a-col :sm="24" :md="12">
|
<a-col :xs="24" :md="12">
|
||||||
<a-row>
|
<a-row>
|
||||||
<a-col :span="12" class="text-center">
|
<a-col :span="12" class="text-center">
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
|
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||||
import Sparkline from '@/components/Sparkline.vue';
|
import Sparkline from '@/components/Sparkline.vue';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
|
|
@ -106,7 +109,7 @@ watch([activeKey, bucket], () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-modal :open="open" :closable="true" :footer="null" width="900px" @cancel="close">
|
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('pages.index.systemHistoryTitle') }}
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue