@@ -411,6 +411,20 @@ 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; }
417+ .vendor-item.dragging { opacity: .4; }
418+ .drag-handle {
419+ display: flex; align-items: center; flex-shrink: 0;
420+ color: var(--text-tertiary); font-size: 15px; line-height: 1;
421+ cursor: grab; user-select: none; padding: 0 2px;
422+ opacity: 0; transition: opacity .2s ease;
423+ }
424+ .vendor-item:hover .drag-handle { opacity: .55; }
425+ .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); }
414428 /* ── 故障转移表 ── */
415429 .ft-table-wrap { overflow-x: auto; }
416430 table { width: 100%; border-collapse: collapse; }
@@ -1332,12 +1346,15 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13321346}
13331347
13341348function updateVendorStatus(status) {
1349+ // 拖拽进行中 / 重排序 POST 未决时跳过重渲染,避免抢占正在操作的 DOM
1350+ if (_tierDrag.active || _tierDrag.inFlight) return;
13351351 const tiers = status.tiers || [];
13361352 const list = document.getElementById('vendor-list');
13371353 if (!tiers.length) {
13381354 list.innerHTML = '<div class="empty"><div class="empty-icon">🔌</div>无供应商数据</div>';
13391355 return;
13401356 }
1357+ const draggable = tiers.length >= 2;
13411358 list.innerHTML = tiers.map(tier => {
13421359 const cb = tier.circuit_breaker || {};
13431360 const cbClass = cbStateClass(cb.state);
@@ -1351,8 +1368,12 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13511368 const rlInfo = tier.rate_limit || {};
13521369 const rlHtml = rlInfo.limited ? `<span class="status-badge sb-warn">限速中</span>` : '';
13531370
1354- return `<div class="vendor-item">
1371+ const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : '');
1372+ const handle = draggable ? `<div class="drag-handle" title="拖拽调整优先级">⠿</div>` : '';
1373+
1374+ return `<div class="vendor-item"${dragAttrs}>
13551375 <div class="vendor-info">
1376+ ${handle}
13561377 <div class="vendor-avatar">${initial}</div>
13571378 <span class="vendor-name">${tier.name}</span>
13581379 </div>
@@ -1365,6 +1386,110 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13651386 }).join('');
13661387}
13671388
1389+ // ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ────
1390+ const _tierDrag = { active: false, srcName: null, inFlight: false };
1391+
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'); });
1395+ }
1396+
1397+ function _tierDragPosition(item, clientY) {
1398+ // 鼠标相对目标行中点的位置 → 插入到上方或下方
1399+ const rect = item.getBoundingClientRect();
1400+ return clientY < rect.top + rect.height / 2 ? 'before' : 'after';
1401+ }
1402+
1403+ function initTierDrag() {
1404+ // 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效
1405+ const list = document.getElementById('vendor-list');
1406+ if (!list || list.dataset.dndBound === '1') return;
1407+ list.dataset.dndBound = '1';
1408+
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;
1413+ _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');
1429+ });
1430+
1431+ list.addEventListener('drop', function(e) {
1432+ if (!_tierDrag.active) return;
1433+ 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);
1451+ });
1452+
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'); });
1459+ });
1460+ }
1461+
1462+ function persistTierOrder(names) {
1463+ if (_tierDrag.inFlight) return; // 防并发:上一次未决则忽略
1464+ if (!Array.isArray(names) || names.length < 2) return;
1465+ _tierDrag.inFlight = true;
1466+ fetch('/api/tier-order', {
1467+ method: 'PUT',
1468+ headers: { 'Content-Type': 'application/json' },
1469+ body: JSON.stringify({ vendors: names })
1470+ }).then(function(res) {
1471+ _tierDrag.inFlight = false; // 先清守卫,允许随后的 refreshOverview 重渲染
1472+ if (res.ok) {
1473+ // 服务端已更新顺序;刷新以同步请求趋势图等的排序
1474+ if (!_tierDrag.active) refreshOverview().catch(function() {});
1475+ } else {
1476+ console.error('tier-order rejected:', res.status);
1477+ _tierRevertToList();
1478+ }
1479+ }).catch(function(e) {
1480+ _tierDrag.inFlight = false;
1481+ console.error('tier-order failed:', e);
1482+ _tierRevertToList();
1483+ });
1484+ }
1485+
1486+ function _tierRevertToList() {
1487+ // 回滚到服务端真实顺序(PUT 失败 → 服务端顺序未变)
1488+ fetchJSON('/api/status').then(function(status) {
1489+ updateVendorStatus(status);
1490+ }).catch(function() {});
1491+ }
1492+
13681493// ── Model Calling 实时状态 ────────────────────────────────
13691494function updateModelCalling(status) {
13701495 var wrap = document.getElementById('model-calling-wrap');
@@ -2200,6 +2325,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
22002325 fetchJSON('/api/dashboard/summary?days=7').then(function(s) {
22012326 if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version;
22022327 }).catch(function(){});
2328+ initTierDrag(); // 绑定供应商列表拖拽(事件委托,幂等)
22032329 refresh(); // 仅加载初始页签的数据
22042330 setInterval(refresh, 600000); // 每 10 分钟刷新当前页签
22052331 if (initial === 'overview') startModelCallingPoll();
0 commit comments