mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-08 14:36:13 +00:00
* ws/inbounds: realtime fixes + perf for 10k+ client inbounds - hub: dedup, throttle, panic-restart, deadlock fix, race tests - client: backoff cap + slow-retry instead of giving up - broadcast: delta-only payload, count-based invalidate fallback - filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound) - perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint - traffic: monotonic all_time + UI clamp + propagate in delta handler - session: persist on update/logout (fixes logout-after-password-change) - ui: protocol tags flex, traffic bar normalize * Remove hub_test.go file * fix: ws hub, inbound service, and frontend correctness - propagate DelInbound error on disable path in SetInboundEnable - skip empty emails in updateClientTraffics to avoid constraint violations - use consistent IN ? clause, drop redundant ErrRecordNotFound guards - Hub.Unregister: direct removeClient fallback when channel is full - applyClientStatsDelta: O(1) email lookup via per-inbound Map cache - WS payload size check: Blob.size instead of .length for real byte count * fix: chunk large IN ? queries and fix IPv6 same-origin check * fix: chunk large IN ? queries and fix IPv6 same-origin check * fix: unify clientStats cache, throttle clarity, hub constants * fix(ui): align traffic/expiry cell columns across all rows * style(ui): redesign outbounds table for visual consistency * style(ui): redesign routing table for visual consistency * fix: * fix: * fix: * fix: * fix: * fix: font * refactor: simplify outbound tone functions for consistency and maintainability --------- Co-authored-by: lolka1333 <test123@gmail.com>
303 lines
12 KiB
HTML
303 lines
12 KiB
HTML
{{define "component/sortableTableTrigger"}}
|
|
<a-icon type="drag" class="sortable-icon"
|
|
role="button" tabindex="0"
|
|
:aria-label="ariaLabel"
|
|
@pointerdown="onPointerDown"
|
|
@keydown="onKeyDown" />
|
|
{{end}}
|
|
|
|
{{define "component/aTableSortable"}}
|
|
<script>
|
|
/**
|
|
* 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 {
|
|
// null when idle. While dragging:
|
|
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
|
drag: null,
|
|
};
|
|
},
|
|
props: {
|
|
'data-source': { type: undefined, required: false },
|
|
'customRow': { type: undefined, required: false },
|
|
'row-key': { type: undefined, required: false },
|
|
},
|
|
inheritAttrs: false,
|
|
provide() {
|
|
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.startDrag,
|
|
});
|
|
Object.defineProperty(sortable, 'moveByKeyboard', {
|
|
enumerable: true,
|
|
get: () => this.moveByKeyboard,
|
|
});
|
|
return { sortable };
|
|
},
|
|
beforeDestroy() {
|
|
this.detachPointerListeners();
|
|
},
|
|
methods: {
|
|
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;
|
|
},
|
|
startDrag(e, sourceIndex) {
|
|
// Primary button only (mouse left / first touch).
|
|
if (e.button != null && e.button !== 0) return;
|
|
e.preventDefault();
|
|
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 (_) {}
|
|
}
|
|
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 {
|
|
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 });
|
|
}
|
|
},
|
|
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 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 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 },
|
|
},
|
|
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: {
|
|
onPointerDown(e) {
|
|
if (this.sortable && this.sortable.startDrag) {
|
|
this.sortable.startDrag(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);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* 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;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
.sortable-table-dragging .sortable-source-row .routing-index,
|
|
.sortable-table-dragging .sortable-source-row .outbound-index {
|
|
opacity: 0.45;
|
|
}
|
|
.sortable-table-dragging .sortable-row > td {
|
|
transition: background-color 0.18s ease;
|
|
}
|
|
/* 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}}
|