mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 22:24:15 +00:00
style(ui): redesign routing table for visual consistency
This commit is contained in:
parent
6166a406b3
commit
973d27cb10
3 changed files with 623 additions and 327 deletions
|
|
@ -1,237 +1,302 @@
|
||||||
{{define "component/sortableTableTrigger"}}
|
{{define "component/sortableTableTrigger"}}
|
||||||
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" @mousedown="mouseDownHandler"
|
<a-icon type="drag" class="sortable-icon"
|
||||||
@click="clickHandler" />
|
role="button" tabindex="0"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@keydown="onKeyDown" />
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "component/aTableSortable"}}
|
{{define "component/aTableSortable"}}
|
||||||
<script>
|
<script>
|
||||||
const DRAGGABLE_ROW_CLASS = 'draggable-row';
|
/**
|
||||||
const findParentRowElement = (el) => {
|
* Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||||
if (!el || !el.tagName) {
|
*
|
||||||
return null;
|
* Why a rewrite:
|
||||||
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) {
|
* - Old impl set `draggable: true` on every row, which (a) broke text
|
||||||
return el;
|
* selection inside cells, (b) let HTML5 start a drag from anywhere on
|
||||||
} else if (el.parentNode) {
|
* the row even when the state machine wasn't primed, producing
|
||||||
return findParentRowElement(el.parentNode);
|
* "phantom drags" that didn't reorder anything.
|
||||||
} else {
|
* - HTML5 drag has no touch support on most mobile browsers and no
|
||||||
return null;
|
* 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', {
|
Vue.component('a-table-sortable', {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sortingElementIndex: null,
|
// null when idle. While dragging:
|
||||||
newElementIndex: null,
|
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||||
|
drag: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
'data-source': {
|
'data-source': { type: undefined, required: false },
|
||||||
type: undefined,
|
'customRow': { type: undefined, required: false },
|
||||||
required: false,
|
'row-key': { type: undefined, required: false },
|
||||||
},
|
|
||||||
'customRow': {
|
|
||||||
type: undefined,
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
provide() {
|
provide() {
|
||||||
const sortable = {}
|
const sortable = {};
|
||||||
Object.defineProperty(sortable, "setSortableIndex", {
|
// 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,
|
enumerable: true,
|
||||||
get: () => this.setCurrentSortableIndex,
|
get: () => this.startDrag,
|
||||||
});
|
});
|
||||||
Object.defineProperty(sortable, "resetSortableIndex", {
|
Object.defineProperty(sortable, 'moveByKeyboard', {
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
get: () => this.resetSortableIndex,
|
get: () => this.moveByKeyboard,
|
||||||
});
|
});
|
||||||
return {
|
return { sortable };
|
||||||
sortable,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
render: function (createElement) {
|
beforeDestroy() {
|
||||||
return createElement('a-table', {
|
this.detachPointerListeners();
|
||||||
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 = {};
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isDragging() {
|
isDragging() { return this.drag !== null; },
|
||||||
const currentIndex = this.sortingElementIndex;
|
// Resolve the row key for a record. Used to identify the source row
|
||||||
return currentIndex !== null && currentIndex !== undefined;
|
// 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) {
|
startDrag(e, sourceIndex) {
|
||||||
this.sortingElementIndex = null;
|
// Primary button only (mouse left / first touch).
|
||||||
this.newElementIndex = null;
|
if (e.button != null && e.button !== 0) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const currentIndex = this.sortingElementIndex;
|
const record = this.dataSource && this.dataSource[sourceIndex];
|
||||||
if (index === currentIndex) {
|
this.drag = {
|
||||||
this.newElementIndex = null;
|
sourceIndex,
|
||||||
return;
|
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);
|
this.attachPointerListeners();
|
||||||
if (!row) {
|
},
|
||||||
return;
|
attachPointerListeners() {
|
||||||
}
|
this._onMove = (ev) => this.onPointerMove(ev);
|
||||||
const rect = row.getBoundingClientRect();
|
this._onUp = (ev) => this.onPointerUp(ev);
|
||||||
const offsetTop = e.pageY - rect.top;
|
this._onCancel = (ev) => this.cancelDrag(ev);
|
||||||
if (offsetTop < rect.height / 2) {
|
document.addEventListener('pointermove', this._onMove, true);
|
||||||
this.newElementIndex = Math.max(index - 1, 0);
|
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 {
|
} 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) {
|
onPointerUp(e) {
|
||||||
if (this.isDragging()) {
|
if (!this.drag) return;
|
||||||
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
|
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) {
|
customRowRender(record, index) {
|
||||||
const parentMethodResult = this.customRow?.(record, index) || {};
|
const parent = (typeof this.customRow === 'function')
|
||||||
const newIndex = this.newElementIndex;
|
? (this.customRow(record, index) || {})
|
||||||
const currentIndex = this.sortingElementIndex;
|
: {};
|
||||||
return {
|
const d = this.drag;
|
||||||
...parentMethodResult,
|
const isSource = d && this.keyOf(record, index) === d.sourceKey;
|
||||||
attrs: {
|
return Object.assign({}, parent, {
|
||||||
...(parentMethodResult?.attrs || {}),
|
// CRITICAL: no `draggable: true`. Drag is initiated only by the
|
||||||
draggable: true,
|
// handle icon. Leaves text-selection on cells working normally.
|
||||||
},
|
attrs: Object.assign({}, parent.attrs || {}),
|
||||||
on: {
|
class: Object.assign({}, parent.class || {}, {
|
||||||
...(parentMethodResult?.on || {}),
|
[ROW_CLASS]: true,
|
||||||
dragstart: (e) => this.dragStartHandler(e, index),
|
'sortable-source-row': !!isSource,
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
records() {
|
||||||
const newIndex = this.newElementIndex;
|
const d = this.drag;
|
||||||
const currentIndex = this.sortingElementIndex;
|
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
|
||||||
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
|
const list = (this.dataSource || []).slice();
|
||||||
return this.dataSource;
|
const [item] = list.splice(d.sourceIndex, 1);
|
||||||
}
|
list.splice(d.targetIndex, 0, item);
|
||||||
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,
|
|
||||||
};
|
|
||||||
return list;
|
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', {
|
Vue.component('a-table-sort-trigger', {
|
||||||
template: `{{template "component/sortableTableTrigger"}}`,
|
template: `{{template "component/sortableTableTrigger"}}`,
|
||||||
props: {
|
props: {
|
||||||
'item-index': {
|
'item-index': { type: undefined, required: false },
|
||||||
type: undefined,
|
|
||||||
required: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
inject: ['sortable'],
|
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: {
|
methods: {
|
||||||
mouseDownHandler(e) {
|
onPointerDown(e) {
|
||||||
if (this.sortable) {
|
if (this.sortable && this.sortable.startDrag) {
|
||||||
this.sortable.setSortableIndex(e, this.itemIndex);
|
this.sortable.startDrag(e, this.itemIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mouseUpHandler(e) {
|
onKeyDown(e) {
|
||||||
if (this.sortable) {
|
if (!this.sortable || !this.sortable.moveByKeyboard) return;
|
||||||
this.sortable.resetSortableIndex(e, this.itemIndex);
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 767px) {
|
/* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
|
||||||
.sortable-icon {
|
`touch-action: none` is critical: it tells the browser not to interpret
|
||||||
display: none;
|
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 {
|
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
|
||||||
background-color: #ffffff !important;
|
.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 {
|
/* While dragging: the source row gets a soft green wash so the user can
|
||||||
background-color: var(--dark-color-surface-100) !important;
|
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;
|
||||||
}
|
}
|
||||||
|
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||||
.ant-table-is-sorting .dragging td {
|
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||||
background-color: rgb(232 244 242) !important;
|
opacity: 0.45;
|
||||||
color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
.sortable-table-dragging .sortable-row > td {
|
||||||
.dark .ant-table-is-sorting .dragging td {
|
transition: background-color 0.18s ease;
|
||||||
background-color: var(--dark-color-table-hover) !important;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
/* Disable text selection across the whole table while a drag is in
|
||||||
.ant-table-is-sorting .dragging {
|
progress — selection during drag is never useful and looks broken. */
|
||||||
opacity: 1;
|
.sortable-table-dragging,
|
||||||
box-shadow: 1px -2px 2px #008771;
|
.sortable-table-dragging * {
|
||||||
transition: all 0.2s;
|
user-select: none;
|
||||||
}
|
|
||||||
|
|
||||||
.ant-table-is-sorting .dragging .ant-table-row-index {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,123 +1,193 @@
|
||||||
{{define "settings/xray/routing"}}
|
{{define "settings/xray/routing"}}
|
||||||
<a-space direction="vertical" size="middle">
|
<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-button type="primary" icon="plus" @click="addRule">{{ i18n
|
||||||
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key"
|
"pages.xray.rules.add" }}</a-button>
|
||||||
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0"
|
<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">
|
v-on:onSort="replaceRule">
|
||||||
<template slot="action" slot-scope="text, rule, index">
|
<template slot="action" slot-scope="text, rule, index">
|
||||||
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
|
<div class="routing-action-cell">
|
||||||
<span class="ant-table-row-index"> [[ index+1 ]] </span>
|
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
|
||||||
<a-dropdown :trigger="['click']">
|
<span class="routing-index">[[ index+1 ]]</span>
|
||||||
<a-icon @click="e => e.preventDefault()" type="more"
|
<a-dropdown :trigger="['click']">
|
||||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
<a-button shape="circle" size="small" class="routing-action-btn"
|
||||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
@click="e => e.preventDefault()">
|
||||||
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
|
<a-icon type="more"></a-icon>
|
||||||
<a-icon type="vertical-align-top"></a-icon>
|
</a-button>
|
||||||
{{ i18n "pages.xray.rules.first"}}
|
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||||
</a-menu-item>
|
<a-menu-item @click="editRule(index)">
|
||||||
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
|
<a-icon type="edit"></a-icon>
|
||||||
<a-icon type="arrow-up"></a-icon>
|
<span>{{ i18n "edit" }}</span>
|
||||||
{{ i18n "pages.xray.rules.up"}}
|
</a-menu-item>
|
||||||
</a-menu-item>
|
<a-menu-item @click="deleteRule(index)">
|
||||||
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
|
<span :style="{ color: '#FF4D4F' }">
|
||||||
<a-icon type="arrow-down"></a-icon>
|
<a-icon type="delete"></a-icon>
|
||||||
{{ i18n "pages.xray.rules.down"}}
|
<span>{{ i18n "delete" }}</span>
|
||||||
</a-menu-item>
|
</span>
|
||||||
<a-menu-item v-if="index<routingRuleData.length-1"
|
</a-menu-item>
|
||||||
@click="replaceRule(index,routingRuleData.length-1)">
|
</a-menu>
|
||||||
<a-icon type="vertical-align-bottom"></a-icon>
|
</a-dropdown>
|
||||||
{{ i18n "pages.xray.rules.last"}}
|
</div>
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
<template slot="inbound" slot-scope="text, rule, index">
|
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<template slot="source" slot-scope="text, rule">
|
||||||
<template slot="content">
|
<div class="criterion-flow">
|
||||||
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p>
|
<a-tooltip v-if="rule.sourceIP"
|
||||||
<p v-if="rule.user">User email: [[ rule.user ]]</p>
|
:title="'Source IP: ' + joinCsv(rule.sourceIP)"
|
||||||
</template>
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
[[ [rule.inboundTag,rule.user].join('\n') ]]
|
:trigger="['hover', 'click']">
|
||||||
</a-popover>
|
<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>
|
||||||
<template slot="outbound" slot-scope="text, rule, index">
|
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<template slot="network" slot-scope="text, rule">
|
||||||
<template slot="content">
|
<div class="criterion-flow">
|
||||||
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p>
|
<a-tooltip v-if="rule.network"
|
||||||
</template>
|
:title="'L4: ' + joinCsv(rule.network)"
|
||||||
[[ rule.outboundTag ]]
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
</a-popover>
|
: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>
|
||||||
<template slot="balancer" slot-scope="text, rule, index">
|
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<template slot="destination" slot-scope="text, rule">
|
||||||
<template slot="content">
|
<div class="criterion-flow">
|
||||||
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p>
|
<a-tooltip v-if="rule.ip"
|
||||||
</template>
|
:title="'Destination IP: ' + joinCsv(rule.ip)"
|
||||||
[[ rule.balancerTag ]]
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
</a-popover>
|
: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>
|
||||||
<template slot="info" slot-scope="text, rule, index">
|
|
||||||
<a-popover placement="bottomRight"
|
<template slot="inbound" slot-scope="text, rule">
|
||||||
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
<div class="criterion-flow">
|
||||||
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
<a-tooltip v-if="rule.inboundTag"
|
||||||
<template slot="content">
|
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
|
||||||
<table cellpadding="2" :style="{ maxWidth: '300px' }">
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
<tr v-if="rule.sourceIP">
|
:trigger="['hover', 'click']">
|
||||||
<td>Source IP</td>
|
<span class="criterion-row">
|
||||||
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
|
<span class="criterion-label">Tag</span>
|
||||||
</tr>
|
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
|
||||||
<tr v-if="rule.sourcePort">
|
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
|
||||||
<td>Source Port</td>
|
</span>
|
||||||
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
|
</a-tooltip>
|
||||||
</tr>
|
<a-tooltip v-if="rule.user"
|
||||||
<tr v-if="rule.vlessRoute">
|
:title="'Client: ' + joinCsv(rule.user)"
|
||||||
<td>VLESS Route</td>
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
|
:trigger="['hover', 'click']">
|
||||||
</tr>
|
<span class="criterion-row">
|
||||||
<tr v-if="rule.network">
|
<span class="criterion-label">User</span>
|
||||||
<td>Network</td>
|
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
|
||||||
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
|
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
|
||||||
</tr>
|
</span>
|
||||||
<tr v-if="rule.protocol">
|
</a-tooltip>
|
||||||
<td>Protocol</td>
|
<span class="routing-criteria-empty"
|
||||||
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
|
v-if="!rule.inboundTag && !rule.user">—</span>
|
||||||
</tr>
|
</div>
|
||||||
<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>
|
</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-table-sortable>
|
||||||
</a-space>
|
</a-space>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -165,40 +165,32 @@
|
||||||
{{template "modals/warpModal"}}
|
{{template "modals/warpModal"}}
|
||||||
{{template "modals/nordModal"}}
|
{{template "modals/nordModal"}}
|
||||||
<script>
|
<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 = [
|
const rulesColumns = [
|
||||||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
|
||||||
{
|
{ title: '{{ i18n "pages.xray.rules.source"}}', align: 'left', width: 180, scopedSlots: { customRender: 'source' } },
|
||||||
title: '{{ i18n "pages.xray.rules.source"}}', children: [
|
{ title: '{{ i18n "pages.inbounds.network"}}', align: 'left', width: 180, scopedSlots: { customRender: 'network' } },
|
||||||
{ title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
|
{ title: '{{ i18n "pages.xray.rules.dest"}}', align: 'left', scopedSlots: { customRender: 'destination' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true },
|
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', width: 180, scopedSlots: { customRender: 'inbound' } },
|
||||||
{ title: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
|
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 170, scopedSlots: { customRender: 'target' } },
|
||||||
},
|
|
||||||
{
|
|
||||||
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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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 = [
|
const rulesMobileColumns = [
|
||||||
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } },
|
{ title: '#', align: 'center', width: 110, scopedSlots: { customRender: 'action' } },
|
||||||
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'inbound' } },
|
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', scopedSlots: { customRender: 'inbound' } },
|
||||||
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'outbound' } },
|
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 140, scopedSlots: { customRender: 'target' } },
|
||||||
{ title: '{{ i18n "pages.xray.rules.info"}}', align: 'center', width: 50, ellipsis: true, scopedSlots: { customRender: 'info' } },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const outboundColumns = [
|
const outboundColumns = [
|
||||||
|
|
@ -633,6 +625,24 @@
|
||||||
isOutboundUntestable(outbound) {
|
isOutboundUntestable(outbound) {
|
||||||
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
|
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) {
|
findOutboundAddress(o) {
|
||||||
serverObj = null;
|
serverObj = null;
|
||||||
switch (o.protocol) {
|
switch (o.protocol) {
|
||||||
|
|
@ -1860,12 +1870,12 @@
|
||||||
.xray-page .outbound-pill {
|
.xray-page .outbound-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 22px;
|
min-height: 22px;
|
||||||
padding: 0 9px;
|
padding: 2px 9px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1.4;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -1965,5 +1975,156 @@
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
opacity: 0.45;
|
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>
|
</style>
|
||||||
{{ template "page/body_end" .}}
|
{{ template "page/body_end" .}}
|
||||||
Loading…
Reference in a new issue