mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +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"}}
|
||||
<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}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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" .}}
|
||||
Loading…
Reference in a new issue