style(ui): redesign routing table for visual consistency

This commit is contained in:
lolka1333 2026-05-05 08:15:37 +02:00
parent 6166a406b3
commit 973d27cb10
3 changed files with 623 additions and 327 deletions

View file

@ -1,237 +1,302 @@
{{define "component/sortableTableTrigger"}}
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" @mousedown="mouseDownHandler"
@click="clickHandler" />
<a-icon type="drag" class="sortable-icon"
role="button" tabindex="0"
:aria-label="ariaLabel"
@pointerdown="onPointerDown"
@keydown="onKeyDown" />
{{end}}
{{define "component/aTableSortable"}}
<script>
const DRAGGABLE_ROW_CLASS = 'draggable-row';
const findParentRowElement = (el) => {
if (!el || !el.tagName) {
return null;
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) {
return el;
} else if (el.parentNode) {
return findParentRowElement(el.parentNode);
} else {
return null;
}
}
/**
* Sortable a-table — drag-to-reorder rows using Pointer Events.
*
* Why a rewrite:
* - Old impl set `draggable: true` on every row, which (a) broke text
* selection inside cells, (b) let HTML5 start a drag from anywhere on
* the row even when the state machine wasn't primed, producing
* "phantom drags" that didn't reorder anything.
* - HTML5 drag has no touch support on most mobile browsers and no
* keyboard fallback at all.
* - The drag-image hack cloned the entire table — slow on big lists.
*
* New design:
* - Only the explicit drag handle initiates a drag, via Pointer Events
* (one API for mouse + touch + pen). Rows are not draggable.
* - During drag, `data-source` is reordered live: the source row visually
* slides into the target slot and other rows shift around it. The live
* reorder IS the visual feedback — no separate floating preview.
* - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
* and signature as before, so existing call sites stay unchanged.
* - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
* the row by one; Escape cancels a pointer-drag in progress.
*/
const ROW_CLASS = 'sortable-row';
Vue.component('a-table-sortable', {
data() {
return {
sortingElementIndex: null,
newElementIndex: null,
// null when idle. While dragging:
// { sourceIndex, targetIndex, pointerId, sourceKey }
drag: null,
};
},
props: {
'data-source': {
type: undefined,
required: false,
},
'customRow': {
type: undefined,
required: false,
}
'data-source': { type: undefined, required: false },
'customRow': { type: undefined, required: false },
'row-key': { type: undefined, required: false },
},
inheritAttrs: false,
provide() {
const sortable = {}
Object.defineProperty(sortable, "setSortableIndex", {
const sortable = {};
// Methods exposed to the trigger child via inject. Defined as getters
// so `this` binds to the component instance, not the plain object.
Object.defineProperty(sortable, 'startDrag', {
enumerable: true,
get: () => this.setCurrentSortableIndex,
get: () => this.startDrag,
});
Object.defineProperty(sortable, "resetSortableIndex", {
Object.defineProperty(sortable, 'moveByKeyboard', {
enumerable: true,
get: () => this.resetSortableIndex,
get: () => this.moveByKeyboard,
});
return {
sortable,
}
return { sortable };
},
render: function (createElement) {
return createElement('a-table', {
class: {
'ant-table-is-sorting': this.isDragging(),
},
props: {
...this.$attrs,
'data-source': this.records,
customRow: (record, index) => this.customRowRender(record, index),
},
on: this.$listeners,
nativeOn: {
drop: (e) => this.dropHandler(e),
},
scopedSlots: this.$scopedSlots,
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`
}
}, this.$slots.default,)
},
created() {
this.$memoSort = {};
beforeDestroy() {
this.detachPointerListeners();
},
methods: {
isDragging() {
const currentIndex = this.sortingElementIndex;
return currentIndex !== null && currentIndex !== undefined;
isDragging() { return this.drag !== null; },
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
keyOf(record, fallback) {
const rk = this.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record && record[rk];
return fallback;
},
resetSortableIndex(e, index) {
this.sortingElementIndex = null;
this.newElementIndex = null;
this.$memoSort = {};
},
setCurrentSortableIndex(e, index) {
this.sortingElementIndex = index;
},
dragStartHandler(e, index) {
if (!this.isDragging()) {
e.preventDefault();
return;
}
const hideDragImage = this.$el.cloneNode(true);
hideDragImage.id = "hideDragImage-hide";
hideDragImage.style.opacity = 0;
e.dataTransfer.setDragImage(hideDragImage, 0, 0);
},
dragStopHandler(e, index) {
const hideDragImage = document.getElementById('hideDragImage-hide');
if (hideDragImage) hideDragImage.remove();
this.resetSortableIndex(e, index);
},
dragOverHandler(e, index) {
if (!this.isDragging()) {
return;
}
startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const currentIndex = this.sortingElementIndex;
if (index === currentIndex) {
this.newElementIndex = null;
return;
const record = this.dataSource && this.dataSource[sourceIndex];
this.drag = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: this.keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor leaves
// the icon. Try/catch because some older browsers throw on capture.
if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
}
const row = findParentRowElement(e.target);
if (!row) {
return;
}
const rect = row.getBoundingClientRect();
const offsetTop = e.pageY - rect.top;
if (offsetTop < rect.height / 2) {
this.newElementIndex = Math.max(index - 1, 0);
this.attachPointerListeners();
},
attachPointerListeners() {
this._onMove = (ev) => this.onPointerMove(ev);
this._onUp = (ev) => this.onPointerUp(ev);
this._onCancel = (ev) => this.cancelDrag(ev);
document.addEventListener('pointermove', this._onMove, true);
document.addEventListener('pointerup', this._onUp, true);
document.addEventListener('pointercancel', this._onCancel, true);
document.addEventListener('keydown', this._onCancel, true);
},
detachPointerListeners() {
if (!this._onMove) return;
document.removeEventListener('pointermove', this._onMove, true);
document.removeEventListener('pointerup', this._onUp, true);
document.removeEventListener('pointercancel', this._onCancel, true);
document.removeEventListener('keydown', this._onCancel, true);
this._onMove = this._onUp = this._onCancel = null;
},
onPointerMove(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
// Hit-test: find which row the pointer Y is inside (or closest to).
const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
if (!rows.length) return;
const y = e.clientY;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
let target = this.drag.targetIndex;
if (y < firstRect.top) {
target = 0;
} else if (y > lastRect.bottom) {
target = rows.length - 1;
} else {
this.newElementIndex = index;
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
target = i;
break;
}
}
}
if (target !== this.drag.targetIndex) {
this.drag = Object.assign({}, this.drag, { targetIndex: target });
}
},
dropHandler(e) {
if (this.isDragging()) {
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
onPointerUp(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
this.commitDrag();
},
commitDrag() {
const d = this.drag;
this.detachPointerListeners();
this.drag = null;
if (d && d.sourceIndex !== d.targetIndex) {
this.$emit('onsort', d.sourceIndex, d.targetIndex);
}
},
cancelDrag(e) {
// Triggered by pointercancel and keydown handlers. For keydown, only
// act on Escape; otherwise let the event flow to other listeners.
if (e && e.type === 'keydown' && e.key !== 'Escape') return;
this.detachPointerListeners();
this.drag = null;
},
// Keyboard reorder: commit immediately by emitting onsort. No "preview"
// state needed since the move is one row up or down.
moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (this.dataSource || []).length) return;
this.$emit('onsort', sourceIndex, target);
},
customRowRender(record, index) {
const parentMethodResult = this.customRow?.(record, index) || {};
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
return {
...parentMethodResult,
attrs: {
...(parentMethodResult?.attrs || {}),
draggable: true,
},
on: {
...(parentMethodResult?.on || {}),
dragstart: (e) => this.dragStartHandler(e, index),
dragend: (e) => this.dragStopHandler(e, index),
dragover: (e) => this.dragOverHandler(e, index),
},
class: {
...(parentMethodResult?.class || {}),
[DRAGGABLE_ROW_CLASS]: true,
['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) : false,
},
};
}
const parent = (typeof this.customRow === 'function')
? (this.customRow(record, index) || {})
: {};
const d = this.drag;
const isSource = d && this.keyOf(record, index) === d.sourceKey;
return Object.assign({}, parent, {
// CRITICAL: no `draggable: true`. Drag is initiated only by the
// handle icon. Leaves text-selection on cells working normally.
attrs: Object.assign({}, parent.attrs || {}),
class: Object.assign({}, parent.class || {}, {
[ROW_CLASS]: true,
'sortable-source-row': !!isSource,
}),
});
},
},
computed: {
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle or when target equals source, returns the original list
// unchanged so Ant Design's table treats this as a stable reference.
records() {
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
return this.dataSource;
}
if (this.$memoSort.newIndex === newIndex) {
return this.$memoSort.list;
}
let list = [...this.dataSource];
list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]);
this.$memoSort = {
newIndex,
list,
};
const d = this.drag;
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
const list = (this.dataSource || []).slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
}
}
},
},
render(h) {
return h('a-table', {
class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
props: Object.assign({}, this.$attrs, {
'data-source': this.records,
'row-key': this.rowKey,
customRow: (record, index) => this.customRowRender(record, index),
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`,
},
}),
on: this.$listeners,
scopedSlots: this.$scopedSlots,
}, this.$slots.default);
},
});
Vue.component('a-table-sort-trigger', {
template: `{{template "component/sortableTableTrigger"}}`,
props: {
'item-index': {
type: undefined,
required: false
}
'item-index': { type: undefined, required: false },
},
inject: ['sortable'],
computed: {
ariaLabel() {
// Localised label is overkill for an internal a11y string; English is
// fine here and matches screen-reader expectations across locales.
return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
},
},
methods: {
mouseDownHandler(e) {
if (this.sortable) {
this.sortable.setSortableIndex(e, this.itemIndex);
onPointerDown(e) {
if (this.sortable && this.sortable.startDrag) {
this.sortable.startDrag(e, this.itemIndex);
}
},
mouseUpHandler(e) {
if (this.sortable) {
this.sortable.resetSortableIndex(e, this.itemIndex);
onKeyDown(e) {
if (!this.sortable || !this.sortable.moveByKeyboard) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
this.sortable.moveByKeyboard(-1, this.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.sortable.moveByKeyboard(+1, this.itemIndex);
}
},
clickHandler(e) {
e.preventDefault();
},
}
})
},
});
</script>
<style>
@media only screen and (max-width: 767px) {
.sortable-icon {
display: none;
}
/* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
`touch-action: none` is critical: it tells the browser not to interpret
touch on the icon as a scroll/zoom gesture, so pointermove fires for
drag-tracking. Without it, mobile browsers eat the pointer events. */
.sortable-icon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
padding: 6px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
transition: background-color 0.15s ease, color 0.15s ease;
user-select: none;
touch-action: none;
}
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active { cursor: grabbing; }
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
}
.ant-table-is-sorting .draggable-row td {
background-color: #ffffff !important;
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.dark .ant-table-is-sorting .draggable-row td {
background-color: var(--dark-color-surface-100) !important;
/* While dragging: the source row gets a soft green wash so the user can
track which row is being moved. Other rows transition smoothly as the
data-source is reordered. */
.sortable-table-dragging .sortable-source-row > td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging td {
background-color: rgb(232 244 242) !important;
color: rgba(0, 0, 0, 0.3);
.sortable-table-dragging .sortable-source-row .routing-index,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.dark .ant-table-is-sorting .dragging td {
background-color: var(--dark-color-table-hover) !important;
color: rgba(255, 255, 255, 0.3);
.sortable-table-dragging .sortable-row > td {
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging {
opacity: 1;
box-shadow: 1px -2px 2px #008771;
transition: all 0.2s;
}
.ant-table-is-sorting .dragging .ant-table-row-index {
opacity: 0.3;
/* Disable text selection across the whole table while a drag is in
progress — selection during drag is never useful and looks broken. */
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;
}
</style>
{{end}}
{{end}}

View file

@ -1,123 +1,193 @@
{{define "settings/xray/routing"}}
<a-space direction="vertical" size="middle">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key"
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0"
<a-space direction="vertical" size="middle" class="routing-modern">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n
"pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns"
:row-key="r => r.key"
:data-source="routingRuleData"
:scroll="{}"
:pagination="false"
:indent-size="0"
class="routing-table"
v-on:onSort="replaceRule">
<template slot="action" slot-scope="text, rule, index">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="ant-table-row-index"> [[ index+1 ]] </span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
<a-icon type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first"}}
</a-menu-item>
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
<a-icon type="arrow-up"></a-icon>
{{ i18n "pages.xray.rules.up"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
<a-icon type="arrow-down"></a-icon>
{{ i18n "pages.xray.rules.down"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1"
@click="replaceRule(index,routingRuleData.length-1)">
<a-icon type="vertical-align-bottom"></a-icon>
{{ i18n "pages.xray.rules.last"}}
</a-menu-item>
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
<div class="routing-action-cell">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="routing-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="routing-action-btn"
@click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete" }}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<template slot="inbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p>
<p v-if="rule.user">User email: [[ rule.user ]]</p>
</template>
[[ [rule.inboundTag,rule.user].join('\n') ]]
</a-popover>
<template slot="source" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.sourceIP"
:title="'Source IP: ' + joinCsv(rule.sourceIP)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.sourceIP)[0] ]]</span>
<span v-if="csv(rule.sourceIP).length > 1" class="criterion-more">+[[ csv(rule.sourceIP).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.sourcePort"
:title="'Source Port: ' + joinCsv(rule.sourcePort)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.sourcePort)[0] ]]</span>
<span v-if="csv(rule.sourcePort).length > 1" class="criterion-more">+[[ csv(rule.sourcePort).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.vlessRoute"
:title="'VLESS Route: ' + joinCsv(rule.vlessRoute)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">VLESS</span>
<span class="criterion-value">[[ csv(rule.vlessRoute)[0] ]]</span>
<span v-if="csv(rule.vlessRoute).length > 1" class="criterion-more">+[[ csv(rule.vlessRoute).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.sourceIP && !rule.sourcePort && !rule.vlessRoute">—</span>
</div>
</template>
<template slot="outbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p>
</template>
[[ rule.outboundTag ]]
</a-popover>
<template slot="network" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.network"
:title="'L4: ' + joinCsv(rule.network)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">L4</span>
<span class="criterion-value">[[ csv(rule.network)[0] ]]</span>
<span v-if="csv(rule.network).length > 1" class="criterion-more">+[[ csv(rule.network).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.protocol"
:title="'Protocol: ' + joinCsv(rule.protocol)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Protocol</span>
<span class="criterion-value">[[ csv(rule.protocol)[0] ]]</span>
<span v-if="csv(rule.protocol).length > 1" class="criterion-more">+[[ csv(rule.protocol).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.attrs"
:title="'Attrs: ' + joinCsv(rule.attrs)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Attrs</span>
<span class="criterion-value">[[ csv(rule.attrs)[0] ]]</span>
<span v-if="csv(rule.attrs).length > 1" class="criterion-more">+[[ csv(rule.attrs).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.network && !rule.protocol && !rule.attrs">—</span>
</div>
</template>
<template slot="balancer" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p>
</template>
[[ rule.balancerTag ]]
</a-popover>
<template slot="destination" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.ip"
:title="'Destination IP: ' + joinCsv(rule.ip)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.ip)[0] ]]</span>
<span v-if="csv(rule.ip).length > 1" class="criterion-more">+[[ csv(rule.ip).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.domain"
:title="'Domain: ' + joinCsv(rule.domain)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Domain</span>
<span class="criterion-value">[[ csv(rule.domain)[0] ]]</span>
<span v-if="csv(rule.domain).length > 1" class="criterion-more">+[[ csv(rule.domain).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.port"
:title="'Destination Port: ' + joinCsv(rule.port)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.port)[0] ]]</span>
<span v-if="csv(rule.port).length > 1" class="criterion-more">+[[ csv(rule.port).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.ip && !rule.domain && !rule.port">—</span>
</div>
</template>
<template slot="info" slot-scope="text, rule, index">
<a-popover placement="bottomRight"
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2" :style="{ maxWidth: '300px' }">
<tr v-if="rule.sourceIP">
<td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.sourcePort">
<td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.vlessRoute">
<td>VLESS Route</td>
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.network">
<td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.protocol">
<td>Protocol</td>
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.attrs">
<td>Attrs</td>
<td><a-tag color="blue" v-for="r in rule.attrs.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.ip">
<td>IP</td>
<td><a-tag color="green" v-for="r in rule.ip.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.domain">
<td>Domain</td>
<td><a-tag color="blue" v-for="r in rule.domain.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.port">
<td>Port</td>
<td><a-tag color="green" v-for="r in rule.port.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.balancerTag">
<td>Balancer Tag</td>
<td><a-tag color="blue">[[ rule.balancerTag ]]</a-tag></td>
</tr>
</table>
</template>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-popover>
<template slot="inbound" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.inboundTag"
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Tag</span>
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.user"
:title="'Client: ' + joinCsv(rule.user)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">User</span>
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.inboundTag && !rule.user">—</span>
</div>
</template>
<template slot="target" slot-scope="text, rule">
<div class="routing-target-cell">
<div class="routing-target-row" v-if="rule.outboundTag">
<a-icon type="export" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-emerald">[[ rule.outboundTag ]]</span>
</div>
<div class="routing-target-row" v-if="rule.balancerTag">
<a-icon type="cluster" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-violet">[[ rule.balancerTag ]]</span>
</div>
<span class="routing-criteria-empty"
v-if="!rule.outboundTag && !rule.balancerTag">—</span>
</div>
</template>
</a-table-sortable>
</a-space>
{{end}}
{{end}}

View file

@ -165,40 +165,32 @@
{{template "modals/warpModal"}}
{{template "modals/nordModal"}}
<script>
// Modernised rules layout — 6 cells (#, source, network, destination,
// inbound, target). Each criterion renders as a single self-labelled
// pill that shows the first value plus a "+N" remainder badge for the
// rest; the full list is surfaced via tooltip on hover. The destination
// column has no fixed width and absorbs leftover horizontal space so the
// table fits typical viewports without a horizontal scrollbar.
const rulesColumns = [
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
{
title: '{{ i18n "pages.xray.rules.source"}}', children: [
{ title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true },
{ title: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
},
{
title: '{{ i18n "pages.inbounds.network"}}', children: [
{ title: 'L4', dataIndex: 'network', align: 'center', width: 10 },
{ title: '{{ i18n "protocol" }}', dataIndex: 'protocol', align: 'center', width: 15, ellipsis: true },
{ title: 'Attrs', dataIndex: 'attrs', align: 'center', width: 10, ellipsis: true }]
},
{
title: '{{ i18n "pages.xray.rules.dest"}}', children: [
{ title: 'IP', dataIndex: 'ip', align: 'center', width: 20, ellipsis: true },
{ title: '{{ i18n "pages.xray.outbound.domain" }}', dataIndex: 'domain', align: 'center', width: 20, ellipsis: true },
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'port', align: 'center', width: 10, ellipsis: true }]
},
{
title: '{{ i18n "pages.xray.rules.inbound"}}', children: [
{ title: '{{ i18n "pages.xray.outbound.tag" }}', dataIndex: 'inboundTag', align: 'center', width: 15, ellipsis: true },
{ title: '{{ i18n "pages.inbounds.client" }}', dataIndex: 'user', align: 'center', width: 20, ellipsis: true }]
},
{ title: '{{ i18n "pages.xray.rules.outbound"}}', dataIndex: 'outboundTag', align: 'center', width: 17 },
{ title: '{{ i18n "pages.xray.rules.balancer"}}', dataIndex: 'balancerTag', align: 'center', width: 15 },
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.source"}}', align: 'left', width: 180, scopedSlots: { customRender: 'source' } },
{ title: '{{ i18n "pages.inbounds.network"}}', align: 'left', width: 180, scopedSlots: { customRender: 'network' } },
{ title: '{{ i18n "pages.xray.rules.dest"}}', align: 'left', scopedSlots: { customRender: 'destination' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', width: 180, scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 170, scopedSlots: { customRender: 'target' } },
];
// Mobile: 3-column table — #, Inbound, Outbound. Source / Network /
// Destination criteria are dropped to keep the table readable on
// narrow viewports. Users see the rule's identity (Inbound) and
// what it does (Outbound) at a glance; full criteria are accessible
// by tapping Edit in the actions menu.
// # column is wider than desktop (110 vs 70) to fit the touch-friendly
// drag handle (padding: 6px → ~28px) alongside the index and dropdown.
const rulesMobileColumns = [
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'outbound' } },
{ title: '{{ i18n "pages.xray.rules.info"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'info' } },
{ title: '#', align: 'center', width: 110, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 140, scopedSlots: { customRender: 'target' } },
];
const outboundColumns = [
@ -633,6 +625,24 @@
isOutboundUntestable(outbound) {
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
},
// csv splits a comma-separated rule field into trimmed non-empty values.
// Routing rule data uses CSV strings for multi-value criteria (e.g.
// sourceIP "1.2.3.0/24,4.5.6.0/24"); the modern table renders each
// criterion as a single summary pill, so values are normally re-joined
// via joinCsv() but this helper is kept for callers that need an array.
csv(value) {
if (!value) return [];
return String(value)
.split(',')
.map(v => v.trim())
.filter(v => v.length > 0);
},
// joinCsv normalises a CSV-style rule field into a single comma-space
// separated string suitable for tooltips. Returns '' for empty inputs
// so v-if guards can short-circuit on the raw rule field.
joinCsv(value) {
return this.csv(value).join(', ');
},
findOutboundAddress(o) {
serverObj = null;
switch (o.protocol) {
@ -1860,12 +1870,12 @@
.xray-page .outbound-pill {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 9px;
min-height: 22px;
padding: 2px 9px;
border-radius: 11px;
font-size: 12px;
font-weight: 500;
line-height: 1;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
white-space: nowrap;
@ -1965,5 +1975,156 @@
box-shadow: none;
opacity: 0.45;
}
/* ───────── Modern routing-rules table ─────────
Reuses the .outbound-pill tonal primitive (identical visual) so the
routing tab feels like the same panel as outbounds. Each cell groups
a routing criterion (Source / Network / Destination / Inbound) and
shows its values as labelled pills. */
.xray-page .routing-modern { width: 100%; }
.xray-page .routing-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: top;
}
.light .xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .routing-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Sort handle / # / actions */
.xray-page .routing-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .routing-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .routing-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .routing-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .routing-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .routing-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Plain-text criterion rows — replaces pill primitives in condition
columns. Each criterion is a row of "label value (+N)" with form-label
styling on the label. No bg, no border, no color tones — keeps cells
light and lets the column header carry the type semantic. The cell's
visual weight is now proportional only to the data length, not to
decoration. The single colored pill in Outbound/Balancer remains as
the row's focal point. */
.xray-page .criterion-flow {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xray-page .criterion-row {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 13px;
line-height: 1.5;
}
.xray-page .criterion-label {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.light .xray-page .criterion-label { color: rgba(0, 0, 0, 0.45); }
.xray-page .criterion-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.85);
}
.light .xray-page .criterion-value { color: rgba(0, 0, 0, 0.85); }
.xray-page .criterion-more {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 500;
}
.light .xray-page .criterion-more { color: rgba(0, 0, 0, 0.45); }
.xray-page .routing-criteria-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
.light .xray-page .routing-criteria-empty { color: rgba(0, 0, 0, 0.3); }
/* Target cell (outbound / balancer) — vertically stacked rows of icon + pill */
.xray-page .routing-target-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.xray-page .routing-target-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-target-icon {
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
</style>
{{ template "page/body_end" .}}