diff --git a/all_code.txt b/all_code.txt index b9792b1..6bd585a 100644 --- a/all_code.txt +++ b/all_code.txt @@ -2176,7 +2176,8 @@ function startLockStatusPolling() { newLockState.add(lockKey); // 記憶體加入 Host 標籤 const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { // 略過目前正在編輯的那個實體按鈕 @@ -2185,11 +2186,18 @@ function startLockStatusPolling() { if (btn.innerText !== "🔒") { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; - // 判斷是別人鎖的,還是自己在另一個視圖鎖的 - if (lockInfo.user_id !== SESSION_USER_ID) { - btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + // 判斷是別人鎖的,還是自己在另一個視圖鎖的 + if (lockInfo.user_id !== SESSION_USER_ID) { + btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + } else { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } } else { - btn.title = `🔒 您正在另一個視圖編輯此項目`; + // 子節點被父節點鎖定 + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; } btn.innerText = "🔒"; } @@ -2203,14 +2211,33 @@ function startLockStatusPolling() { // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 if (oldHost === host && !newLockState.has(oldKey)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { if (btn.innerText === "🔒") { - btn.style.pointerEvents = 'auto'; - btn.style.opacity = '0.3'; - btn.title = "鎖定並編輯此項目"; - btn.innerText = "✏️"; + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + // 檢查自己是否還在鎖定清單中 + if (currentLocks[btnPath]) { + stillLockedByOther = true; + } else { + // 檢查是否有其他父節點鎖定它 + for (const lockedPath of Object.keys(currentLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } + } + } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } } }); } @@ -4021,6 +4048,9 @@ function escapeHTML(str) { export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; +// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害 +const activeEditSessions = new Set(); + export let currentEditPath = null; export let currentEditElementId = null; export let currentEditIsSuccess = false; @@ -4066,6 +4096,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) { // ========================================== export async function startEditFolder(path, elementId) { + // 🚨 防護 1:防止連點或重複執行 + if (activeEditSessions.has(elementId)) return; + activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖 + const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; @@ -4073,9 +4107,41 @@ export async function startEditFolder(path, elementId) { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; + // 立即讓按鈕呈現讀取中,防止使用者焦慮連點 + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.pointerEvents = 'none'; + editBtn.innerText = "⏳"; + } + try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); - if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); + + // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 + if (!activeEditSessions.has(elementId)) { + if (result.status === 200 || (result.data && result.data.status === 'success')) { + // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 + if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; + window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去 + } + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; + } + return; // 立即中斷,絕不觸碰 UI + } + + if (result.status === 409) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } + return alert(`⚠️ ${result.data.detail}`); + } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); @@ -4094,16 +4160,22 @@ export async function startEditFolder(path, elementId) { } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { + // 🌟 擴大選擇器:連同所有子節點一起秒級鎖定 + document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; - btn.title = `🔒 您正在另一個視圖編輯此項目`; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } else { + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; + } btn.innerText = "🔒"; } }); - const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) editBtn.style.display = 'none'; const actionBtn = document.getElementById(`actions-${elementId}`); @@ -4122,12 +4194,17 @@ export async function startEditFolder(path, elementId) { let optData = {}; try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} - const pathsToSync = []; + // 🚨 防護 3:如果等待 API 期間使用者按了取消,立刻中斷後續渲染 + if (!activeEditSessions.has(elementId)) return; + const pathsToSync = []; const containersArray = Array.from(leafContainers); const CHUNK_SIZE = 50; for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { + // 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷 + if (!activeEditSessions.has(elementId)) return; + const chunk = containersArray.slice(i, i + CHUNK_SIZE); chunk.forEach(container => { @@ -4174,11 +4251,20 @@ export async function startEditFolder(path, elementId) { if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass); } } catch (error) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function startEditLeaf(path, elementId) { + // 🚨 防護 1:防止連點或重複執行 + if (activeEditSessions.has(elementId)) return; + activeEditSessions.add(elementId); + const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; @@ -4186,9 +4272,40 @@ export async function startEditLeaf(path, elementId) { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.pointerEvents = 'none'; + editBtn.innerText = "⏳"; + } + try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); - if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); + + // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 + if (!activeEditSessions.has(elementId)) { + if (result.status === 200 || (result.data && result.data.status === 'success')) { + // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 + if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; + window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + apiReleaseLock(path, SESSION_USER_ID, host); + } + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; + } + return; + } + + if (result.status === 409) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } + return alert(`⚠️ ${result.data.detail}`); + } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); @@ -4196,12 +4313,10 @@ export async function startEditLeaf(path, elementId) { intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); ACTIVE_HEARTBEATS[lockKey] = intervalId; - // 🌟 立即更新全域鎖定狀態 (加入 Host 隔離) if (!window.globalActiveLocks) window.globalActiveLocks = {}; if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {}; window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username }; - // 🌟 確保不在冷卻名單中 (防止手速過快) if (window.recentlyReleasedLocks) { delete window.recentlyReleasedLocks[`${host}@@${path}`]; } @@ -4216,7 +4331,7 @@ export async function startEditLeaf(path, elementId) { } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; + if (editBtn) editBtn.style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const container = document.getElementById(`container-${elementId}`); @@ -4235,6 +4350,7 @@ export async function startEditLeaf(path, elementId) { apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); let found = false; for (let i = 0; i < 10; i++) { + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; @@ -4244,6 +4360,8 @@ export async function startEditLeaf(path, elementId) { } } + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 + let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; @@ -4270,15 +4388,23 @@ export async function startEditLeaf(path, elementId) { } container.innerHTML = inputHtml; } catch (e) { + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 container.innerHTML = ``; } } } catch (error) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function cancelEditFolder(path, elementId) { + activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 + const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; @@ -4287,28 +4413,59 @@ export async function cancelEditFolder(path, elementId) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } - try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} - // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態) + // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} + // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { + // 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定 + document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { - btn.style.pointerEvents = 'auto'; - btn.style.opacity = '0.3'; - btn.title = "鎖定並編輯此項目"; - btn.innerText = "✏️"; + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + // 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點 + if (window.globalActiveLocks && window.globalActiveLocks[host]) { + const hostLocks = window.globalActiveLocks[host]; + if (hostLocks[btnPath]) { + stillLockedByOther = true; + } else { + for (const lockedPath of Object.keys(hostLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } + } + } + } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; - document.getElementById(`actions-${elementId}`).style.display = 'none'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.display = 'inline-block'; + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 + } + + const actionBtns = document.getElementById(`actions-${elementId}`); + if (actionBtns) actionBtns.style.display = 'none'; const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); @@ -4342,6 +4499,8 @@ export async function cancelEditFolder(path, elementId) { } export async function cancelEditLeaf(path, elementId) { + activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 + const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; @@ -4350,12 +4509,13 @@ export async function cancelEditLeaf(path, elementId) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } - try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} - // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態) + // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} + // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; @@ -4370,8 +4530,17 @@ export async function cancelEditLeaf(path, elementId) { } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; - document.getElementById(`actions-${elementId}`).style.display = 'none'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.display = 'inline-block'; + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 + } + + const actionBtns = document.getElementById(`actions-${elementId}`); + if (actionBtns) actionBtns.style.display = 'none'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); @@ -5415,16 +5584,33 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode = let lockOpacity = "0.3"; let lockPointer = "auto"; - if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) { - const lockInfo = window.globalActiveLocks[currentHost][currentPath]; - if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { - isLocked = true; - lockText = "🔒"; - lockOpacity = "0.2"; - lockPointer = "none"; - lockTitle = lockInfo.user_id !== SESSION_USER_ID - ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` - : `🔒 您正在另一個視圖編輯此項目`; + if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { + const hostLocks = window.globalActiveLocks[currentHost]; + + // 1. 檢查自己是否被鎖定 + if (hostLocks[currentPath]) { + const lockInfo = hostLocks[currentPath]; + if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = lockInfo.user_id !== SESSION_USER_ID + ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` + : `🔒 您正在另一個視圖編輯此項目`; + } + } else { + // 2. 檢查父層級是否被鎖定 (Cascading Lock) + for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { + if (currentPath.startsWith(lockedPath + "::")) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; + break; + } + } } } @@ -5495,16 +5681,33 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode = let lockOpacity = "0.3"; let lockPointer = "auto"; - if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) { - const lockInfo = window.globalActiveLocks[currentHost][currentPath]; - if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { - isLocked = true; - lockText = "🔒"; - lockOpacity = "0.2"; - lockPointer = "none"; - lockTitle = lockInfo.user_id !== SESSION_USER_ID - ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` - : `🔒 您正在另一個視圖編輯此項目`; + if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { + const hostLocks = window.globalActiveLocks[currentHost]; + + // 1. 檢查自己是否被鎖定 + if (hostLocks[currentPath]) { + const lockInfo = hostLocks[currentPath]; + if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = lockInfo.user_id !== SESSION_USER_ID + ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` + : `🔒 您正在另一個視圖編輯此項目`; + } + } else { + // 2. 檢查父層級是否被鎖定 (Cascading Lock) + for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { + if (currentPath.startsWith(lockedPath + "::")) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; + break; + } + } } } diff --git a/static/app.js b/static/app.js index 9dde413..c105017 100644 --- a/static/app.js +++ b/static/app.js @@ -147,7 +147,8 @@ function startLockStatusPolling() { newLockState.add(lockKey); // 記憶體加入 Host 標籤 const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { // 略過目前正在編輯的那個實體按鈕 @@ -156,11 +157,18 @@ function startLockStatusPolling() { if (btn.innerText !== "🔒") { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; - // 判斷是別人鎖的,還是自己在另一個視圖鎖的 - if (lockInfo.user_id !== SESSION_USER_ID) { - btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + // 判斷是別人鎖的,還是自己在另一個視圖鎖的 + if (lockInfo.user_id !== SESSION_USER_ID) { + btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + } else { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } } else { - btn.title = `🔒 您正在另一個視圖編輯此項目`; + // 子節點被父節點鎖定 + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; } btn.innerText = "🔒"; } @@ -174,14 +182,33 @@ function startLockStatusPolling() { // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 if (oldHost === host && !newLockState.has(oldKey)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { if (btn.innerText === "🔒") { - btn.style.pointerEvents = 'auto'; - btn.style.opacity = '0.3'; - btn.title = "鎖定並編輯此項目"; - btn.innerText = "✏️"; + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + // 檢查自己是否還在鎖定清單中 + if (currentLocks[btnPath]) { + stillLockedByOther = true; + } else { + // 檢查是否有其他父節點鎖定它 + for (const lockedPath of Object.keys(currentLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } + } + } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } } }); } diff --git a/static/edit-mode.js b/static/edit-mode.js index 2cd7245..683505b 100644 --- a/static/edit-mode.js +++ b/static/edit-mode.js @@ -24,6 +24,9 @@ function escapeHTML(str) { export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; +// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害 +const activeEditSessions = new Set(); + export let currentEditPath = null; export let currentEditElementId = null; export let currentEditIsSuccess = false; @@ -69,6 +72,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) { // ========================================== export async function startEditFolder(path, elementId) { + // 🚨 防護 1:防止連點或重複執行 + if (activeEditSessions.has(elementId)) return; + activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖 + const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; @@ -76,9 +83,41 @@ export async function startEditFolder(path, elementId) { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; + // 立即讓按鈕呈現讀取中,防止使用者焦慮連點 + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.pointerEvents = 'none'; + editBtn.innerText = "⏳"; + } + try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); - if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); + + // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 + if (!activeEditSessions.has(elementId)) { + if (result.status === 200 || (result.data && result.data.status === 'success')) { + // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 + if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; + window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去 + } + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; + } + return; // 立即中斷,絕不觸碰 UI + } + + if (result.status === 409) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } + return alert(`⚠️ ${result.data.detail}`); + } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); @@ -97,16 +136,22 @@ export async function startEditFolder(path, elementId) { } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { + // 🌟 擴大選擇器:連同所有子節點一起秒級鎖定 + document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; - btn.title = `🔒 您正在另一個視圖編輯此項目`; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } else { + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; + } btn.innerText = "🔒"; } }); - const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) editBtn.style.display = 'none'; const actionBtn = document.getElementById(`actions-${elementId}`); @@ -125,12 +170,17 @@ export async function startEditFolder(path, elementId) { let optData = {}; try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} - const pathsToSync = []; + // 🚨 防護 3:如果等待 API 期間使用者按了取消,立刻中斷後續渲染 + if (!activeEditSessions.has(elementId)) return; + const pathsToSync = []; const containersArray = Array.from(leafContainers); const CHUNK_SIZE = 50; for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { + // 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷 + if (!activeEditSessions.has(elementId)) return; + const chunk = containersArray.slice(i, i + CHUNK_SIZE); chunk.forEach(container => { @@ -177,11 +227,20 @@ export async function startEditFolder(path, elementId) { if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass); } } catch (error) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function startEditLeaf(path, elementId) { + // 🚨 防護 1:防止連點或重複執行 + if (activeEditSessions.has(elementId)) return; + activeEditSessions.add(elementId); + const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; @@ -189,9 +248,40 @@ export async function startEditLeaf(path, elementId) { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.pointerEvents = 'none'; + editBtn.innerText = "⏳"; + } + try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); - if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); + + // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 + if (!activeEditSessions.has(elementId)) { + if (result.status === 200 || (result.data && result.data.status === 'success')) { + // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 + if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; + window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + apiReleaseLock(path, SESSION_USER_ID, host); + } + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; + } + return; + } + + if (result.status === 409) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } + return alert(`⚠️ ${result.data.detail}`); + } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); @@ -199,12 +289,10 @@ export async function startEditLeaf(path, elementId) { intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); ACTIVE_HEARTBEATS[lockKey] = intervalId; - // 🌟 立即更新全域鎖定狀態 (加入 Host 隔離) if (!window.globalActiveLocks) window.globalActiveLocks = {}; if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {}; window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username }; - // 🌟 確保不在冷卻名單中 (防止手速過快) if (window.recentlyReleasedLocks) { delete window.recentlyReleasedLocks[`${host}@@${path}`]; } @@ -219,7 +307,7 @@ export async function startEditLeaf(path, elementId) { } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; + if (editBtn) editBtn.style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const container = document.getElementById(`container-${elementId}`); @@ -238,6 +326,7 @@ export async function startEditLeaf(path, elementId) { apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); let found = false; for (let i = 0; i < 10; i++) { + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; @@ -247,6 +336,8 @@ export async function startEditLeaf(path, elementId) { } } + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 + let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; @@ -273,15 +364,23 @@ export async function startEditLeaf(path, elementId) { } container.innerHTML = inputHtml; } catch (e) { + if (!activeEditSessions.has(elementId)) return; // 🚨 防護 container.innerHTML = ``; } } } catch (error) { + activeEditSessions.delete(elementId); + if (editBtn) { + editBtn.style.pointerEvents = 'auto'; + editBtn.innerText = "✏️"; + } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function cancelEditFolder(path, elementId) { + activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 + const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; @@ -290,28 +389,59 @@ export async function cancelEditFolder(path, elementId) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } - try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} - // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態) + // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} + // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { + // 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定 + document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { - btn.style.pointerEvents = 'auto'; - btn.style.opacity = '0.3'; - btn.title = "鎖定並編輯此項目"; - btn.innerText = "✏️"; + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + // 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點 + if (window.globalActiveLocks && window.globalActiveLocks[host]) { + const hostLocks = window.globalActiveLocks[host]; + if (hostLocks[btnPath]) { + stillLockedByOther = true; + } else { + for (const lockedPath of Object.keys(hostLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } + } + } + } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; - document.getElementById(`actions-${elementId}`).style.display = 'none'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.display = 'inline-block'; + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 + } + + const actionBtns = document.getElementById(`actions-${elementId}`); + if (actionBtns) actionBtns.style.display = 'none'; const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); @@ -345,6 +475,8 @@ export async function cancelEditFolder(path, elementId) { } export async function cancelEditLeaf(path, elementId) { + activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 + const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; @@ -353,12 +485,13 @@ export async function cancelEditLeaf(path, elementId) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } - try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} - // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態) + // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); + try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} + // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; @@ -373,8 +506,17 @@ export async function cancelEditLeaf(path, elementId) { } }); - document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; - document.getElementById(`actions-${elementId}`).style.display = 'none'; + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (editBtn) { + editBtn.style.display = 'inline-block'; + editBtn.style.pointerEvents = 'auto'; + editBtn.style.opacity = '0.3'; + editBtn.title = "鎖定並編輯此項目"; + editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 + } + + const actionBtns = document.getElementById(`actions-${elementId}`); + if (actionBtns) actionBtns.style.display = 'none'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); diff --git a/static/tree-ui.js b/static/tree-ui.js index 39e0bfb..7f43c28 100644 --- a/static/tree-ui.js +++ b/static/tree-ui.js @@ -126,16 +126,33 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode = let lockOpacity = "0.3"; let lockPointer = "auto"; - if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) { - const lockInfo = window.globalActiveLocks[currentHost][currentPath]; - if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { - isLocked = true; - lockText = "🔒"; - lockOpacity = "0.2"; - lockPointer = "none"; - lockTitle = lockInfo.user_id !== SESSION_USER_ID - ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` - : `🔒 您正在另一個視圖編輯此項目`; + if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { + const hostLocks = window.globalActiveLocks[currentHost]; + + // 1. 檢查自己是否被鎖定 + if (hostLocks[currentPath]) { + const lockInfo = hostLocks[currentPath]; + if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = lockInfo.user_id !== SESSION_USER_ID + ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` + : `🔒 您正在另一個視圖編輯此項目`; + } + } else { + // 2. 檢查父層級是否被鎖定 (Cascading Lock) + for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { + if (currentPath.startsWith(lockedPath + "::")) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; + break; + } + } } } @@ -206,16 +223,33 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode = let lockOpacity = "0.3"; let lockPointer = "auto"; - if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) { - const lockInfo = window.globalActiveLocks[currentHost][currentPath]; - if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { - isLocked = true; - lockText = "🔒"; - lockOpacity = "0.2"; - lockPointer = "none"; - lockTitle = lockInfo.user_id !== SESSION_USER_ID - ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` - : `🔒 您正在另一個視圖編輯此項目`; + if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { + const hostLocks = window.globalActiveLocks[currentHost]; + + // 1. 檢查自己是否被鎖定 + if (hostLocks[currentPath]) { + const lockInfo = hostLocks[currentPath]; + if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = lockInfo.user_id !== SESSION_USER_ID + ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` + : `🔒 您正在另一個視圖編輯此項目`; + } + } else { + // 2. 檢查父層級是否被鎖定 (Cascading Lock) + for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { + if (currentPath.startsWith(lockedPath + "::")) { + isLocked = true; + lockText = "🔒"; + lockOpacity = "0.2"; + lockPointer = "none"; + lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; + break; + } + } } }