diff --git a/all_code.txt b/all_code.txt index 37b62e6..550aad8 100644 --- a/all_code.txt +++ b/all_code.txt @@ -150,28 +150,36 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b if not pool: return False - # Extract known fields - cmts_version = metadata.get("cmts_version", "unknown") + cmts_version = metadata.get("cmts_version") last_scanned = metadata.get("last_scanned", None) - query = """ - INSERT INTO device_status (host, config_type, cmts_version, last_scanned) - VALUES ($1, $2, $3, $4) - ON CONFLICT (host, config_type) - DO UPDATE SET - cmts_version = EXCLUDED.cmts_version, - last_scanned = EXCLUDED.last_scanned, - updated_at = CURRENT_TIMESTAMP; - """ try: async with pool.acquire() as conn: - await conn.execute(query, host, config_type, cmts_version, last_scanned) + # 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號! + if cmts_version and cmts_version != "unknown": + query = """ + INSERT INTO device_status (host, config_type, cmts_version, last_scanned) + VALUES ($1, $2, $3, $4) + ON CONFLICT (host, config_type) + DO UPDATE SET + cmts_version = EXCLUDED.cmts_version, + last_scanned = EXCLUDED.last_scanned, + updated_at = CURRENT_TIMESTAMP; + """ + await conn.execute(query, host, config_type, cmts_version, last_scanned) + else: + query = """ + INSERT INTO device_status (host, config_type, last_scanned) + VALUES ($1, $2, $3) + ON CONFLICT (host, config_type) + DO UPDATE SET + last_scanned = EXCLUDED.last_scanned, + updated_at = CURRENT_TIMESTAMP; + """ + await conn.execute(query, host, config_type, last_scanned) return True - except asyncpg.PostgresError as e: - logger.error(f"❌ DB Error (upsert_device_status): {e}") - return False except Exception as e: - logger.error(f"❌ Unknown Error (upsert_device_status): {e}") + logger.error(f"❌ DB Error (upsert_device_status): {e}") return False async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]: @@ -1247,6 +1255,31 @@ FILE: index.html + + + @@ -1542,9 +1575,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con # 🌟 新增:在第一批次連線時,先抓取設備版本 if cmts_version == "unknown": - process.stdin.write("show version\n") + # 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住 + process.stdin.write("show version | nomore\n") await process.stdin.drain() - version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)") + version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)") match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output) if match: cmts_version = match.group(1) @@ -1806,7 +1840,7 @@ from contextlib import asynccontextmanager import database # 引入我們剛剛拆分出來的路由模組 -from routers import query, config, terminal, lock, leaf_options, backup, diagnostics +from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth @asynccontextmanager async def lifespan(app: FastAPI): @@ -1828,6 +1862,7 @@ app.include_router(leaf_options.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1") app.include_router(diagnostics.router, prefix="/api/v1") +app.include_router(auth.router, prefix="/api/v1") # WebSocket 通常獨立於 API 版本之外,所以不加前綴 app.include_router(terminal.router) @@ -2223,6 +2258,23 @@ export async function apiSetLogLevel(module, level) { return response.json(); } +// ========================================== +// 7. 授權與驗證 API (Auth) +// ========================================== +export async function apiVerifyGodMode(password) { + const response = await fetch('/api/v1/auth/god-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "驗證失敗"); + } + return response.json(); +} + ================================================================================ FILE: static/app.js @@ -2244,7 +2296,8 @@ import { apiGetScanStatus, apiGetLockStatus, apiGetCmDiagnostics, apiListCms, apiListRpds, - apiGetLogLevels, apiSetLogLevel + apiGetLogLevels, apiSetLogLevel, + apiVerifyGodMode } from './api.js'; // 3. 共用工具模組 (Utils) @@ -2828,7 +2881,8 @@ async function fetchFullConfig(configType = 'running') { const optData = await optRes.json(); const cachedVersion = optData.__metadata__?.cmts_version; - if (cachedVersion && cachedVersion !== currentVersion) { + // 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾 + if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) { const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`); if (doClear) { const allKeys = Object.keys(optData); @@ -3787,19 +3841,24 @@ window.applyBackupFilters = function() { // 渲染過濾後的結果 if (filteredData.length > 0) { + // 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode) + const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true'; + const displayAdmin = isGodMode ? 'inline-block' : 'none'; + tbody.innerHTML = filteredData.map(item => ` ${new Date(item.timestamp).toLocaleString()} ${item.snapshot_name} - + ${item.description || ''} ${item.config_type} - - + + + `).join(''); @@ -4124,44 +4183,127 @@ function closeModal() { document.addEventListener("DOMContentLoaded", checkScanButtonState); // ========================================== -// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯 +// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0) // ========================================== const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global']; +// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫 +function unlockAdminFeatures() { + // 1. 解鎖全域快取掃描按鈕 + ADMIN_BTN_IDS.forEach(id => { + const btn = document.getElementById(id); + if (btn) btn.style.display = 'inline-block'; + }); + + // 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕 + document.querySelectorAll('.debug-preview-btn').forEach(btn => { + btn.style.display = 'inline-block'; + }); + + // 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕 + document.querySelectorAll('.admin-only-action').forEach(btn => { + btn.style.display = 'inline-block'; + }); +} + document.addEventListener('DOMContentLoaded', () => { const appTitle = document.getElementById('app-title'); - function unlockAdminFeatures() { - ADMIN_BTN_IDS.forEach(id => { - const btn = document.getElementById(id); - if (btn) btn.style.display = 'inline-block'; - }); - - // 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕 - document.querySelectorAll('.debug-preview-btn').forEach(btn => { - btn.style.display = 'inline-block'; - }); + // 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效) + if (sessionStorage.getItem('godModeUnlocked') === 'true') { + unlockAdminFeatures(); } - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.get('mode') === 'godmode') unlockAdminFeatures(); + // 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門 + // 🌟 3. 連點標題觸發機制 let clickCount = 0; let clickTimer = null; if (appTitle) { appTitle.addEventListener('click', () => { + // 已解鎖則忽略 + if (sessionStorage.getItem('godModeUnlocked') === 'true') return; + clickCount++; if (clickCount === 5) { - unlockAdminFeatures(); - alert('🔓 已解鎖系統維護者模式!'); + openGodModeModal(); // 喚出密碼對話框 clickCount = 0; } clearTimeout(clickTimer); clickTimer = setTimeout(() => { clickCount = 0; }, 1000); }); } + + // 🌟 4. 綁定密碼框的 Enter 快捷鍵 + const pwInput = document.getElementById('godModePassword'); + if (pwInput) { + pwInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') window.verifyGodMode(); + }); + } }); +// 🌟 Modal 操作與 API 驗證邏輯 +window.openGodModeModal = function() { + const modal = document.getElementById('godModeModal'); + const input = document.getElementById('godModePassword'); + const errorSpan = document.getElementById('god-mode-error'); + + errorSpan.textContent = ''; + input.value = ''; + input.classList.remove('shake-animation'); + + modal.classList.add('active'); + setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦 +}; + +window.closeGodModeModal = function() { + document.getElementById('godModeModal').classList.remove('active'); +}; + +window.verifyGodMode = async function() { + const input = document.getElementById('godModePassword'); + const errorSpan = document.getElementById('god-mode-error'); + const btn = document.getElementById('btn-unlock-godmode'); + const pwd = input.value.trim(); + + if (!pwd) { + errorSpan.textContent = '密碼不可為空!'; + triggerShake(input); + return; + } + + btn.disabled = true; + btn.innerHTML = '⏳ 驗證中...'; + errorSpan.textContent = ''; + input.classList.remove('shake-animation'); + + try { + await apiVerifyGodMode(pwd); + + // 驗證成功 + sessionStorage.setItem('godModeUnlocked', 'true'); + closeGodModeModal(); + alert('🔓 密碼正確!已解鎖系統維護者模式。'); + unlockAdminFeatures(); + + } catch (err) { + // 驗證失敗 + errorSpan.textContent = '❌ 密碼錯誤,請重新輸入'; + triggerShake(input); + } finally { + btn.disabled = false; + btn.innerHTML = '🚀 解鎖'; + } +}; + +function triggerShake(element) { + element.classList.remove('shake-animation'); + void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發 + element.classList.add('shake-animation'); + element.focus(); +} + // 統一控制 4 顆按鈕的狀態 function setAdminButtonsState(isBusy, text = "⏳ 執行中...") { ADMIN_BTN_IDS.forEach(id => { @@ -4712,6 +4854,21 @@ button:hover { background-color: #3498db; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +/* ========================================= + 🚨 密碼輸入錯誤震動動畫 (Shake Effect) + ========================================= */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); } + 20%, 40%, 60%, 80% { transform: translateX(6px); } +} + +.shake-animation { + animation: shake 0.4s ease-in-out; + border-color: #e74c3c !important; /* 震動時外框變紅 */ + background-color: #fdedec !important; +} + ================================================================================ FILE: static/edit-mode.js @@ -4753,7 +4910,21 @@ export let currentEditDiffs = []; export async function releaseAllLocks() { const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); - if (keysToRelease.length === 0) return; + + // 🌟 1. 檢查當前是否處於 God Mode 狀態 + const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true'; + + // 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限 + sessionStorage.removeItem('godModeUnlocked'); + + if (keysToRelease.length === 0) { + // 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整 + if (wasGodModeUnlocked) { + alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。"); + location.reload(); + } + return; + } const releasePromises = keysToRelease.map(key => { const [host, path] = key.split('@@'); @@ -4764,8 +4935,8 @@ export async function releaseAllLocks() { }); await Promise.all(releasePromises); - alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。"); - location.reload(); + alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。"); + location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態 } async function sendHeartbeat(path, host, intervalId, lockKey) { @@ -5049,7 +5220,9 @@ export async function startEditLeaf(path, elementId) { await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; - if (leafCache && leafCache.options && leafCache.options.length > 0) { + + // 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來 + if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) { found = true; break; } } @@ -7612,6 +7785,36 @@ async def list_rpds(host: str, username: str, password: str): return {"rpds": []} +================================================================================ +FILE: routers/auth.py +================================================================================ +import os +import secrets +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from logger import get_logger + +logger = get_logger("app.auth") +router = APIRouter(tags=["Auth"]) + +# 定義請求本體格式 +class GodModeRequest(BaseModel): + password: str + +@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼") +async def verify_god_mode(req: GodModeRequest): + # 從環境變數讀取正確密碼,預設為 "admin999" + expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm") + + # 使用 compare_digest 防禦計時攻擊 + if secrets.compare_digest(req.password, expected_password): + logger.info("🔓 上帝模式解鎖成功!") + return {"status": "success"} + + logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)") + raise HTTPException(status_code=401, detail="Invalid authorization code") + + ================================================================================ FILE: routers/config.py ================================================================================ diff --git a/cmts_scraper.py b/cmts_scraper.py index 8b94926..a1193c3 100644 --- a/cmts_scraper.py +++ b/cmts_scraper.py @@ -185,9 +185,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con # 🌟 新增:在第一批次連線時,先抓取設備版本 if cmts_version == "unknown": - process.stdin.write("show version\n") + # 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住 + process.stdin.write("show version | nomore\n") await process.stdin.drain() - version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)") + version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)") match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output) if match: cmts_version = match.group(1) diff --git a/database.py b/database.py index edec941..98ba8eb 100644 --- a/database.py +++ b/database.py @@ -142,28 +142,36 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b if not pool: return False - # Extract known fields - cmts_version = metadata.get("cmts_version", "unknown") + cmts_version = metadata.get("cmts_version") last_scanned = metadata.get("last_scanned", None) - query = """ - INSERT INTO device_status (host, config_type, cmts_version, last_scanned) - VALUES ($1, $2, $3, $4) - ON CONFLICT (host, config_type) - DO UPDATE SET - cmts_version = EXCLUDED.cmts_version, - last_scanned = EXCLUDED.last_scanned, - updated_at = CURRENT_TIMESTAMP; - """ try: async with pool.acquire() as conn: - await conn.execute(query, host, config_type, cmts_version, last_scanned) + # 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號! + if cmts_version and cmts_version != "unknown": + query = """ + INSERT INTO device_status (host, config_type, cmts_version, last_scanned) + VALUES ($1, $2, $3, $4) + ON CONFLICT (host, config_type) + DO UPDATE SET + cmts_version = EXCLUDED.cmts_version, + last_scanned = EXCLUDED.last_scanned, + updated_at = CURRENT_TIMESTAMP; + """ + await conn.execute(query, host, config_type, cmts_version, last_scanned) + else: + query = """ + INSERT INTO device_status (host, config_type, last_scanned) + VALUES ($1, $2, $3) + ON CONFLICT (host, config_type) + DO UPDATE SET + last_scanned = EXCLUDED.last_scanned, + updated_at = CURRENT_TIMESTAMP; + """ + await conn.execute(query, host, config_type, last_scanned) return True - except asyncpg.PostgresError as e: - logger.error(f"❌ DB Error (upsert_device_status): {e}") - return False except Exception as e: - logger.error(f"❌ Unknown Error (upsert_device_status): {e}") + logger.error(f"❌ DB Error (upsert_device_status): {e}") return False async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]: diff --git a/index.html b/index.html index a251580..2ff8ccf 100644 --- a/index.html +++ b/index.html @@ -575,6 +575,31 @@ + + + diff --git a/main.py b/main.py index 4dd233d..52c2318 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager import database # 引入我們剛剛拆分出來的路由模組 -from routers import query, config, terminal, lock, leaf_options, backup, diagnostics +from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth @asynccontextmanager async def lifespan(app: FastAPI): @@ -29,6 +29,7 @@ app.include_router(leaf_options.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1") app.include_router(diagnostics.router, prefix="/api/v1") +app.include_router(auth.router, prefix="/api/v1") # WebSocket 通常獨立於 API 版本之外,所以不加前綴 app.include_router(terminal.router) diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..a9e84ea --- /dev/null +++ b/routers/auth.py @@ -0,0 +1,25 @@ +import os +import secrets +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from logger import get_logger + +logger = get_logger("app.auth") +router = APIRouter(tags=["Auth"]) + +# 定義請求本體格式 +class GodModeRequest(BaseModel): + password: str + +@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼") +async def verify_god_mode(req: GodModeRequest): + # 從環境變數讀取正確密碼,預設為 "admin999" + expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm") + + # 使用 compare_digest 防禦計時攻擊 + if secrets.compare_digest(req.password, expected_password): + logger.info("🔓 上帝模式解鎖成功!") + return {"status": "success"} + + logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)") + raise HTTPException(status_code=401, detail="Invalid authorization code") diff --git a/static/api.js b/static/api.js index 8c8e6ca..6c2c2d8 100644 --- a/static/api.js +++ b/static/api.js @@ -225,3 +225,20 @@ export async function apiSetLogLevel(module, level) { }); return response.json(); } + +// ========================================== +// 7. 授權與驗證 API (Auth) +// ========================================== +export async function apiVerifyGodMode(password) { + const response = await fetch('/api/v1/auth/god-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "驗證失敗"); + } + return response.json(); +} diff --git a/static/app.js b/static/app.js index 83fa07c..cb54e01 100644 --- a/static/app.js +++ b/static/app.js @@ -15,7 +15,8 @@ import { apiGetScanStatus, apiGetLockStatus, apiGetCmDiagnostics, apiListCms, apiListRpds, - apiGetLogLevels, apiSetLogLevel + apiGetLogLevels, apiSetLogLevel, + apiVerifyGodMode } from './api.js'; // 3. 共用工具模組 (Utils) @@ -599,7 +600,8 @@ async function fetchFullConfig(configType = 'running') { const optData = await optRes.json(); const cachedVersion = optData.__metadata__?.cmts_version; - if (cachedVersion && cachedVersion !== currentVersion) { + // 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾 + if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) { const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`); if (doClear) { const allKeys = Object.keys(optData); @@ -1558,19 +1560,24 @@ window.applyBackupFilters = function() { // 渲染過濾後的結果 if (filteredData.length > 0) { + // 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode) + const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true'; + const displayAdmin = isGodMode ? 'inline-block' : 'none'; + tbody.innerHTML = filteredData.map(item => ` ${new Date(item.timestamp).toLocaleString()} ${item.snapshot_name} - + ${item.description || ''} ${item.config_type} - - + + + `).join(''); @@ -1895,44 +1902,127 @@ function closeModal() { document.addEventListener("DOMContentLoaded", checkScanButtonState); // ========================================== -// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯 +// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0) // ========================================== const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global']; +// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫 +function unlockAdminFeatures() { + // 1. 解鎖全域快取掃描按鈕 + ADMIN_BTN_IDS.forEach(id => { + const btn = document.getElementById(id); + if (btn) btn.style.display = 'inline-block'; + }); + + // 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕 + document.querySelectorAll('.debug-preview-btn').forEach(btn => { + btn.style.display = 'inline-block'; + }); + + // 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕 + document.querySelectorAll('.admin-only-action').forEach(btn => { + btn.style.display = 'inline-block'; + }); +} + document.addEventListener('DOMContentLoaded', () => { const appTitle = document.getElementById('app-title'); - function unlockAdminFeatures() { - ADMIN_BTN_IDS.forEach(id => { - const btn = document.getElementById(id); - if (btn) btn.style.display = 'inline-block'; - }); - - // 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕 - document.querySelectorAll('.debug-preview-btn').forEach(btn => { - btn.style.display = 'inline-block'; - }); + // 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效) + if (sessionStorage.getItem('godModeUnlocked') === 'true') { + unlockAdminFeatures(); } - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.get('mode') === 'godmode') unlockAdminFeatures(); + // 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門 + // 🌟 3. 連點標題觸發機制 let clickCount = 0; let clickTimer = null; if (appTitle) { appTitle.addEventListener('click', () => { + // 已解鎖則忽略 + if (sessionStorage.getItem('godModeUnlocked') === 'true') return; + clickCount++; if (clickCount === 5) { - unlockAdminFeatures(); - alert('🔓 已解鎖系統維護者模式!'); + openGodModeModal(); // 喚出密碼對話框 clickCount = 0; } clearTimeout(clickTimer); clickTimer = setTimeout(() => { clickCount = 0; }, 1000); }); } + + // 🌟 4. 綁定密碼框的 Enter 快捷鍵 + const pwInput = document.getElementById('godModePassword'); + if (pwInput) { + pwInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') window.verifyGodMode(); + }); + } }); +// 🌟 Modal 操作與 API 驗證邏輯 +window.openGodModeModal = function() { + const modal = document.getElementById('godModeModal'); + const input = document.getElementById('godModePassword'); + const errorSpan = document.getElementById('god-mode-error'); + + errorSpan.textContent = ''; + input.value = ''; + input.classList.remove('shake-animation'); + + modal.classList.add('active'); + setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦 +}; + +window.closeGodModeModal = function() { + document.getElementById('godModeModal').classList.remove('active'); +}; + +window.verifyGodMode = async function() { + const input = document.getElementById('godModePassword'); + const errorSpan = document.getElementById('god-mode-error'); + const btn = document.getElementById('btn-unlock-godmode'); + const pwd = input.value.trim(); + + if (!pwd) { + errorSpan.textContent = '密碼不可為空!'; + triggerShake(input); + return; + } + + btn.disabled = true; + btn.innerHTML = '⏳ 驗證中...'; + errorSpan.textContent = ''; + input.classList.remove('shake-animation'); + + try { + await apiVerifyGodMode(pwd); + + // 驗證成功 + sessionStorage.setItem('godModeUnlocked', 'true'); + closeGodModeModal(); + alert('🔓 密碼正確!已解鎖系統維護者模式。'); + unlockAdminFeatures(); + + } catch (err) { + // 驗證失敗 + errorSpan.textContent = '❌ 密碼錯誤,請重新輸入'; + triggerShake(input); + } finally { + btn.disabled = false; + btn.innerHTML = '🚀 解鎖'; + } +}; + +function triggerShake(element) { + element.classList.remove('shake-animation'); + void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發 + element.classList.add('shake-animation'); + element.focus(); +} + // 統一控制 4 顆按鈕的狀態 function setAdminButtonsState(isBusy, text = "⏳ 執行中...") { ADMIN_BTN_IDS.forEach(id => { diff --git a/static/edit-mode.js b/static/edit-mode.js index 5c8b048..3ce1718 100644 --- a/static/edit-mode.js +++ b/static/edit-mode.js @@ -35,7 +35,21 @@ export let currentEditDiffs = []; export async function releaseAllLocks() { const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); - if (keysToRelease.length === 0) return; + + // 🌟 1. 檢查當前是否處於 God Mode 狀態 + const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true'; + + // 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限 + sessionStorage.removeItem('godModeUnlocked'); + + if (keysToRelease.length === 0) { + // 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整 + if (wasGodModeUnlocked) { + alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。"); + location.reload(); + } + return; + } const releasePromises = keysToRelease.map(key => { const [host, path] = key.split('@@'); @@ -46,8 +60,8 @@ export async function releaseAllLocks() { }); await Promise.all(releasePromises); - alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。"); - location.reload(); + alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。"); + location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態 } async function sendHeartbeat(path, host, intervalId, lockKey) { @@ -331,7 +345,9 @@ export async function startEditLeaf(path, elementId) { await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; - if (leafCache && leafCache.options && leafCache.options.length > 0) { + + // 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來 + if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) { found = true; break; } } diff --git a/static/style.css b/static/style.css index dc22d77..9da9d00 100644 --- a/static/style.css +++ b/static/style.css @@ -272,3 +272,18 @@ button:hover { background-color: #3498db; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +/* ========================================= + 🚨 密碼輸入錯誤震動動畫 (Shake Effect) + ========================================= */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); } + 20%, 40%, 60%, 80% { transform: translateX(6px); } +} + +.shake-animation { + animation: shake 0.4s ease-in-out; + border-color: #e74c3c !important; /* 震動時外框變紅 */ + background-color: #fdedec !important; +}