feat(routing): drag-reorder rules, split balancer column, mobile card layout
- 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.
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
{{ t('pages.xray.Routings') }}
|
||||
</a-button>
|
||||
|
||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
||||
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
||||
phone (~520px of columns alone), so render each rule as a
|
||||
compact card with the routing summary + criteria chips. -->
|
||||
<div v-if="isMobile" class="rule-list">
|
||||
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
|
||||
'row-dragging': draggedIndex === index,
|
||||
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
|
||||
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
|
||||
}" :data-row-key="index">
|
||||
<div class="rule-card-head">
|
||||
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
|
||||
<span class="rule-number">#{{ index + 1 }}</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<MoreOutlined />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="rule-flow">
|
||||
<div class="flow-side">
|
||||
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
|
||||
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
|
||||
{{ chipPreview(rule.inboundTag) }}
|
||||
</a-tag>
|
||||
<span v-else class="criterion-empty">any</span>
|
||||
</div>
|
||||
<span class="flow-arrow">→</span>
|
||||
<div class="flow-side flow-side-target">
|
||||
<span class="flow-label">{{
|
||||
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
|
||||
}}</span>
|
||||
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
|
||||
<ExportOutlined /> {{ rule.outboundTag }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
|
||||
<ClusterOutlined /> {{ rule.balancerTag }}
|
||||
</a-tag>
|
||||
<span v-else class="criterion-empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
|
||||
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
|
||||
<span class="criterion-chip">
|
||||
<span class="criterion-chip-label">{{ chip.label }}</span>
|
||||
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!rows.length" class="rule-empty">—</div>
|
||||
</div>
|
||||
|
||||
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<!-- ============== # / actions ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-cell">
|
||||
<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">
|
||||
@@ -333,18 +477,25 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Outbound / balancer target ============== -->
|
||||
<template v-else-if="column.key === 'target'">
|
||||
<!-- ============== Outbound ============== -->
|
||||
<template v-else-if="column.key === 'outbound'">
|
||||
<div class="target-cell">
|
||||
<div v-if="record.outboundTag" class="target-row">
|
||||
<ExportOutlined class="target-icon" />
|
||||
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
||||
</div>
|
||||
<span v-else class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Balancer ============== -->
|
||||
<template v-else-if="column.key === 'balancer'">
|
||||
<div class="target-cell">
|
||||
<div v-if="record.balancerTag" class="target-row">
|
||||
<ClusterOutlined class="target-icon" />
|
||||
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
||||
</div>
|
||||
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
||||
<span v-else class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.35;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
touch-action: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
:deep(.row-dragging) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
:deep(.drop-before > td) {
|
||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||
}
|
||||
|
||||
:deep(.drop-after > td) {
|
||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||
}
|
||||
|
||||
.row-index {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
@@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* === Mobile card list ====================================== */
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.rule-card.row-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.rule-card.drop-before {
|
||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||
}
|
||||
|
||||
.rule-card.drop-after {
|
||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||
}
|
||||
|
||||
.rule-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-number {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rule-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flow-side {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.flow-side-target {
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flow-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.flow-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
font-size: 16px;
|
||||
opacity: 0.45;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-criteria {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.criterion-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.criterion-chip-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.criterion-chip-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
:global(body.dark) .rule-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:global(body.dark) .criterion-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user