+
+
-
-
-
⚠️ 即將寫入的指令
+
+
-
-
-
-
-
-
-
-
-
-
×
+
+
+
⚠️ 即將寫入的指令
+
+
+
+
+
+
×
+
+
+
+
+
+
+
-
-
-
-
-
-
diff --git a/routers/config.py b/routers/config.py
index bd57609..f2e044b 100644
--- a/routers/config.py
+++ b/routers/config.py
@@ -331,6 +331,77 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "success", "data": perfect_tree}
except Exception as e:
return {"status": "error", "message": str(e)}
+
+@router.get("/cmts-full-config/stream")
+async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
+ """獲取整台 CMTS 的完整配置 (串流進度回報版)"""
+
+ async def config_streamer():
+ try:
+ # 1. 廣播:正在連線
+ yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
+ await asyncio.sleep(0.1) # 讓前端有時間渲染
+
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+
+ # 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
+ net_connect = await asyncio.to_thread(ConnectHandler, **device)
+
+ # 2. 廣播:正在下載
+ yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
+
+ if config_type == "full":
+ await asyncio.to_thread(net_connect.send_command_timing, "config")
+ command = "show full-configuration | nomore"
+ raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
+ await asyncio.to_thread(net_connect.send_command_timing, "exit")
+ else:
+ command = "show running-config | nomore"
+ raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
+
+ await asyncio.to_thread(net_connect.disconnect)
+
+ # 3. 廣播:正在解析
+ yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
+ await asyncio.sleep(0.1)
+
+ clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
+ clean_output = clean_output.lstrip()
+
+ base_tree = parse_cli_to_tree(clean_output)
+ perfect_tree = deep_split_tree(base_tree)
+
+ # ==========================================
+ # 🌟 深度過濾攔截器 (維持原樣)
+ # ==========================================
+ if not skip_filter:
+ yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
+ await asyncio.sleep(0.1)
+
+ hidden_keys = await load_tree_filters(config_type)
+
+ for path in hidden_keys:
+ keys = path.split('::')
+ current = perfect_tree
+ for k in keys[:-1]:
+ if isinstance(current, dict) and k in current:
+ current = current[k]
+ else:
+ current = None
+ break
+ if isinstance(current, dict) and keys[-1] in current:
+ current.pop(keys[-1], None)
+ # ==========================================
+
+ # 4. 廣播:完成並傳送最終資料
+ yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
+
+ except Exception as e:
+ yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
+
+ # 回傳 NDJSON 串流格式
+ return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
# ==========================================
# 🌟 系統設定 API (System Settings)
diff --git a/shared.py b/shared.py
index dcaff08..8870c82 100644
--- a/shared.py
+++ b/shared.py
@@ -28,44 +28,28 @@ def parse_cli_to_tree(cli_text: str) -> dict:
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
"""
# ==========================================
- # 🌟 階段 0:預處理 (修復終端機 200 字元自動折行問題)
+ # 🌟 階段 0:預處理 (清理雜訊,移除危險的自動黏合)
# ==========================================
+ cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
+
raw_lines = cli_text.split('\n')
fixed_lines = []
- # 定義常見的 Root 指令關鍵字,避免把正常的指令誤判為斷行
- root_keywords = (
- 'alias', 'ssh', 'hostname', 'logging', 'cable', 'ipdr',
- 'snmp-server', 'aaa', 'radius-server', 'privilege', 'cli', 'packetcable',
- 'system', 'network', 'clock', 'no'
- )
-
for line in raw_lines:
- line = line.replace('\r', '')
+ line = ansi_escape.sub('', line)
+ line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
+
if not line.strip():
continue
- # 💡 判斷是否為被截斷的行:
- # 1. 前一行長度非常長 (大於 190 字元,根據您的截圖,極限是 199)
- # 2. 當前行「沒有縮排」(終端機自動折行會把字元推到最左邊第 0 格)
- # 3. 當前行不是註解 '!'
- if fixed_lines and len(fixed_lines[-1]) > 190 and not line.startswith(' ') and not line.startswith('!'):
- # 取得當前行的第一個單字
- first_word = line.strip().split()[0] if line.strip() else ""
-
- # 確保它不是一個正常的 Root 指令
- if first_word not in root_keywords:
- # ✅ 觸發黏合邏輯:將這行直接接在上一行後面
- fixed_lines[-1] = fixed_lines[-1] + line
- continue
-
+ # 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
fixed_lines.append(line)
- # 統一去除右側多餘空白,準備進入階段 1
lines = [line.rstrip() for line in fixed_lines if line.strip()]
# ==========================================
- # 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
+ # 🌟 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
# ==========================================
def build_raw_tree(start_idx, current_indent):
nodes = []
@@ -75,17 +59,13 @@ def parse_cli_to_tree(cli_text: str) -> dict:
stripped = line.strip()
indent = len(line) - len(line.lstrip())
- # 🌟 嚴格判斷 1:遇到「真實指令」縮排退回,才代表區塊結束
- # (加上 stripped != '!',防止設備偶發的「0 縮排 !」導致區塊被誤殺)
if indent < current_indent and stripped != '!':
return nodes, i
- # 🌟 嚴格判斷 2:處理 ! (無論縮排多少,都當純分隔符消耗掉)
if stripped == '!':
i += 1
continue
- # 🌟 探測是否有子節點 (強化版:往下尋找第一個「非 !」的真實指令來判斷)
has_children = False
next_i = i + 1
while next_i < len(lines) and lines[next_i].strip() == '!':
@@ -109,16 +89,14 @@ def parse_cli_to_tree(cli_text: str) -> dict:
raw_tree, _ = build_raw_tree(0, 0)
# ==========================================
- # 階段 2:邏輯群組化與字典合併
+ # 🌟 階段 2:邏輯群組化與字典合併 (保留嚴格型別防護)
# ==========================================
def deep_merge(dict1, dict2):
- """深度合併兩個字典 (解決同名資料夾與指令群組的融合)"""
for k, v in dict2.items():
if k in dict1:
if isinstance(dict1[k], dict) and isinstance(v, dict):
deep_merge(dict1[k], v)
else:
- # 發生碰撞時的安全機制
idx = 1
while f"{k} [{idx}]" in dict1:
idx += 1
@@ -127,7 +105,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
dict1[k] = v
def nest_folder_key(key_string, value_dict, target_dict):
- """將 'cable mac-domain 13' 拆解為巢狀字典"""
parts = key_string.split()
current = target_dict
for i, part in enumerate(parts):
@@ -138,14 +115,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
if isinstance(current[part], dict) and isinstance(value_dict, dict):
deep_merge(current[part], value_dict)
else:
- current[f"{part} (資料夾)"] = value_dict
+ idx = 1
+ while f"{part} (衝突 {idx})" in current:
+ idx += 1
+ current[f"{part} (衝突 {idx})"] = value_dict
else:
if part not in current or not isinstance(current[part], dict):
- current[part] = {}
+ if part in current and not isinstance(current[part], dict):
+ old_val = current[part]
+ current[part] = {"[0]": old_val}
+ else:
+ current[part] = {}
current = current[part]
def group_leaves(leaf_strings):
- """將單行指令依據共同前綴進行遞迴群組化"""
grouped = {}
first_word_map = defaultdict(list)
@@ -165,8 +148,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
group_dict = {}
if valid_remainders:
sub_grouped = group_leaves(valid_remainders)
-
- # 判斷是否全為純陣列值 (例如 IP 列表)
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
if is_all_empty_vals:
@@ -183,7 +164,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
else:
group_dict[k] = v
- # 補回完全沒有後續參數的指令
for _ in range(empty_count):
idx = 0
while f"[{idx}]" in group_dict:
@@ -196,12 +176,10 @@ def parse_cli_to_tree(cli_text: str) -> dict:
def fold_nodes(nodes):
result = {}
- # 1. 處理資料夾 (Folders)
for node in [n for n in nodes if n["type"] == "folder"]:
folded_children = fold_nodes(node["children"])
nest_folder_key(node["content"], folded_children, result)
- # 2. 處理單行指令 (Leaves)
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
if leaves:
leaf_dict = group_leaves(leaves)
diff --git a/static/app.js b/static/app.js
index bef7a4f..a9d4df9 100644
--- a/static/app.js
+++ b/static/app.js
@@ -387,28 +387,29 @@ async function executeQuery(mode) {
}
}
-// 載入完整設備配置並生成樹狀圖,預設為 running
+// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
- // 🌟 動態抓取當前模式的專屬容器
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
- if (loadingMsg) loadingMsg.style.display = 'inline-block';
+ if (loadingMsg) {
+ loadingMsg.style.display = 'inline-block';
+ loadingMsg.innerHTML = '⏳ 準備連線至設備...';
+ }
if (treeContainer) treeContainer.innerHTML = '';
- let needAutoScan = false; // 🌟 標記是否需要自動掃描
+ let needAutoScan = false;
+ let progressInterval = null;
try {
- // --- 1. 版本守門員 ---
+ // --- 1. 版本守門員 (維持原樣) ---
try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
-
- // 🌟 修正 1:讀取選項快取時,必須加上 config_type 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
@@ -417,12 +418,8 @@ async function fetchFullConfig(configType = 'running') {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
-
- // 🌟 修正 2:清空快取時,必須傳遞 configType 告訴後端清哪一個
await apiClearCache(connInfo.host, allKeys, configType);
-
- alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
- needAutoScan = true; // 🌟 觸發自動掃描旗標
+ needAutoScan = true;
}
}
}
@@ -430,53 +427,109 @@ async function fetchFullConfig(configType = 'running') {
console.warn("版本檢查失敗,略過", vErr);
}
- // --- 2. 載入完整配置 ---
- const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
-
- // 🌟 新增:將原始資料存入全域變數,供全域掃描使用
- window.currentTreeData = result.data;
+ // --- 2. 載入完整配置 (ReadableStream 串流接收) ---
+ const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
- // 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
+ if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ let buffer = "";
+ let finalTreeData = null;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ let lines = buffer.split('\n');
+ buffer = lines.pop();
+
+ for (let line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const data = JSON.parse(line);
+
+ if (data.status === 'progress') {
+ if (data.message.includes('正在下載')) {
+ let progress = 0;
+ if (progressInterval) clearInterval(progressInterval);
+
+ progressInterval = setInterval(() => {
+ progress += (95 - progress) * 0.06;
+ if (loadingMsg) {
+ loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
+ }
+ }, 500);
+ } else {
+ // 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
+ if (progressInterval) {
+ clearInterval(progressInterval);
+ progressInterval = null;
+
+ // 1. 強制填滿 100% 並給予成功提示
+ if (loadingMsg) {
+ loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
+ loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
+ }
+
+ // 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
+ await new Promise(resolve => setTimeout(resolve, 600));
+
+ // 3. 恢復原本的顏色
+ if (loadingMsg) loadingMsg.style.color = "";
+ }
+
+ // 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
+ if (loadingMsg) loadingMsg.innerHTML = data.message;
+ }
+ }
+ else if (data.status === 'success') {
+ finalTreeData = data.data;
+ }
+ else if (data.status === 'error') {
+ throw new Error(data.message);
+ }
+ } catch (e) {
+ console.warn("解析串流 JSON 失敗:", line);
+ }
+ }
+ }
+
+ if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
+
+ window.currentTreeData = finalTreeData;
+
+ // --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
- if (currentSelectedTask !== expectedTask) {
- console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
- return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
- }
- if (result.status === 'success') {
- if (treeContainer) {
- // 🌟 傳入 configType,讓 ID 加上前綴
- treeContainer.innerHTML = buildTree(result.data, '', false, configType);
-
- // 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
- if (loadingMsg) loadingMsg.style.display = 'none';
-
- // 🌟 3. 檢查後端是否已經有人在掃描
- const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
-
- if (isSomeoneScanning) {
- alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
- lockScanButton(60000);
- } else {
- enableGlobalScanButton();
-
- // 如果剛清空快取,自動啟動掃描
- if (needAutoScan) {
- setTimeout(() => {
- // 🌟 修正 3:全域掃描也必須知道現在要掃哪一棵樹
- scanGlobalMissingOptions(configType);
- }, 500);
- }
+ if (currentSelectedTask !== expectedTask) return;
+
+ if (treeContainer) {
+ treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
+ if (loadingMsg) loadingMsg.style.display = 'none';
+
+ const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
+ if (isSomeoneScanning) {
+ alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
+ lockScanButton(60000);
+ } else {
+ enableGlobalScanButton();
+ if (needAutoScan) {
+ setTimeout(() => scanGlobalMissingOptions(configType), 500);
}
}
- } else {
- if (treeContainer) treeContainer.innerHTML = `
❌ 錯誤: ${result.message}
`;
}
+
} catch (error) {
if (treeContainer) treeContainer.innerHTML = `
❌ 連線或解析失敗: ${error.message}
`;
} finally {
- if (loadingMsg) loadingMsg.style.display = 'none';
+ if (progressInterval) clearInterval(progressInterval);
+ if (loadingMsg) {
+ loadingMsg.style.display = 'none';
+ loadingMsg.style.color = ""; // 確保重置顏色
+ }
}
}
@@ -1152,12 +1205,11 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const connInfo = getGlobalConnectionInfo();
const modalOutput = document.getElementById('modalOutput');
- // 2. 建立即時 Log 視窗 UI (模擬終端機風格)
+ // 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
+ // 移除 min-height 和 overflow-y,讓它完全撐滿父容器
modalOutput.innerHTML = `
-
- ⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
-
-
+
+
[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...
[系統] 準備連線至設備 ${connInfo.host}...
`;
@@ -1199,9 +1251,13 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
if (data.status === 'progress') {
logContainer.innerHTML += `
[${timeStr}] ${data.message}
`;
} else if (data.status === 'success') {
+ // 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
+ let cleanOutput = data.output || '無';
+ cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
+
logContainer.innerHTML += `
[${timeStr}] ${data.message}
-
設備 Commit 回傳:
${data.output || '無'}
+
設備 Commit 回傳:
${cleanOutput}
`;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
} else if (data.status === 'error') {
diff --git a/static/edit-mode.js b/static/edit-mode.js
index 0f72bce..1846a56 100644
--- a/static/edit-mode.js
+++ b/static/edit-mode.js
@@ -481,12 +481,10 @@ async function executeGeneratedCLI(script) {
const outputEl = document.getElementById('side-execution-result');
- // 1. 建立即時 Log 視窗 UI
+ // 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
outputEl.innerHTML = `
-
- ⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
-
-
+
+
[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...
[系統] 準備連線至設備 ${connInfo.host}...
`;
diff --git a/static/style.css b/static/style.css
index 9ac5bcf..d40828a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -96,7 +96,7 @@ h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-si
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
/* 4. 內容區塊 */
-.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; }
+.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; box-sizing: border-box; max-width: 100%; overflow: hidden; }
.tab-content.active { display: flex; flex-direction: column; }
/* 5. 控制列樣式 (微縮元件) */
@@ -106,8 +106,8 @@ button { padding: 6px 14px; background-color: #2980b9; color: white; border: non
button:hover { background-color: #3498db; }
/* 6. 終端機與輸出區塊 */
-#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; }
-.xterm { padding: 10px; }
+#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; min-height: 0; overflow: hidden; position: relative; }
+.xterm { padding: 10px; height: 100%; box-sizing: border-box; }
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
@@ -134,8 +134,34 @@ button:hover { background-color: #3498db; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
.modal-close:hover { color: #e74c3c; }
-.modal-body { flex-grow: 1; padding: 20px; overflow: auto; background-color: #1e1e1e; }
-.readonly-terminal { margin: 0; color: #e0e0e0; font-family: 'Courier New', Courier, monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
+/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
+.modal-body {
+ flex-grow: 1;
+ padding: 0 !important; /* 🌟 關鍵:移除原本 20px 的留白 */
+ background-color: #1e1e1e;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden; /* 將捲軸交給內層的 terminal 處理 */
+}
+
+/* 2. 徹底移除終端機的邊框,並接管滿版空間 */
+.readonly-terminal {
+ margin: 0 !important;
+ padding: 15px 20px !important; /* 保留適當的文字呼吸空間,不讓字完全貼死邊緣 */
+ color: #e0e0e0;
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ background-color: transparent !important; /* 🌟 關鍵:背景透明,融入 Modal */
+ border: none !important; /* 🌟 關鍵:拔除所有細邊框 */
+ box-shadow: none !important;
+ border-radius: 0 !important;
+ flex-grow: 1;
+ overflow-y: auto; /* 垂直捲動 */
+ overflow-x: auto; /* 水平捲動 */
+ white-space: pre; /* 強制不折行以支援水平滑動,保護版面 */
+ box-sizing: border-box;
+}
/* 樹狀圖節點的容器樣式 */
.tree-node-header {
@@ -238,4 +264,12 @@ button:hover { background-color: #3498db; }
font-weight: bold;
}
+/* 針對 SweetAlert2 彈窗內的終端機強制去框 */
+.swal2-html-container pre {
+ border: none !important;
+ background: transparent !important;
+ padding: 15px !important;
+ margin: 0 !important;
+}
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }