From 46b6f8c66c3c9c5ac99b7fabaa8eb260b281733b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 13 May 2026 15:28:21 +0200 Subject: [PATCH] feat(routing): drag-reorder rules, split balancer column, mobile card layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grip-handle drag-and-drop on the # cell to reorder rules, built on Pointer Events so the same code works for mouse, touch, and pen (HTML5 drag doesn't fire from touch on iOS Safari). 5px threshold keeps quick taps from triggering a reorder; up/down arrow menu items stay as a keyboard/a11y fallback. Drop indicator is a 2px blue line on the target edge; dragged row fades to 40%. - Split the old combined target column into Outbounds and Balancer columns. Each row now has exactly one populated cell — green outbound tag or purple balancer tag. - Mobile drops the a-table (520px+ of column widths overflowed every phone) for a stacked card layout: # + grip + actions on top, an "Inbound → Outbound/Balancer" flow row in the middle, and criteria chips (domain, IP, port, src IP/port, L4, protocol, user, VLESS) below for whichever fields are actually set. Multi-value chips collapse to "first +N" with full value on hover. --- frontend/src/pages/xray/RoutingTab.vue | 355 +++++++++++++++++++++++-- 1 file changed, 334 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/xray/RoutingTab.vue b/frontend/src/pages/xray/RoutingTab.vue index e7ce5344..c98c0c16 100644 --- a/frontend/src/pages/xray/RoutingTab.vue +++ b/frontend/src/pages/xray/RoutingTab.vue @@ -10,6 +10,7 @@ import { ClusterOutlined, ArrowUpOutlined, ArrowDownOutlined, + HolderOutlined, } from '@ant-design/icons-vue'; import { Modal } from 'ant-design-vue'; @@ -22,9 +23,11 @@ const { t } = useI18n(); // "lead value + N more" pill per criterion (matches the legacy pill // layout); full lists surface via tooltip on hover. // -// Reorder uses up/down buttons in the action menu rather than the -// jQuery-Sortable drag handle the legacy panel used — same effect, -// no extra dep. The mobile column layout drops source/network/ +// Reorder via Pointer Events on the grip icon — unified mouse + +// touch + pen path so the same code works on desktop and mobile +// (HTML5 drag doesn't fire from touch on iOS Safari, hence the +// switch). Up/down buttons in the action menu stay as a keyboard +// fallback. The mobile column layout drops source/network/ // destination criteria for readability. const props = defineProps({ @@ -162,6 +165,58 @@ function moveDown(idx) { [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]]; } +const draggedIndex = ref(null); +const dropTargetIndex = ref(null); +let dragStartY = 0; +let dragMoved = false; + +function onHandlePointerDown(idx, ev) { + if (ev.button != null && ev.button !== 0) return; + ev.preventDefault(); + draggedIndex.value = idx; + dropTargetIndex.value = idx; + dragStartY = ev.clientY; + dragMoved = false; + document.addEventListener('pointermove', onDragPointerMove); + document.addEventListener('pointerup', onDragPointerUp); + document.addEventListener('pointercancel', onDragPointerUp); +} + +function onDragPointerMove(ev) { + if (draggedIndex.value == null) return; + if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return; + dragMoved = true; + const el = document.elementFromPoint(ev.clientX, ev.clientY); + if (!el) return; + const tr = el.closest('tr[data-row-key]'); + if (!tr) return; + const idx = Number(tr.getAttribute('data-row-key')); + if (Number.isFinite(idx)) dropTargetIndex.value = idx; +} + +function onDragPointerUp() { + document.removeEventListener('pointermove', onDragPointerMove); + document.removeEventListener('pointerup', onDragPointerUp); + document.removeEventListener('pointercancel', onDragPointerUp); + const from = draggedIndex.value; + const to = dropTargetIndex.value; + draggedIndex.value = null; + dropTargetIndex.value = null; + if (!dragMoved || from == null || to == null || from === to) return; + const rules = props.templateSettings.routing.rules; + const [moved] = rules.splice(from, 1); + rules.splice(to, 0, moved); +} + +function rowProps(_record, index) { + const classes = []; + if (draggedIndex.value === index) classes.push('row-dragging'); + if (dropTargetIndex.value === index && draggedIndex.value !== index) { + classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before'); + } + return { class: classes.join(' ') }; +} + // === Columns ========================================================= // Computed so titles re-render after a locale swap. const desktopColumns = computed(() => [ @@ -170,14 +225,31 @@ const desktopColumns = computed(() => [ { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' }, { title: 'Destination', align: 'left', key: 'destination' }, { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' }, - { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' }, + { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' }, + { title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' }, ]); -const mobileColumns = computed(() => [ - { title: '#', align: 'center', width: 70, key: 'action' }, - { title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' }, - { title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' }, -]); -const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value)); +const columns = computed(() => desktopColumns.value); + +function ruleCriteriaChips(rule) { + const chips = []; + if (rule.domain) chips.push({ label: 'Domain', value: rule.domain }); + if (rule.ip) chips.push({ label: 'IP', value: rule.ip }); + if (rule.port) chips.push({ label: 'Port', value: rule.port }); + if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP }); + if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort }); + if (rule.network) chips.push({ label: 'L4', value: rule.network }); + if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol }); + if (rule.user) chips.push({ label: 'User', value: rule.user }); + if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute }); + return chips; +} + +function chipPreview(value) { + const parts = csv(value); + if (parts.length === 0) return ''; + if (parts.length === 1) return parts[0]; + return `${parts[0]} +${parts.length - 1}`; +}