Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorlolka1333 <xtrafcyz@gmail.com>2026-05-05 18:27:49 +0300
committerGitHub <noreply@github.com>2026-05-05 18:27:49 +0300
commit8177f6dc667f2edca66073e433baf5cff36cda41 (patch)
tree7fddbec4848c3c0ab0fc39e3dc45703c368e9340 /web/html/component/aTableSortable.html
parent77d94b25d054bd6cf7ace029571db9c58ae87fa9 (diff)
ws/inbounds: realtime fixes + perf for 10k+ client inbounds (#4123)HEADmain
* 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>
Diffstat (limited to 'web/html/component/aTableSortable.html')
-rw-r--r--web/html/component/aTableSortable.html424
1 files changed, 244 insertions, 180 deletions
diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html
index 925adbb5..fdbc247e 100644
--- a/web/html/component/aTableSortable.html
+++ b/web/html/component/aTableSortable.html
@@ -1,238 +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;
- },
- resetSortableIndex(e, index) {
- this.sortingElementIndex = null;
- this.newElementIndex = null;
- this.$memoSort = {};
- },
- setCurrentSortableIndex(e, index) {
- this.sortingElementIndex = index;
+ 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;
},
- dragStartHandler(e, index) {
- if (!this.isDragging()) {
- e.preventDefault();
- return;
+ 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 (_) {}
}
- const hideDragImage = this.$el.cloneNode(true);
- hideDragImage.id = "hideDragImage-hide";
- hideDragImage.style.opacity = 0;
- e.dataTransfer.setDragImage(hideDragImage, 0, 0);
+ this.attachPointerListeners();
},
- dragStopHandler(e, index) {
- const hideDragImage = document.getElementById('hideDragImage-hide');
- if (hideDragImage) hideDragImage.remove();
- this.resetSortableIndex(e, index);
+ 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);
},
- dragOverHandler(e, index) {
- if (!this.isDragging()) {
- return;
- }
- e.preventDefault();
- const currentIndex = this.sortingElementIndex;
- if (index === currentIndex) {
- this.newElementIndex = null;
- return;
- }
- 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);
+ 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;
}
-
- .ant-table-is-sorting .draggable-row td {
- background-color: #ffffff !important;
+ .sortable-icon:hover {
+ color: rgba(255, 255, 255, 0.85);
+ background: rgba(255, 255, 255, 0.06);
}
-
- .dark .ant-table-is-sorting .draggable-row td {
- background-color: var(--dark-color-surface-100) !important;
+ .sortable-icon:active { cursor: grabbing; }
+ .sortable-icon:focus-visible {
+ outline: 2px solid #008771;
+ outline-offset: 2px;
}
- .ant-table-is-sorting .dragging td {
- background-color: rgb(232 244 242) !important;
- color: rgba(0, 0, 0, 0.3);
+ .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 .dragging td {
- background-color: var(--dark-color-table-hover) !important;
- color: rgba(255, 255, 255, 0.3);
+ /* 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 {
- opacity: 1;
- box-shadow: 1px -2px 2px #008771;
- transition: all 0.2s;
+ .sortable-table-dragging .sortable-source-row .routing-index,
+ .sortable-table-dragging .sortable-source-row .outbound-index {
+ opacity: 0.45;
}
-
- .ant-table-is-sorting .dragging .ant-table-row-index {
- opacity: 0.3;
+ .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}} \ No newline at end of file
+{{end}}