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;
+ }
+ }
}
}