Pārlūkot izejas kodu

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 <[email protected]>
MHSanaei 6 stundas atpakaļ
vecāks
revīzija
b885a1f8a6

+ 34 - 4
frontend/src/components/Sparkline.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
 
 const props = defineProps({
   data: { type: Array, required: true },
@@ -36,8 +36,37 @@ const props = defineProps({
 
 const hoverIdx = ref(-1);
 
-const viewBoxAttr = computed(() => `0 0 ${props.vbWidth} ${props.height}`);
-const drawWidth = computed(() => Math.max(1, props.vbWidth - props.paddingLeft - props.paddingRight));
+// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
+// 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 nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
 
@@ -164,7 +193,7 @@ function onMouseMove(evt) {
   if (!props.showTooltip || pointsArr.value.length === 0) return;
   const rect = evt.currentTarget.getBoundingClientRect();
   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 dx = n > 1 ? drawWidth.value / (n - 1) : 0;
   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>
   <svg
+    ref="svgRef"
     width="100%"
     :height="height"
     :viewBox="viewBoxAttr"

+ 50 - 24
frontend/src/pages/index/IndexPage.vue

@@ -123,18 +123,18 @@ async function openConfig() {
           <a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('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, 12]">
               <a-col :span="24">
                 <StatusCard :status="status" :is-mobile="isMobile" />
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
                   @stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
                   @open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('menu.link')" hoverable>
                   <template #actions>
                     <a-space class="action" @click="logsOpen = true">
@@ -153,7 +153,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card title="3X-UI" hoverable>
                   <template v-if="panelUpdateInfo.updateAvailable" #extra>
                     <a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
@@ -164,23 +164,21 @@ async function openConfig() {
                       </a-tag>
                     </a-tooltip>
                   </template>
-                  <a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
-                    <a-tag color="green">v{{ displayVersion }}</a-tag>
-                  </a>
-                  <a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
-                    <a-tag color="green">@XrayUI</a-tag>
-                  </a>
-                  <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>
-                  <a-tag color="blue" class="history-tag" @click="openSystemHistory">
-                    <AreaChartOutlined />
-                    {{ t('pages.index.systemHistoryTitle') }}
-                  </a-tag>
+                  <div class="link-tags">
+                    <a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
+                      <a-tag color="green">v{{ displayVersion }}</a-tag>
+                    </a>
+                    <a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
+                      <a-tag color="green">@XrayUI</a-tag>
+                    </a>
+                    <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>
+                  </div>
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('pages.index.operationHours')" hoverable>
                   <a-tag :color="status.xray.color">
                     Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
@@ -189,8 +187,14 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <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-tag color="green">
                       {{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
@@ -199,7 +203,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('usage')" hoverable>
                   <a-tag color="green">
                     {{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
@@ -210,7 +214,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('pages.index.overallSpeed')" hoverable>
                   <a-row :gutter="isMobile ? [8, 8] : 0">
                     <a-col :span="12">
@@ -235,7 +239,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('pages.index.totalData')" hoverable>
                   <a-row :gutter="isMobile ? [8, 8] : 0">
                     <a-col :span="12">
@@ -258,7 +262,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('pages.index.ipAddresses')" hoverable>
                   <template #extra>
                     <a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
@@ -285,7 +289,7 @@ async function openConfig() {
                 </a-card>
               </a-col>
 
-              <a-col :sm="24" :lg="12">
+              <a-col :xs="24" :lg="12">
                 <a-card :title="t('pages.index.connectionCount')" hoverable>
                   <a-row :gutter="isMobile ? [8, 8] : 0">
                     <a-col :span="12">
@@ -354,6 +358,13 @@ async function openConfig() {
   padding: 24px;
 }
 
+@media (max-width: 768px) {
+  .content-area {
+    padding: 12px;
+    padding-top: 64px;
+  }
+}
+
 .loading-spacer {
   min-height: calc(100vh - 120px);
 }
@@ -376,6 +387,21 @@ async function openConfig() {
   display: inline-flex;
   align-items: center;
   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 {

+ 2 - 2
frontend/src/pages/index/StatusCard.vue

@@ -27,7 +27,7 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
   <a-card hoverable>
     <a-row :gutter="[0, isMobile ? 16 : 0]">
       <!-- CPU + Memory -->
-      <a-col :sm="24" :md="12">
+      <a-col :xs="24" :md="12">
         <a-row>
           <a-col :span="12" class="text-center">
             <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>
 
       <!-- Swap + Disk -->
-      <a-col :sm="24" :md="12">
+      <a-col :xs="24" :md="12">
         <a-row>
           <a-col :span="12" class="text-center">
             <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"

+ 4 - 1
frontend/src/pages/index/SystemHistoryModal.vue

@@ -3,8 +3,11 @@ import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { HttpUtil, SizeFormatter } from '@/utils';
 import Sparkline from '@/components/Sparkline.vue';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
 
 const { t } = useI18n();
+const { isMobile } = useMediaQuery();
+const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
 
 const props = defineProps({
   open: { type: Boolean, default: false },
@@ -106,7 +109,7 @@ watch([activeKey, bucket], () => {
 </script>
 
 <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>
       {{ t('pages.index.systemHistoryTitle') }}
       <a-select v-model:value="bucket" size="small" class="bucket-select">