fix: 修復系統中「鎖定狀態未向下繼承」的嚴重 UI/UX 漏洞。
This commit is contained in:
parent
6698e34292
commit
ecedee49d2
249
all_code.txt
249
all_code.txt
|
|
@ -2176,7 +2176,8 @@ function startLockStatusPolling() {
|
||||||
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
||||||
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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 => {
|
targetBtns.forEach(btn => {
|
||||||
// 略過目前正在編輯的那個實體按鈕
|
// 略過目前正在編輯的那個實體按鈕
|
||||||
|
|
@ -2185,12 +2186,19 @@ function startLockStatusPolling() {
|
||||||
if (btn.innerText !== "🔒") {
|
if (btn.innerText !== "🔒") {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
btn.style.opacity = '0.2';
|
btn.style.opacity = '0.2';
|
||||||
|
|
||||||
|
const btnPath = btn.getAttribute('data-path');
|
||||||
|
if (btnPath === path) {
|
||||||
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
||||||
if (lockInfo.user_id !== SESSION_USER_ID) {
|
if (lockInfo.user_id !== SESSION_USER_ID) {
|
||||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
||||||
} else {
|
} else {
|
||||||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 子節點被父節點鎖定
|
||||||
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||||||
|
}
|
||||||
btn.innerText = "🔒";
|
btn.innerText = "🔒";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -2203,15 +2211,34 @@ function startLockStatusPolling() {
|
||||||
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
||||||
if (oldHost === host && !newLockState.has(oldKey)) {
|
if (oldHost === host && !newLockState.has(oldKey)) {
|
||||||
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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 => {
|
targetBtns.forEach(btn => {
|
||||||
if (btn.innerText === "🔒") {
|
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.pointerEvents = 'auto';
|
||||||
btn.style.opacity = '0.3';
|
btn.style.opacity = '0.3';
|
||||||
btn.title = "鎖定並編輯此項目";
|
btn.title = "鎖定並編輯此項目";
|
||||||
btn.innerText = "✏️";
|
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);
|
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||||
const ACTIVE_HEARTBEATS = {};
|
const ACTIVE_HEARTBEATS = {};
|
||||||
|
|
||||||
|
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
|
||||||
|
const activeEditSessions = new Set();
|
||||||
|
|
||||||
export let currentEditPath = null;
|
export let currentEditPath = null;
|
||||||
export let currentEditElementId = null;
|
export let currentEditElementId = null;
|
||||||
export let currentEditIsSuccess = false;
|
export let currentEditIsSuccess = false;
|
||||||
|
|
@ -4066,6 +4096,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export async function startEditFolder(path, elementId) {
|
export async function startEditFolder(path, elementId) {
|
||||||
|
// 🚨 防護 1:防止連點或重複執行
|
||||||
|
if (activeEditSessions.has(elementId)) return;
|
||||||
|
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : '';
|
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 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 {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
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 (result.data.status === 'success') {
|
||||||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
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, '\\"');
|
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}`) {
|
if (btn.id !== `edit-btn-${elementId}`) {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
btn.style.opacity = '0.2';
|
btn.style.opacity = '0.2';
|
||||||
|
|
||||||
|
const btnPath = btn.getAttribute('data-path');
|
||||||
|
if (btnPath === path) {
|
||||||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||||||
|
} else {
|
||||||
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||||||
|
}
|
||||||
btn.innerText = "🔒";
|
btn.innerText = "🔒";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
||||||
if (editBtn) editBtn.style.display = 'none';
|
if (editBtn) editBtn.style.display = 'none';
|
||||||
|
|
||||||
const actionBtn = document.getElementById(`actions-${elementId}`);
|
const actionBtn = document.getElementById(`actions-${elementId}`);
|
||||||
|
|
@ -4122,12 +4194,17 @@ export async function startEditFolder(path, elementId) {
|
||||||
let optData = {};
|
let optData = {};
|
||||||
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
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 containersArray = Array.from(leafContainers);
|
||||||
const CHUNK_SIZE = 50;
|
const CHUNK_SIZE = 50;
|
||||||
|
|
||||||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||||||
|
// 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷
|
||||||
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
|
|
||||||
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
||||||
|
|
||||||
chunk.forEach(container => {
|
chunk.forEach(container => {
|
||||||
|
|
@ -4174,11 +4251,20 @@ export async function startEditFolder(path, elementId) {
|
||||||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
activeEditSessions.delete(elementId);
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.style.pointerEvents = 'auto';
|
||||||
|
editBtn.innerText = "✏️";
|
||||||
|
}
|
||||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startEditLeaf(path, elementId) {
|
export async function startEditLeaf(path, elementId) {
|
||||||
|
// 🚨 防護 1:防止連點或重複執行
|
||||||
|
if (activeEditSessions.has(elementId)) return;
|
||||||
|
activeEditSessions.add(elementId);
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : '';
|
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 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 {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
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 (result.data.status === 'success') {
|
||||||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
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);
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||||||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||||||
|
|
||||||
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
|
|
||||||
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
||||||
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
||||||
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
||||||
|
|
||||||
// 🌟 確保不在冷卻名單中 (防止手速過快)
|
|
||||||
if (window.recentlyReleasedLocks) {
|
if (window.recentlyReleasedLocks) {
|
||||||
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
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';
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||||||
|
|
||||||
const container = document.getElementById(`container-${elementId}`);
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
|
@ -4235,6 +4350,7 @@ export async function startEditLeaf(path, elementId) {
|
||||||
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
||||||
let found = false;
|
let found = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host, currentMode);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
|
|
@ -4244,6 +4360,8 @@ export async function startEditLeaf(path, elementId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||||
|
|
||||||
let inputHtml = "";
|
let inputHtml = "";
|
||||||
let hintAttr = "";
|
let hintAttr = "";
|
||||||
let hintIcon = "";
|
let hintIcon = "";
|
||||||
|
|
@ -4270,15 +4388,23 @@ export async function startEditLeaf(path, elementId) {
|
||||||
}
|
}
|
||||||
container.innerHTML = inputHtml;
|
container.innerHTML = inputHtml;
|
||||||
} catch (e) {
|
} 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;">`;
|
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) {
|
} catch (error) {
|
||||||
|
activeEditSessions.delete(elementId);
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.style.pointerEvents = 'auto';
|
||||||
|
editBtn.innerText = "✏️";
|
||||||
|
}
|
||||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelEditFolder(path, elementId) {
|
export async function cancelEditFolder(path, elementId) {
|
||||||
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
@ -4287,28 +4413,59 @@ export async function cancelEditFolder(path, elementId) {
|
||||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||||
delete 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 = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
|
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||||
|
|
||||||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||||||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||||||
delete window.globalActiveLocks[host][path];
|
delete window.globalActiveLocks[host][path];
|
||||||
}
|
}
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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}`) {
|
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.pointerEvents = 'auto';
|
||||||
btn.style.opacity = '0.3';
|
btn.style.opacity = '0.3';
|
||||||
btn.title = "鎖定並編輯此項目";
|
btn.title = "鎖定並編輯此項目";
|
||||||
btn.innerText = "✏️";
|
btn.innerText = "✏️";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
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 contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
@ -4342,6 +4499,8 @@ export async function cancelEditFolder(path, elementId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelEditLeaf(path, elementId) {
|
export async function cancelEditLeaf(path, elementId) {
|
||||||
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
@ -4350,12 +4509,13 @@ export async function cancelEditLeaf(path, elementId) {
|
||||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||||
delete 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 = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
|
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||||
|
|
||||||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||||||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||||||
delete 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';
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
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 container = document.getElementById(`container-${elementId}`);
|
||||||
const origVal = container.getAttribute('data-original');
|
const origVal = container.getAttribute('data-original');
|
||||||
|
|
@ -5415,8 +5584,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
let lockOpacity = "0.3";
|
let lockOpacity = "0.3";
|
||||||
let lockPointer = "auto";
|
let lockPointer = "auto";
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const lockInfo = window.globalActiveLocks[currentHost][currentPath];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
|
// 1. 檢查自己是否被鎖定
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true;
|
||||||
lockText = "🔒";
|
lockText = "🔒";
|
||||||
|
|
@ -5426,6 +5599,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
? `🔒 此區塊正由 [${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(`
|
htmlParts.push(`
|
||||||
|
|
@ -5495,8 +5681,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
let lockOpacity = "0.3";
|
let lockOpacity = "0.3";
|
||||||
let lockPointer = "auto";
|
let lockPointer = "auto";
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const lockInfo = window.globalActiveLocks[currentHost][currentPath];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
|
// 1. 檢查自己是否被鎖定
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true;
|
||||||
lockText = "🔒";
|
lockText = "🔒";
|
||||||
|
|
@ -5506,6 +5696,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
? `🔒 此區塊正由 [${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 = '';
|
let displayContent = '';
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@ function startLockStatusPolling() {
|
||||||
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
||||||
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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 => {
|
targetBtns.forEach(btn => {
|
||||||
// 略過目前正在編輯的那個實體按鈕
|
// 略過目前正在編輯的那個實體按鈕
|
||||||
|
|
@ -156,12 +157,19 @@ function startLockStatusPolling() {
|
||||||
if (btn.innerText !== "🔒") {
|
if (btn.innerText !== "🔒") {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
btn.style.opacity = '0.2';
|
btn.style.opacity = '0.2';
|
||||||
|
|
||||||
|
const btnPath = btn.getAttribute('data-path');
|
||||||
|
if (btnPath === path) {
|
||||||
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
||||||
if (lockInfo.user_id !== SESSION_USER_ID) {
|
if (lockInfo.user_id !== SESSION_USER_ID) {
|
||||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
||||||
} else {
|
} else {
|
||||||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 子節點被父節點鎖定
|
||||||
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||||||
|
}
|
||||||
btn.innerText = "🔒";
|
btn.innerText = "🔒";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -174,15 +182,34 @@ function startLockStatusPolling() {
|
||||||
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
||||||
if (oldHost === host && !newLockState.has(oldKey)) {
|
if (oldHost === host && !newLockState.has(oldKey)) {
|
||||||
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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 => {
|
targetBtns.forEach(btn => {
|
||||||
if (btn.innerText === "🔒") {
|
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.pointerEvents = 'auto';
|
||||||
btn.style.opacity = '0.3';
|
btn.style.opacity = '0.3';
|
||||||
btn.title = "鎖定並編輯此項目";
|
btn.title = "鎖定並編輯此項目";
|
||||||
btn.innerText = "✏️";
|
btn.innerText = "✏️";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ function escapeHTML(str) {
|
||||||
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||||
const ACTIVE_HEARTBEATS = {};
|
const ACTIVE_HEARTBEATS = {};
|
||||||
|
|
||||||
|
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
|
||||||
|
const activeEditSessions = new Set();
|
||||||
|
|
||||||
export let currentEditPath = null;
|
export let currentEditPath = null;
|
||||||
export let currentEditElementId = null;
|
export let currentEditElementId = null;
|
||||||
export let currentEditIsSuccess = false;
|
export let currentEditIsSuccess = false;
|
||||||
|
|
@ -69,6 +72,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export async function startEditFolder(path, elementId) {
|
export async function startEditFolder(path, elementId) {
|
||||||
|
// 🚨 防護 1:防止連點或重複執行
|
||||||
|
if (activeEditSessions.has(elementId)) return;
|
||||||
|
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : '';
|
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 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 {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
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 (result.data.status === 'success') {
|
||||||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
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, '\\"');
|
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}`) {
|
if (btn.id !== `edit-btn-${elementId}`) {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
btn.style.opacity = '0.2';
|
btn.style.opacity = '0.2';
|
||||||
|
|
||||||
|
const btnPath = btn.getAttribute('data-path');
|
||||||
|
if (btnPath === path) {
|
||||||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||||||
|
} else {
|
||||||
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||||||
|
}
|
||||||
btn.innerText = "🔒";
|
btn.innerText = "🔒";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
||||||
if (editBtn) editBtn.style.display = 'none';
|
if (editBtn) editBtn.style.display = 'none';
|
||||||
|
|
||||||
const actionBtn = document.getElementById(`actions-${elementId}`);
|
const actionBtn = document.getElementById(`actions-${elementId}`);
|
||||||
|
|
@ -125,12 +170,17 @@ export async function startEditFolder(path, elementId) {
|
||||||
let optData = {};
|
let optData = {};
|
||||||
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
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 containersArray = Array.from(leafContainers);
|
||||||
const CHUNK_SIZE = 50;
|
const CHUNK_SIZE = 50;
|
||||||
|
|
||||||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||||||
|
// 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷
|
||||||
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
|
|
||||||
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
||||||
|
|
||||||
chunk.forEach(container => {
|
chunk.forEach(container => {
|
||||||
|
|
@ -177,11 +227,20 @@ export async function startEditFolder(path, elementId) {
|
||||||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
activeEditSessions.delete(elementId);
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.style.pointerEvents = 'auto';
|
||||||
|
editBtn.innerText = "✏️";
|
||||||
|
}
|
||||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startEditLeaf(path, elementId) {
|
export async function startEditLeaf(path, elementId) {
|
||||||
|
// 🚨 防護 1:防止連點或重複執行
|
||||||
|
if (activeEditSessions.has(elementId)) return;
|
||||||
|
activeEditSessions.add(elementId);
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : '';
|
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 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 {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
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 (result.data.status === 'success') {
|
||||||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
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);
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||||||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||||||
|
|
||||||
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
|
|
||||||
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
||||||
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
||||||
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
||||||
|
|
||||||
// 🌟 確保不在冷卻名單中 (防止手速過快)
|
|
||||||
if (window.recentlyReleasedLocks) {
|
if (window.recentlyReleasedLocks) {
|
||||||
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
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';
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||||||
|
|
||||||
const container = document.getElementById(`container-${elementId}`);
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
|
@ -238,6 +326,7 @@ export async function startEditLeaf(path, elementId) {
|
||||||
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
||||||
let found = false;
|
let found = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host, currentMode);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
|
|
@ -247,6 +336,8 @@ export async function startEditLeaf(path, elementId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||||
|
|
||||||
let inputHtml = "";
|
let inputHtml = "";
|
||||||
let hintAttr = "";
|
let hintAttr = "";
|
||||||
let hintIcon = "";
|
let hintIcon = "";
|
||||||
|
|
@ -273,15 +364,23 @@ export async function startEditLeaf(path, elementId) {
|
||||||
}
|
}
|
||||||
container.innerHTML = inputHtml;
|
container.innerHTML = inputHtml;
|
||||||
} catch (e) {
|
} 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;">`;
|
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) {
|
} catch (error) {
|
||||||
|
activeEditSessions.delete(elementId);
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.style.pointerEvents = 'auto';
|
||||||
|
editBtn.innerText = "✏️";
|
||||||
|
}
|
||||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelEditFolder(path, elementId) {
|
export async function cancelEditFolder(path, elementId) {
|
||||||
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
@ -290,28 +389,59 @@ export async function cancelEditFolder(path, elementId) {
|
||||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||||
delete 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 = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
|
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||||
|
|
||||||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||||||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||||||
delete window.globalActiveLocks[host][path];
|
delete window.globalActiveLocks[host][path];
|
||||||
}
|
}
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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}`) {
|
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.pointerEvents = 'auto';
|
||||||
btn.style.opacity = '0.3';
|
btn.style.opacity = '0.3';
|
||||||
btn.title = "鎖定並編輯此項目";
|
btn.title = "鎖定並編輯此項目";
|
||||||
btn.innerText = "✏️";
|
btn.innerText = "✏️";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
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 contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
@ -345,6 +475,8 @@ export async function cancelEditFolder(path, elementId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelEditLeaf(path, elementId) {
|
export async function cancelEditLeaf(path, elementId) {
|
||||||
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
@ -353,12 +485,13 @@ export async function cancelEditLeaf(path, elementId) {
|
||||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||||
delete 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 = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
|
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||||
|
|
||||||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||||||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||||||
delete 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';
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
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 container = document.getElementById(`container-${elementId}`);
|
||||||
const origVal = container.getAttribute('data-original');
|
const origVal = container.getAttribute('data-original');
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
let lockOpacity = "0.3";
|
let lockOpacity = "0.3";
|
||||||
let lockPointer = "auto";
|
let lockPointer = "auto";
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const lockInfo = window.globalActiveLocks[currentHost][currentPath];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
|
// 1. 檢查自己是否被鎖定
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true;
|
||||||
lockText = "🔒";
|
lockText = "🔒";
|
||||||
|
|
@ -137,6 +141,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
? `🔒 此區塊正由 [${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(`
|
htmlParts.push(`
|
||||||
|
|
@ -206,8 +223,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
let lockOpacity = "0.3";
|
let lockOpacity = "0.3";
|
||||||
let lockPointer = "auto";
|
let lockPointer = "auto";
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const lockInfo = window.globalActiveLocks[currentHost][currentPath];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
|
// 1. 檢查自己是否被鎖定
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true;
|
||||||
lockText = "🔒";
|
lockText = "🔒";
|
||||||
|
|
@ -217,6 +238,19 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
? `🔒 此區塊正由 [${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 = '';
|
let displayContent = '';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue