fix: 修復系統中「鎖定狀態未向下繼承」的嚴重 UI/UX 漏洞。

This commit is contained in:
swpa 2026-06-02 17:35:33 +08:00
parent 6698e34292
commit ecedee49d2
4 changed files with 510 additions and 104 deletions

View File

@ -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,12 +2186,19 @@ function startLockStatusPolling() {
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
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.innerText = "🔒";
}
});
@ -2203,15 +2211,34 @@ 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 === "🔒") {
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';
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 = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
}
}
} 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}`) {
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,8 +5584,12 @@ 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 (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 = "🔒";
@ -5426,6 +5599,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
? `🔒 此區塊正由 [${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;
}
}
}
}
htmlParts.push(`
@ -5495,8 +5681,12 @@ 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 (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 = "🔒";
@ -5506,6 +5696,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
? `🔒 此區塊正由 [${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;
}
}
}
}
let displayContent = '';

View File

@ -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,12 +157,19 @@ function startLockStatusPolling() {
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
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.innerText = "🔒";
}
});
@ -174,15 +182,34 @@ 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 === "🔒") {
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 = "✏️";
}
}
});
}
});

View File

@ -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';
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 = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
}
}
} 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}`) {
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');

View File

@ -126,8 +126,12 @@ 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 (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 = "🔒";
@ -137,6 +141,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
? `🔒 此區塊正由 [${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;
}
}
}
}
htmlParts.push(`
@ -206,8 +223,12 @@ 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 (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 = "🔒";
@ -217,6 +238,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
? `🔒 此區塊正由 [${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;
}
}
}
}
let displayContent = '';