فهرست منبع

feat(panel): add 'Edit' button to tables and enhance layout (#4355)

- Move 'Edit' button from dropdown to the table since it's the most used action. Only for desktop.
- Increase column widths for action keys in Inbounds, Balancers, Outbounds and Routing tables.
- Slightly enhance layout for consistency.
Black 1 روز پیش
والد
کامیت
194de8869e

+ 65 - 49
frontend/src/pages/inbounds/InboundList.vue

@@ -230,7 +230,7 @@ const hasAnyRemark = computed(() =>
 const desktopColumns = computed(() => {
   const cols = [
     sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
-    { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
+    { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
     sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
   ];
   if (hasAnyRemark.value) {
@@ -571,59 +571,68 @@ function showQrCodeMenu(dbInbound) {
         <template #bodyCell="{ column, record }">
           <!-- ============== Action dropdown ============== -->
           <template v-if="column.key === 'action'">
-            <a-dropdown :trigger="['click']">
-              <MoreOutlined class="row-action-trigger" @click.prevent />
-              <template #overlay>
-                <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
-                  <a-menu-item key="edit">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
-                    <QrcodeOutlined /> {{ t('qrCode') }}
-                  </a-menu-item>
-                  <template v-if="record.isMultiUser()">
-                    <a-menu-item key="addClient">
-                      <UserAddOutlined /> {{ t('pages.client.add') }}
-                    </a-menu-item>
-                    <a-menu-item key="addBulkClient">
-                      <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                    </a-menu-item>
-                    <a-menu-item key="copyClients">
-                      <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                    </a-menu-item>
-                    <a-menu-item key="resetClients">
-                      <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+            <div class="action-buttons">
+              <a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
+                <template #icon>
+                  <EditOutlined />
+                </template>
+              </a-button>
+
+              <a-dropdown :trigger="['click']">
+                <a-button type="text" size="small" @click.prevent>
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
+                    <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
+                      <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
-                    <a-menu-item key="export">
-                      <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                    <template v-if="record.isMultiUser()">
+                      <a-menu-item key="addClient">
+                        <UserAddOutlined /> {{ t('pages.client.add') }}
+                      </a-menu-item>
+                      <a-menu-item key="addBulkClient">
+                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
+                      </a-menu-item>
+                      <a-menu-item key="copyClients">
+                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
+                      </a-menu-item>
+                      <a-menu-item key="resetClients">
+                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+                      </a-menu-item>
+                      <a-menu-item key="export">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                      </a-menu-item>
+                      <a-menu-item v-if="subEnable" key="subs">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                      </a-menu-item>
+                      <a-menu-item key="delDepletedClients" class="danger-item">
+                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                      </a-menu-item>
+                    </template>
+                    <template v-else>
+                      <a-menu-item key="showInfo">
+                        <InfoCircleOutlined /> {{ t('info') }}
+                      </a-menu-item>
+                    </template>
+                    <a-menu-item key="clipboard">
+                      <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
                     </a-menu-item>
-                    <a-menu-item v-if="subEnable" key="subs">
-                      <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                    <a-menu-item key="resetTraffic">
+                      <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
                     </a-menu-item>
-                    <a-menu-item key="delDepletedClients" class="danger-item">
-                      <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                    <a-menu-item key="clone">
+                      <BlockOutlined /> {{ t('pages.inbounds.clone') }}
                     </a-menu-item>
-                  </template>
-                  <template v-else>
-                    <a-menu-item key="showInfo">
-                      <InfoCircleOutlined /> {{ t('info') }}
+                    <a-menu-item key="delete" class="danger-item">
+                      <DeleteOutlined /> {{ t('delete') }}
                     </a-menu-item>
-                  </template>
-                  <a-menu-item key="clipboard">
-                    <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
-                  </a-menu-item>
-                  <a-menu-item key="resetTraffic">
-                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
-                  </a-menu-item>
-                  <a-menu-item key="clone">
-                    <BlockOutlined /> {{ t('pages.inbounds.clone') }}
-                  </a-menu-item>
-                  <a-menu-item key="delete" class="danger-item">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </template>
 
           <!-- ============== Enable switch (desktop) ============== -->
@@ -764,6 +773,13 @@ function showQrCodeMenu(dbInbound) {
     margin-bottom: 4px;
 }
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
 .protocol-tags {
   display: inline-flex;
   flex-wrap: wrap;

+ 47 - 21
frontend/src/pages/xray/BalancersTab.vue

@@ -22,6 +22,7 @@ const { t } = useI18n();
 const props = defineProps({
   templateSettings: { type: Object, default: null },
   clientReverseTags: { type: Array, default: () => [] },
+  isMobile: { type: Boolean, default: false },
 });
 
 const STRATEGY_LABELS = {
@@ -197,7 +198,7 @@ function confirmDelete(idx) {
 }
 
 const columns = computed(() => [
-  { title: '#', key: 'action', align: 'center', width: 80 },
+  { title: '#', key: 'action', align: 'center', width: 100 },
   { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
   { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
   { title: 'Selector', key: 'selector', align: 'center' },
@@ -267,25 +268,39 @@ const obsText = computed({
         {{ t('pages.xray.Balancers') }}
       </a-button>
 
-      <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
+      <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
+        size="small" :scroll="{ x: 400 }">
         <template #bodyCell="{ column, record, index }">
           <template v-if="column.key === 'action'">
-            <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small" class="action-btn">
-                <MoreOutlined />
-              </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+            <div class="action-cell">
+              <span class="row-index">{{ index + 1 }}</span>
+
+              <div :class="!isMobile ? 'action-buttons' : ''">
+                <a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
+                  <template #icon>
+                    <EditOutlined />
+                  </template>
+                </a-button>
+
+                <a-dropdown :trigger="['click']">
+                  <a-button shape="circle" size="small">
+                    <template #icon>
+                      <MoreOutlined />
+                    </template>
+                  </a-button>
+                  <template #overlay>
+                    <a-menu>
+                      <a-menu-item v-if="isMobile" @click="openEdit(index)">
+                        <EditOutlined /> {{ t('edit') }}
+                      </a-menu-item>
+                      <a-menu-item class="danger" @click="confirmDelete(index)">
+                        <DeleteOutlined /> {{ t('delete') }}
+                      </a-menu-item>
+                    </a-menu>
+                  </template>
+                </a-dropdown>
+              </div>
+            </div>
           </template>
 
           <template v-else-if="column.key === 'strategy'">
@@ -316,14 +331,25 @@ const obsText = computed({
 </template>
 
 <style scoped>
+.action-cell {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
 .row-index {
   font-weight: 500;
   opacity: 0.7;
-  margin-right: 6px;
+  min-width: 18px;
+  text-align: right;
 }
 
-.action-btn {
-  vertical-align: middle;
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
 }
 
 .danger {

+ 51 - 30
frontend/src/pages/xray/OutboundsTab.vue

@@ -157,9 +157,9 @@ function hasBreakdown(r) {
 // === Columns ========================================================
 // Computed so titles re-render after a locale swap.
 const columns = computed(() => [
-  { title: '#', key: 'action', align: 'center', width: 70 },
-  { title: 'Tag', key: 'identity', align: 'left', width: 220 },
-  { title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
+  { title: '#', key: 'action', align: 'center', width: 100 },
+  { title: 'Tag', key: 'identity', align: 'left' },
+  { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
   { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
   { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
   { title: t('check'), key: 'test', align: 'center', width: 80 },
@@ -322,33 +322,41 @@ const rows = computed(() => {
         <template v-if="column.key === 'action'">
           <div class="action-cell">
             <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small">
-                <MoreOutlined />
-              </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item v-if="index > 0" @click="setFirst(index)">
-                    <VerticalAlignTopOutlined /> Move to top
-                  </a-menu-item>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> Edit
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
-                    <ArrowUpOutlined />
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
-                    <ArrowDownOutlined />
-                  </a-menu-item>
-                  <a-menu-item @click="emit('reset-traffic', record.tag || '')">
-                    <RetweetOutlined /> Reset traffic
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> Delete
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+
+            <div class="action-buttons">
+              <a-button shape="circle" size="small" @click="openEdit(index)">
+                <template #icon>
+                    <EditOutlined />
+                  </template>
+                </a-button>
+
+              <a-dropdown :trigger="['click']">
+                <a-button shape="circle" size="small">
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu>
+                    <a-menu-item v-if="index > 0" @click="setFirst(index)">
+                      <VerticalAlignTopOutlined /> Move to top
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                      <ArrowUpOutlined />
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                      <ArrowDownOutlined />
+                    </a-menu-item>
+                    <a-menu-item @click="emit('reset-traffic', record.tag || '')">
+                      <RetweetOutlined /> Reset traffic
+                    </a-menu-item>
+                    <a-menu-item class="danger" @click="confirmDelete(index)">
+                      <DeleteOutlined /> Delete
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </div>
         </template>
 
@@ -444,6 +452,11 @@ const rows = computed(() => {
   justify-content: flex-end;
 }
 
+.toolbar-right :global(.ant-space),
+.header-actions :global(.ant-space) {
+  margin-bottom: 0 !important;
+}
+
 .card-empty {
   text-align: center;
   opacity: 0.4;
@@ -526,6 +539,14 @@ const rows = computed(() => {
   text-align: right;
 }
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
+}
+
 .identity-cell {
   display: flex;
   flex-direction: column;

+ 40 - 21
frontend/src/pages/xray/RoutingTab.vue

@@ -220,7 +220,7 @@ function rowProps(_record, index) {
 // === Columns =========================================================
 // Computed so titles re-render after a locale swap.
 const desktopColumns = computed(() => [
-  { title: '#', align: 'center', width: 70, key: 'action' },
+  { title: '#', align: 'center', width: 100, key: 'action' },
   { title: 'Source', align: 'left', width: 180, key: 'source' },
   { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
   { title: 'Destination', align: 'left', key: 'destination' },
@@ -340,27 +340,38 @@ function chipPreview(value) {
             <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
               @pointerdown="onHandlePointerDown(index, $event)" />
             <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small">
-                <MoreOutlined />
+
+            <div :class="!isMobile ? 'action-buttons' : ''">
+              <a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
+                <template #icon>
+                  <EditOutlined />
+                </template>
               </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
-                    <ArrowUpOutlined />
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
-                    <ArrowDownOutlined />
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+
+              <a-dropdown :trigger="['click']">
+                <a-button shape="circle" size="small">
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu>
+                    <a-menu-item v-if="isMobile" @click="openEdit(index)">
+                      <EditOutlined /> {{ t('edit') }}
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                      <ArrowUpOutlined />
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                      <ArrowDownOutlined />
+                    </a-menu-item>
+                    <a-menu-item class="danger" @click="confirmDelete(index)">
+                      <DeleteOutlined /> {{ t('delete') }}
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </div>
         </template>
 
@@ -550,6 +561,14 @@ function chipPreview(value) {
   text-align: right;
 }
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
+}
+
 .criterion-flow {
   display: flex;
   flex-direction: column;

+ 2 - 1
frontend/src/pages/xray/XrayPage.vue

@@ -349,7 +349,8 @@ onBeforeUnmount(() => {
                         </a-tooltip>
                         <span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
                       </template>
-                      <BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
+                      <BalancersTab :template-settings="templateSettings" 
+                      :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-dns" class="tab-pane">