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