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}`; +}