Skip to content

Commit f9fa8c3

Browse files
committed
fix(dashboard): 供应商队列拖拽改用 Pointer Events,修复无法拖动并实机验证 cc 优先级实时生效;
- 根因:原生 HTML5 DnD 机制脆弱(触屏不支持、跨浏览器易「拿不起来」)。经活体探针确认前端设置(draggable/data-vendor/事件委托/PUT 端点)与后端(单一全局 router 切片赋值、executor 共享引用)均无缺陷,#269 仅补后端单测、拖拽从未做浏览器验证 —— 唯机制本身不可靠。 - 交互:以 #vendor-list 上的 pointerdown/move/up/cancel 事件委托替换 dragstart/dragover/drop/dragend;手柄发起 + setPointerCapture + 4px 阈值 + 乐观实时重排;复用既有 _tierDrag 状态机、persistTierOrder(PUT /api/tier-order) 与失败回滚,顺序未变则跳过 PUT。 - 样式:手柄增加 touch-action:none(触屏可拖),移除失效的 [draggable] 与 drag-over-* 规则。 - 加固:/dashboard 响应加 Cache-Control:no-cache,避免浏览器缓存旧内联脚本掩盖前端修复。 - 测试:新增前端守卫断言(交付含 pointer 事件 + PUT、不含原生 draggable),补齐 #269 缺失的前端验证;test_app_routes.py 42 项全绿。 - 实机验证:独立端口拖拽使 DOM 与 /api/status 顺序同步翻转、PUT 200、无控制台报错,证明重排实时改变 cc 运行时优先级。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
1 parent c8606eb commit f9fa8c3

2 files changed

Lines changed: 125 additions & 65 deletions

File tree

src/coding/proxy/server/dashboard.py

Lines changed: 103 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -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
14031440
function 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:

tests/test_app_routes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,3 +1213,25 @@ def test_tier_order_preserves_circuit_breaker_and_rate_limit():
12131213
same = next(t for t in router.tiers if t.name == target.name)
12141214
assert not same.can_execute() # 仍熔断
12151215
assert same.is_rate_limited # 仍限速
1216+
1217+
1218+
def test_dashboard_serves_pointer_drag_reorder_and_no_cache():
1219+
"""Dashboard 供应商拖拽已由脆弱的原生 HTML5 DnD 改为 Pointer Events,并禁用页面缓存.
1220+
1221+
#269 仅覆盖后端 /api/tier-order,前端拖拽机制无任何断言(拖拽从未被验证)。
1222+
此处补一条轻量守卫:确保重排序 UI 机制(指针事件 + PUT /api/tier-order)随页面交付、
1223+
不回退到原生 DnD,且响应带 no-cache 以免浏览器留存旧内联脚本掩盖前端修复。
1224+
"""
1225+
with _make_app() as client:
1226+
resp = client.get("/dashboard")
1227+
assert resp.status_code == 200
1228+
assert resp.headers.get("cache-control") == "no-cache"
1229+
html = resp.text
1230+
# Pointer Events 重排机制在位
1231+
assert "initTierDrag" in html
1232+
assert "pointerdown" in html
1233+
assert "setPointerCapture" in html
1234+
assert "/api/tier-order" in html
1235+
# 已移除脆弱的原生 HTML5 DnD(不再渲染 draggable 属性 / 监听 dragstart)
1236+
assert 'draggable="true"' not in html
1237+
assert "addEventListener('dragstart'" not in html

0 commit comments

Comments
 (0)