@@ -411,20 +411,17 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
411411 height: 100%; border-radius: 2px;
412412 transition: width .6s cubic-bezier(.4,0,.2,1);
413413 }
414- /* ── 供应商状态:拖拽排序 ── */
415- .vendor-item[draggable="true"] { cursor: grab; }
416- .vendor-item[draggable="true"]:active { cursor: grabbing; }
414+ /* ── 供应商状态:拖拽排序(Pointer Events,手柄发起) ── */
417415 .vendor-item.dragging { opacity: .4; }
418416 .drag-handle {
419417 display: flex; align-items: center; flex-shrink: 0;
420418 color: var(--text-tertiary); font-size: 15px; line-height: 1;
421- cursor: grab; user-select: none; padding: 0 2px;
419+ cursor: grab; user-select: none; touch-action: none; padding: 0 2px;
422420 opacity: 0; transition: opacity .2s ease;
423421 }
422+ .drag-handle:active { cursor: grabbing; }
424423 .vendor-item:hover .drag-handle { opacity: .55; }
425424 .drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); }
426- .vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); }
427- .vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); }
428425 /* ── 故障转移表 ── */
429426 .ft-table-wrap { overflow-x: auto; }
430427 table { width: 100%; border-collapse: collapse; }
@@ -1354,7 +1351,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13541351 list.innerHTML = '<div class="empty"><div class="empty-icon">🔌</div>无供应商数据</div>';
13551352 return;
13561353 }
1357- const draggable = tiers.length >= 2;
1354+ const reorderable = tiers.length >= 2;
13581355 list.innerHTML = tiers.map(tier => {
13591356 const cb = tier.circuit_breaker || {};
13601357 const cbClass = cbStateClass(cb.state);
@@ -1368,8 +1365,9 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13681365 const rlInfo = tier.rate_limit || {};
13691366 const rlHtml = rlInfo.limited ? `<span class="status-badge sb-warn">限速中</span>` : '';
13701367
1371- const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : '');
1372- const handle = draggable ? `<div class="drag-handle" title="拖拽调整优先级">⠿</div>` : '';
1368+ // Pointer Events 重排:仅需 data-vendor 定位;手柄作为拖拽发起点(无原生 draggable)
1369+ const dragAttrs = ` data-vendor="${tier.name}"`;
1370+ const handle = reorderable ? `<div class="drag-handle" title="拖拽调整优先级">⠿</div>` : '';
13731371
13741372 return `<div class="vendor-item"${dragAttrs}>
13751373 <div class="vendor-info">
@@ -1386,76 +1384,111 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13861384 }).join('');
13871385}
13881386
1389- // ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ────
1390- const _tierDrag = { active: false, srcName: null, inFlight: false };
1387+ // ── 供应商状态:拖拽调整优先级(Pointer Events 重排,运行时生效,不重置配额) ────
1388+ // 采用 Pointer Events 而非原生 HTML5 DnD:后者触屏不支持、跨浏览器易「拿不起来」,
1389+ // 是业界公认脆弱的重排序方案(故 SortableJS / dnd-kit 等均改用指针事件)。
1390+ const _tierDrag = {
1391+ active: false, // 已越过阈值、进入拖拽
1392+ srcName: null, // 被拖拽 vendor 名
1393+ srcEl: null, // 被拖拽行
1394+ pointerId: null, // 捕获的指针 ID
1395+ startY: 0, // pointerdown 起点 Y
1396+ origOrder: null, // 拖拽开始时的顺序快照(无变化则跳过 PUT)
1397+ inFlight: false, // PUT 未决
1398+ };
1399+ const _TIER_DRAG_THRESHOLD = 4; // px,越过才判定为拖拽(否则视作点击)
13911400
1392- function _tierDragClearIndicators() {
1393- document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after')
1394- .forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); });
1401+ function _tierCurrentOrder(listEl) {
1402+ return Array.from(listEl.querySelectorAll('.vendor-item'))
1403+ .map(function(el) { return el.dataset.vendor; })
1404+ .filter(Boolean);
13951405}
13961406
1397- function _tierDragPosition(item, clientY) {
1398- // 鼠标相对目标行中点的位置 → 插入到上方或下方
1399- const rect = item.getBoundingClientRect();
1400- return clientY < rect.top + rect.height / 2 ? 'before' : 'after';
1407+ // 依据指针 Y 与各兄弟行中点比较,即时(乐观)重排 DOM
1408+ function _tierDragMoveTo(listEl, clientY) {
1409+ const dragged = _tierDrag.srcEl;
1410+ if (!dragged) return;
1411+ const siblings = Array.from(listEl.querySelectorAll('.vendor-item'))
1412+ .filter(function(el) { return el !== dragged; });
1413+ for (var i = 0; i < siblings.length; i++) {
1414+ const rect = siblings[i].getBoundingClientRect();
1415+ if (clientY < rect.top + rect.height / 2) {
1416+ if (dragged !== siblings[i] && dragged.nextElementSibling !== siblings[i]) {
1417+ listEl.insertBefore(dragged, siblings[i]);
1418+ }
1419+ return;
1420+ }
1421+ }
1422+ if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged); // 落到末尾
1423+ }
1424+
1425+ function _tierDragReset() {
1426+ if (_tierDrag.srcEl) {
1427+ if (_tierDrag.pointerId != null) {
1428+ try { _tierDrag.srcEl.releasePointerCapture(_tierDrag.pointerId); } catch (_) {}
1429+ }
1430+ _tierDrag.srcEl.classList.remove('dragging');
1431+ }
1432+ _tierDrag.active = false;
1433+ _tierDrag.srcName = null;
1434+ _tierDrag.srcEl = null;
1435+ _tierDrag.pointerId = null;
1436+ _tierDrag.startY = 0;
1437+ _tierDrag.origOrder = null;
14011438}
14021439
14031440function initTierDrag() {
1404- // 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效
1441+ // 指针事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效
14051442 const list = document.getElementById('vendor-list');
14061443 if (!list || list.dataset.dndBound === '1') return;
14071444 list.dataset.dndBound = '1';
14081445
1409- list.addEventListener('dragstart', function(e) {
1410- const item = e.target && e.target.closest && e.target.closest('.vendor-item');
1411- if (!item || !item.draggable) return;
1412- _tierDrag.active = true;
1446+ list.addEventListener('pointerdown', function(e) {
1447+ if (e.button != null && e.button !== 0) return; // 仅主指针/左键
1448+ const handle = e.target && e.target.closest && e.target.closest('.drag-handle');
1449+ if (!handle) return; // 仅从手柄发起,避免误触与文本选择
1450+ const item = handle.closest('.vendor-item');
1451+ if (!item || !item.dataset.vendor) return;
1452+ if (list.querySelectorAll('.vendor-item').length < 2) return;
1453+ _tierDrag.srcEl = item;
14131454 _tierDrag.srcName = item.dataset.vendor;
1414- item.classList.add('dragging');
1415- if (e.dataTransfer) {
1416- e.dataTransfer.effectAllowed = 'move';
1417- try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {}
1418- }
1419- });
1420-
1421- list.addEventListener('dragover', function(e) {
1422- if (!_tierDrag.active) return;
1423- e.preventDefault(); // 允许 drop(无论是否命中某一行)
1424- if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
1425- const item = e.target && e.target.closest && e.target.closest('.vendor-item');
1426- _tierDragClearIndicators();
1427- if (!item || item.dataset.vendor === _tierDrag.srcName) return;
1428- item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after');
1455+ _tierDrag.pointerId = e.pointerId;
1456+ _tierDrag.startY = e.clientY;
1457+ try { item.setPointerCapture(e.pointerId); } catch (_) {} // 捕获后 DOM 重排不丢事件
1458+ e.preventDefault(); // 阻止文本选择
14291459 });
14301460
1431- list.addEventListener('drop', function(e) {
1432- if (!_tierDrag.active) return;
1461+ list.addEventListener('pointermove', function(e) {
1462+ if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return;
1463+ if (!_tierDrag.active) {
1464+ if (Math.abs(e.clientY - _tierDrag.startY) < _TIER_DRAG_THRESHOLD) return;
1465+ _tierDrag.active = true; // 越阈值 → 正式进入拖拽
1466+ _tierDrag.origOrder = _tierCurrentOrder(list);
1467+ _tierDrag.srcEl.classList.add('dragging');
1468+ }
14331469 e.preventDefault();
1434- const listEl = document.getElementById('vendor-list');
1435- const dragged = listEl.querySelector('.vendor-item.dragging');
1436- if (!dragged) return;
1437- let target = e.target && e.target.closest && e.target.closest('.vendor-item');
1438- if (!target) {
1439- // 落在容器空白处 → 移到末尾
1440- if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged);
1441- } else if (target !== dragged) {
1442- const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling;
1443- if (ref !== dragged) listEl.insertBefore(dragged, ref);
1444- }
1445- _tierDragClearIndicators();
1446- const order = Array.from(listEl.children)
1447- .filter(function(el) { return el.classList.contains('vendor-item'); })
1448- .map(function(el) { return el.dataset.vendor; })
1449- .filter(Boolean);
1450- persistTierOrder(order);
1470+ _tierDragMoveTo(list, e.clientY);
14511471 });
14521472
1453- list.addEventListener('dragend', function() {
1454- _tierDrag.active = false;
1455- _tierDrag.srcName = null;
1456- _tierDragClearIndicators();
1457- document.querySelectorAll('#vendor-list .dragging')
1458- .forEach(function(el) { el.classList.remove('dragging'); });
1473+ function _tierDragFinish(commit) {
1474+ if (!_tierDrag.srcEl) return;
1475+ const wasActive = _tierDrag.active;
1476+ const order = _tierCurrentOrder(list);
1477+ const orig = _tierDrag.origOrder;
1478+ _tierDragReset();
1479+ if (!wasActive) return; // 仅点击手柄未拖动 → 空操作
1480+ if (!commit) { _tierRevertToList(); return; } // 取消 → 回滚服务端真实顺序
1481+ if (orig && order.join(',') === orig.join(',')) return; // 顺序未变 → 跳过 PUT
1482+ persistTierOrder(order); // 提交新顺序(PUT + 失败回滚)
1483+ }
1484+
1485+ list.addEventListener('pointerup', function(e) {
1486+ if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return;
1487+ _tierDragFinish(true);
1488+ });
1489+ list.addEventListener('pointercancel', function(e) {
1490+ if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return;
1491+ _tierDragFinish(false);
14591492 });
14601493}
14611494
@@ -2417,8 +2450,13 @@ async def favicon_svg() -> Response:
24172450
24182451 @app .get ("/dashboard" , response_class = HTMLResponse , include_in_schema = False )
24192452 async def dashboard () -> HTMLResponse :
2420- """返回 Dashboard HTML 页面."""
2421- return HTMLResponse (content = _DASHBOARD_HTML )
2453+ """返回 Dashboard HTML 页面.
2454+
2455+ 内联 JS/CSS 随版本变化,禁用缓存以免浏览器留存旧内联脚本掩盖前端修复。
2456+ """
2457+ return HTMLResponse (
2458+ content = _DASHBOARD_HTML , headers = {"Cache-Control" : "no-cache" }
2459+ )
24222460
24232461 @app .get ("/api/dashboard/summary" )
24242462 async def dashboard_summary (request : Request , days : int = 7 ) -> Response :
0 commit comments