diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index 4732005..429901c 100644 --- a/components/ts_automation/src/ts_action_manager.c +++ b/components/ts_automation/src/ts_action_manager.c @@ -1233,8 +1233,8 @@ esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ssh_ref, } if (cmd_config.nohup) { /* Generate safe name from command name for log/pid files - * Use cmd.name (user-readable identifier) for consistency - * varName is only for service mode status variables + * Priority: cmd.name alphanumeric chars -> cmd_id alphanumeric chars -> "cmd" + * Pure Chinese names produce empty string, so fallback to cmd_id */ char safe_name[32] = {0}; const char *src = cmd_config.name; @@ -1246,18 +1246,30 @@ esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ssh_ref, safe_name[j++] = src[i]; } } + if (j == 0) { + /* Fallback to cmd_id (e.g. "emb-pull-up" -> "embpullup") */ + const char *id_src = ssh_ref->cmd_id; + for (int i = 0; id_src[i] && j < 20; i++) { + if ((id_src[i] >= 'a' && id_src[i] <= 'z') || + (id_src[i] >= 'A' && id_src[i] <= 'Z') || + (id_src[i] >= '0' && id_src[i] <= '9')) { + safe_name[j++] = id_src[i]; + } + } + } if (j == 0) { strcpy(safe_name, "cmd"); } - ESP_LOGI(TAG, "nohup safe_name='%s' (from name='%s')", safe_name, cmd_config.name); + ESP_LOGI(TAG, "nohup safe_name='%s' (from name='%s', id='%s')", safe_name, cmd_config.name, ssh_ref->cmd_id); /* nohup command with PID file for process tracking - * Format: nohup > 2>&1 & echo $! > - * The echo $! captures the background process PID + * Format: nohup > 2>&1 & echo $! > ; sleep 0.3; cat + * 注意:$! 可能获取中间 shell PID 而非真实进程 PID(差 1), + * 前端已通过 PID-1 fallback 处理此情况 */ snprintf(nohup_cmd, TS_SSH_CMD_COMMAND_MAX + 128, - "nohup %s > /tmp/ts_nohup_%s.log 2>&1 & echo $! > /tmp/ts_nohup_%s.pid", - expanded_cmd, safe_name, safe_name); + "nohup %s > /tmp/ts_nohup_%s.log 2>&1 & echo $! > /tmp/ts_nohup_%s.pid; sleep 0.3; cat /tmp/ts_nohup_%s.pid", + expanded_cmd, safe_name, safe_name, safe_name); ESP_LOGI(TAG, "SSH nohup mode: %s", nohup_cmd); } @@ -1382,7 +1394,7 @@ esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ssh_ref, cmd_config.ready_pattern[0] && cmd_config.var_name[0]) { /* Generate safe name (same logic as nohup wrapper above) - * Use cmd.name for file paths, var_name only for status variables + * Priority: cmd.name -> cmd_id -> "cmd" */ char safe_name[32] = {0}; const char *src = cmd_config.name; @@ -1394,6 +1406,16 @@ esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ssh_ref, safe_name[j++] = src[i]; } } + if (j == 0) { + const char *id_src = ssh_ref->cmd_id; + for (int i = 0; id_src[i] && j < 20; i++) { + if ((id_src[i] >= 'a' && id_src[i] <= 'z') || + (id_src[i] >= 'A' && id_src[i] <= 'Z') || + (id_src[i] >= '0' && id_src[i] <= '9')) { + safe_name[j++] = id_src[i]; + } + } + } if (j == 0) { strcpy(safe_name, "cmd"); } diff --git a/components/ts_security/src/ts_ssh_client.c b/components/ts_security/src/ts_ssh_client.c index 9922d40..82be18b 100644 --- a/components/ts_security/src/ts_ssh_client.c +++ b/components/ts_security/src/ts_ssh_client.c @@ -620,15 +620,21 @@ esp_err_t ts_ssh_exec(ts_ssh_session_t session, const char *command, ts_ssh_exec result->stdout_len = 0; result->stderr_len = 0; - /* 循环读取直到完成 */ + /* 循环读取直到 channel EOF。 + * 内层 do-while 批量 drain 当前所有可用数据(rc > 0 时持续读)。 + * 外层用 libssh2_channel_eof() 判断是否结束。 + * 原来的 bug:drain 后 rc==0,走 else break 过早退出,丢失后续数据。 + * 安全措施:非 EAGAIN 错误立即返回,防止无限循环;最多等 300 轮(约 5 分钟)。 */ + int wait_rounds = 0; for (;;) { char buffer[512]; - - /* 读取 stdout */ + int got_data = 0; + + /* Drain stdout —— 一次性读完当前所有可用数据 */ do { rc = libssh2_channel_read(channel, buffer, sizeof(buffer)); if (rc > 0) { - /* 扩展缓冲区 */ + got_data = 1; while (result->stdout_len + rc >= stdout_capacity) { stdout_capacity *= 2; char *new_buf = TS_REALLOC_PSRAM(result->stdout_data, stdout_capacity); @@ -642,14 +648,19 @@ esp_err_t ts_ssh_exec(ts_ssh_session_t session, const char *command, ts_ssh_exec } memcpy(result->stdout_data + result->stdout_len, buffer, rc); result->stdout_len += rc; + } else if (rc < 0 && rc != LIBSSH2_ERROR_EAGAIN) { + set_error(session, "Channel read error: %d", rc); + ts_ssh_exec_result_free(result); + libssh2_channel_free(channel); + return ESP_FAIL; } } while (rc > 0); - /* 读取 stderr */ + /* Drain stderr —— 同理 */ do { rc = libssh2_channel_read_stderr(channel, buffer, sizeof(buffer)); if (rc > 0) { - /* 扩展缓冲区 */ + got_data = 1; while (result->stderr_len + rc >= stderr_capacity) { stderr_capacity *= 2; char *new_buf = TS_REALLOC_PSRAM(result->stderr_data, stderr_capacity); @@ -663,15 +674,32 @@ esp_err_t ts_ssh_exec(ts_ssh_session_t session, const char *command, ts_ssh_exec } memcpy(result->stderr_data + result->stderr_len, buffer, rc); result->stderr_len += rc; + } else if (rc < 0 && rc != LIBSSH2_ERROR_EAGAIN) { + set_error(session, "Channel stderr read error: %d", rc); + ts_ssh_exec_result_free(result); + libssh2_channel_free(channel); + return ESP_FAIL; } } while (rc > 0); - /* 检查是否完成 */ - if (rc == LIBSSH2_ERROR_EAGAIN) { - wait_socket(session->sock, session->session, 1000); - } else { + /* 用 channel EOF 判断是否结束(不再依赖 rc 的值) */ + if (libssh2_channel_eof(channel)) { break; } + + /* 收到数据则重置等待计数;无数据则累计,超过 300 轮(~5 分钟)视为超时 */ + if (got_data) { + wait_rounds = 0; + } else { + wait_rounds++; + if (wait_rounds > 300) { + set_error(session, "Read timeout: no EOF after %d wait rounds", wait_rounds); + break; /* 不返回错误,保留已读数据 */ + } + } + + /* 未 EOF:等待更多数据到达后再循环 */ + wait_socket(session->sock, session->session, 1000); } /* 添加字符串终止符 */ diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index cd5a324..c6bd019 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -362,7 +362,7 @@ button.btn-gray:hover, background: var(--bg-card); padding: 24px; border-radius: var(--radius-lg); - min-width: 320px; + min-width: 360px; max-width: 90%; transform: translateY(0); transition: transform 0.3s ease; @@ -418,6 +418,7 @@ button.btn-gray:hover, width: 32px; height: 32px; min-width: 32px; + margin-left: auto; display: flex; align-items: center; justify-content: center; @@ -739,12 +740,12 @@ button.btn-gray:hover, .config-row input[type="text"], .config-row input[type="number"] { - padding: 7px 10px; + padding: 9px 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); - font-size: 0.8rem; + font-size: 0.875rem; font-family: inherit; box-shadow: var(--shadow-sm); } @@ -761,12 +762,12 @@ button.btn-gray:hover, } .config-row select { - padding: 7px 10px; + padding: 9px 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); - font-size: 0.8rem; + font-size: 0.875rem; font-family: inherit; box-shadow: var(--shadow-sm); } @@ -1046,24 +1047,31 @@ button.btn-gray:hover, .form-group label { display: block; - margin-bottom: 4px; + margin-bottom: 6px; font-weight: 500; + font-size: 0.875rem; } .form-group input:not([type="checkbox"]):not([type="radio"]), .form-group select, .form-group textarea { + display: block; width: 100%; - padding: 7px 12px; + padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); - font-size: 0.8rem; + font-size: 0.875rem; font-family: inherit; background: var(--bg-card); color: var(--text-primary); box-shadow: var(--shadow-sm); } +.form-group small { + display: block; + margin-top: 4px; +} + .form-group input[type="checkbox"] { width: auto; min-width: 18px; @@ -1112,6 +1120,28 @@ button.btn-gray:hover, box-shadow: 0 0 0 3px var(--blue-50); } +/* Base .input / .form-control class — consistent sizing for inputs outside .form-group */ +.input, +.form-control { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-family: inherit; + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-sm); + box-sizing: border-box; +} + +.input:focus, +.form-control:focus { + outline: none; + border-color: var(--blue-500); + box-shadow: 0 0 0 3px var(--blue-50); +} + .form-actions { display: flex; gap: 12px; @@ -2192,7 +2222,8 @@ button.btn-gray:hover, width: 80px; height: 4px; -webkit-appearance: none; - background: var(--card-bg); + appearance: none; + background: var(--border-color); border-radius: 2px; } @@ -2316,7 +2347,8 @@ button.btn-gray:hover, width: 100%; height: 6px; -webkit-appearance: none; - background: var(--card-bg); + appearance: none; + background: var(--border-color); border-radius: 3px; cursor: pointer; } @@ -2454,7 +2486,8 @@ button.btn-gray:hover, flex: 1; height: 6px; -webkit-appearance: none; - background: var(--card-bg); + appearance: none; + background: var(--border-color); border-radius: 3px; } @@ -2557,12 +2590,12 @@ button.btn-gray:hover, } .matrix-controls select { - padding: 6px 8px; + padding: 8px 10px; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); - font-size: 0.8rem; + font-size: 0.875rem; cursor: pointer; } @@ -2671,7 +2704,8 @@ button.btn-gray:hover, flex: 1; height: 6px; -webkit-appearance: none; - background: var(--card-bg); + appearance: none; + background: var(--border-color); border-radius: 3px; } @@ -2691,10 +2725,19 @@ button.btn-gray:hover, color: var(--text-color); } -/* 小按钮 */ -.btn-sm { - padding: 6px 12px; - font-size: 0.85rem; +/* 小按钮(全站统一) */ +.btn-sm, +.btn.btn-sm { + padding: 5px 10px; + font-size: 0.8rem; + box-shadow: none !important; /* 消除 btn-gray 与 btn-danger 的阴影差异 */ +} +/* btn-sm 内图标固定宽度 — 不同 RemixIcon 字符宽度不同, + 固定 1em 后所有图标占据相同空间,按钮宽度完全一致 */ +.btn.btn-sm > i { + display: inline-block; + width: 1em; + text-align: center; } .btn-success { @@ -3299,8 +3342,8 @@ button.btn-gray:hover, /* 安全页按钮统一为 btn-sm 尺寸 */ .page-security .btn, .page-security .btn-sm { - padding: 5px 12px; - font-size: 0.85rem; + padding: 5px 10px; + font-size: 0.8rem; } /* 安全页按钮 icon 与文字间距统一(与其他页面一致) */ .page-security .btn { @@ -3331,8 +3374,8 @@ button.btn-gray:hover, .page-security .form-actions .btn, .page-security .form-actions .btn-sm { - padding: 5px 12px; - font-size: 0.85rem; + padding: 5px 10px; + font-size: 0.8rem; } .page-security h1, @@ -3432,11 +3475,7 @@ button.btn-gray:hover, font-family: monospace; } -/* 小按钮 */ -.btn-sm { - padding: 5px 12px; - font-size: 0.85rem; -} +/* 小按钮 → 已合并到全站统一 .btn-sm */ /* ========================================================================= 文件管理页面样式 @@ -3733,9 +3772,6 @@ button.btn-gray:hover, } .file-actions-cell .btn-sm { - padding: 4px 8px; - font-size: 0.8rem; - display: inline-block; margin: 0 2px; } @@ -3995,9 +4031,9 @@ button.btn-gray:hover, .config-item input[type="number"], .config-item input[type="password"], .config-item select { - padding: 8px 12px; + padding: 9px 12px; border: 1px solid var(--border-color); - border-radius: 6px; + border-radius: var(--radius-sm); background: var(--bg-color); color: var(--text-color); font-size: 0.9rem; @@ -4315,9 +4351,9 @@ button.btn-gray:hover, .wifi-mode-selector select { flex: 1; - padding: 6px 10px; + padding: 8px 12px; border: 1px solid var(--border-color); - border-radius: 4px; + border-radius: var(--radius-sm); font-size: 0.9rem; } @@ -4396,10 +4432,10 @@ button.btn-gray:hover, .service-config .input-sm { flex: 1; - padding: 6px 10px; + padding: 8px 10px; border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 0.85rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; } .service-actions { @@ -4599,18 +4635,14 @@ button.btn-gray:hover, font-size: 0.9rem; } -/* 小型按钮 */ -.btn-sm { - padding: 4px 10px; - font-size: 0.85rem; -} +/* 小型按钮 → 已合并到全站统一 .btn-sm */ /* 小型选择框 */ .select-sm { - padding: 4px 8px; + padding: 6px 10px; font-size: 0.85rem; border: 1px solid var(--border-color); - border-radius: 4px; + border-radius: var(--radius-sm); } /* 小型模态框 */ @@ -4634,7 +4666,7 @@ button.btn-gray:hover, gap: 8px; cursor: pointer; height: 100%; - padding-top: 20px; + padding-top: 24px; } .checkbox-label input[type="checkbox"] { @@ -5205,7 +5237,8 @@ button.btn-gray:hover, /* Section 通用样式 */ .page-automation .section { - margin-bottom: 24px; + margin-bottom: 20px; + margin-top: 12px; } .page-automation .section-header { @@ -5395,11 +5428,12 @@ button.btn-gray:hover, width: 100%; border-collapse: collapse; font-size: 0.9rem; + table-layout: auto; } .page-automation .data-table th { background: var(--bg-color); - padding: 10px 12px; + padding: 10px 14px; text-align: left; font-weight: 600; font-size: 0.85rem; @@ -5409,7 +5443,7 @@ button.btn-gray:hover, } .page-automation .data-table td { - padding: 10px 12px; + padding: 10px 14px; border-bottom: 1px solid var(--border-color); vertical-align: middle; } @@ -5536,6 +5570,17 @@ button.btn-gray:hover, min-width: 0; } +/* 确保自动化模态框内所有输入框和选择框填满容器 */ +.automation-modal .form-group input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), +.automation-modal .form-group select, +.automation-modal .form-group textarea, +.automation-modal .config-section input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), +.automation-modal .config-section select { + width: 100%; + display: block; + box-sizing: border-box; +} + .automation-modal .form-group label { display: block; margin-bottom: 6px; @@ -5630,12 +5675,12 @@ button.btn-gray:hover, /* 条件逻辑缩短,避免与冷却时间、立即启用挤在一起 */ #add-rule-modal .form-row.three-col .form-group-logic { - flex: 0 0 120px; + flex: 0 0 130px; min-width: 0; } #add-rule-modal .form-row.three-col .form-group:nth-of-type(2) { - flex: 0 0 120px; + flex: 0 0 140px; min-width: 0; } @@ -5656,7 +5701,7 @@ button.btn-gray:hover, } .condition-row .cond-operator { - flex: 0 0 110px; + flex: 0 0 130px; } .condition-row .btn { @@ -5920,6 +5965,19 @@ button.btn-gray:hover, padding-bottom: 0; } +/* 动作模板表单行:确保并排布局不折行 */ +.action-section .form-row { + display: flex; + gap: 12px; + flex-wrap: nowrap; + align-items: flex-start; + margin-bottom: 16px; +} + +.action-section .form-row .form-group { + min-width: 0; +} + .section-title { display: flex; align-items: center; @@ -6077,15 +6135,27 @@ button.btn-gray:hover, .input-with-unit input { flex: 1; border: none; - padding: 8px 12px; - font-size: 0.95rem; + padding: 9px 12px; + font-size: 0.9rem; outline: none; } +/* 覆盖 .form-group input 的高优先级规则,避免双重边框和阴影 */ +.form-group .input-with-unit input:not([type="checkbox"]):not([type="radio"]) { + border: none; + border-radius: 0; + box-shadow: none; + width: auto; +} + .input-with-unit input:focus { box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.2); } +.form-group .input-with-unit input:focus { + box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.2); +} + .input-with-unit .unit { display: flex; align-items: center; @@ -6568,6 +6638,22 @@ button.btn-gray:hover, .page-automation .data-table { display: block; overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .page-automation .data-table th, + .page-automation .data-table td { + padding: 8px 10px; + white-space: nowrap; + } + + .page-automation .section { + padding: 16px; + margin-bottom: 16px; + } + + .page-automation .card.compact .card-content { + padding: 8px; } .automation-modal { @@ -6991,7 +7077,11 @@ button.btn-gray:hover, /* 小型加载指示器 */ .spinner-small { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1em; + height: 1em; animation: spin 1s linear infinite; } @@ -7298,6 +7388,7 @@ button.btn-gray:hover, .dw-card-label { font-size: 0.85em; font-weight: 500; + font-style: normal; color: var(--text-light); } @@ -7725,11 +7816,14 @@ button.btn-gray:hover, flex-wrap: nowrap; gap: 8px; padding: 8px 10px; + padding-right: 80px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; transition: all 0.2s; + position: relative; + overflow: hidden; } .dw-manager-item:hover { @@ -7742,6 +7836,7 @@ button.btn-gray:hover, } .dw-manager-item-icon { + flex-shrink: 0; font-size: 1.1em; } @@ -7749,6 +7844,7 @@ button.btn-gray:hover, flex: 1; min-width: 0; font-weight: 500; + font-style: normal; font-size: 0.9em; white-space: nowrap; overflow: hidden; @@ -7756,6 +7852,8 @@ button.btn-gray:hover, } .dw-manager-item-type { + flex-shrink: 0; + white-space: nowrap; font-size: 0.7em; color: var(--text-light); background: var(--bg-color); @@ -7764,11 +7862,21 @@ button.btn-gray:hover, } .dw-manager-item-actions { - flex-shrink: 0; - margin-left: auto; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); display: flex; gap: 2px; opacity: 0; + background: var(--card-bg); + padding: 2px 4px; + border-radius: 4px; + transition: opacity 0.2s; +} + +.dw-manager-item.active .dw-manager-item-actions { + background: var(--blue-100); } .dw-manager-item:hover .dw-manager-item-actions { @@ -8444,8 +8552,8 @@ button.btn-gray:hover, gap: 4px; padding: 4px 8px; font-size: 0.75em; - background: rgba(0, 0, 0, 0.05); - border-top: 1px solid rgba(0, 0, 0, 0.1); + background: transparent; + border-top: 1px solid rgba(0, 0, 0, 0.08); } .quick-action-service-status .service-label { @@ -8457,28 +8565,28 @@ button.btn-gray:hover, } .quick-action-service-status.status-ready { - background: var(--emerald-100); + background: transparent; color: var(--emerald-600); } .quick-action-service-status.status-checking { - background: var(--blue-100); + background: transparent; color: var(--blue-500); animation: service-checking 1.5s ease-in-out infinite; } .quick-action-service-status.status-timeout { - background: var(--amber-100); + background: transparent; color: var(--amber-500); } .quick-action-service-status.status-failed { - background: var(--rose-100); + background: transparent; color: var(--rose-500); } .quick-action-service-status.status-idle { - background: rgba(149, 165, 166, 0.15); + background: transparent; color: var(--text-secondary); } diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index da0230a..ff364c3 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -85,7 +85,7 @@
-

Loading...

+

Loading

diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index e0f72b6..0384a89 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -90,10 +90,31 @@ class TianShanAPI { try { const response = await fetch(getApiUrl(endpoint), options); clearTimeout(timeoutId); - const json = await response.json(); + const text = await response.text(); + let json; + try { + json = JSON.parse(text); + } catch (parseErr) { + // 响应可能被 WebSocket 等数据污染(如 ~{"type"...),尝试从某处解析出带 code 的 API 格式 + if (parseErr instanceof SyntaxError && text) { + let idx = 0; + while ((idx = text.indexOf('{', idx)) >= 0) { + try { + const parsed = JSON.parse(text.slice(idx)); + if (parsed != null && typeof parsed === 'object' && 'code' in parsed) { + json = parsed; + break; + } + } catch (_) {} + idx += 1; + } + if (json === undefined) throw parseErr; + } else { + throw parseErr; + } + } // 返回 JSON 响应,即使是错误码也返回(让调用者决定如何处理) - // 只有真正的 HTTP 错误(如网络错误)才抛出异常 if (!response.ok && !json.code) { throw new Error(json.message || json.error || 'Request failed'); } @@ -101,7 +122,6 @@ class TianShanAPI { return json; } catch (error) { clearTimeout(timeoutId); - // 只对非预期错误打印日志 if (error.name === 'AbortError') { console.error(`API Timeout: ${endpoint} (>${timeout}ms)`); throw new Error('Request timeout'); diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 3fcda29..004574c 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -400,8 +400,6 @@ function handleEvent(msg) { // 处理日志消息 if (msg.type === 'log') { - console.log('[Debug] Received log message, type:', msg.type); - // 日志页面处理 if (typeof window.handleLogMessage === 'function') { window.handleLogMessage(msg); @@ -409,17 +407,10 @@ function handleEvent(msg) { // 模态框实时日志处理 const modal = document.getElementById('terminal-logs-modal'); - console.log('[Debug] Modal check - exists:', !!modal, 'display:', modal?.style.display); - if (modal && modal.style.display === 'flex') { - console.log('[Debug] Modal is visible, calling handleModalLogMessage'); if (typeof window.handleModalLogMessage === 'function') { window.handleModalLogMessage(msg); - } else { - console.error('[Debug] handleModalLogMessage function not found!'); } - } else { - console.log('[Debug] Modal not visible or not found'); } return; } @@ -447,12 +438,8 @@ function handleEvent(msg) { // 终端页面的日志模态框 const modal = document.getElementById('terminal-logs-modal'); - console.log('[Modal] log_history received, modal:', modal, 'display:', modal?.style.display); - console.log('[Modal] logs count:', logs.length); - if (modal && modal.style.display === 'flex') { - console.log('[Modal] Updating modalLogEntries...'); - modalLogEntries.length = 0; // 清空数组但保持引用 + modalLogEntries.length = 0; modalLogEntries.push(...logs.map(log => ({ level: log.level || 3, levelName: getLevelName(log.level || 3), @@ -461,10 +448,7 @@ function handleEvent(msg) { timestamp: log.timestamp || Date.now(), task: log.task || '' }))); - console.log('[Modal] After push, modalLogEntries length:', modalLogEntries.length); renderModalLogs(); - } else { - console.log('[Modal] Modal not visible, skipping update'); } return; @@ -2638,6 +2622,8 @@ async function loadDataWidgets() { await saveDataWidgets(); } } + // 修复已损坏的图标数据 + _repairCorruptedWidgetIcons(); return; } } catch (e) { @@ -2652,6 +2638,33 @@ async function loadDataWidgets() { } else { dataWidgets = []; } + + // 3. 修复已损坏的图标数据(旧版编辑面板未转义 HTML 属性导致 icon 被截断) + _repairCorruptedWidgetIcons(); +} + +/** + * 修复损坏的组件图标数据 + * 旧版编辑面板未对 icon 值进行 HTML 转义,导致 中的引号 + * 截断了 input value 属性,保存后 icon 变成 ' 结束 + if (trimmed.startsWith('')) { + console.warn(`修复损坏的图标 (组件 "${w.label}"):`, JSON.stringify(w.icon), '→ 恢复为类型默认图标'); + w.icon = WIDGET_TYPES[w.type]?.icon || ''; + repaired = true; + } + } + }); + if (repaired) { + // 异步保存修复后的数据 + saveDataWidgets(); + } } /** @@ -2829,6 +2842,22 @@ function formatDisplayValue(value, config) { return prefix + String(value) + suffix; } +/** + * 校验并安全化组件图标 HTML + * 防止损坏的 标签吞噬后续文本(如标签名称) + */ +function sanitizeWidgetIcon(iconHtml) { + if (!iconHtml || typeof iconHtml !== 'string') return ''; + const trimmed = iconHtml.trim(); + // 有效图标:以 结束(正确闭合) + if (trimmed.startsWith('')) return trimmed + ' '; + if (trimmed === '') return ''; + // 如果只是 remixicon 类名(旧格式),包装成完整标签 + if (/^ri-[\w-]+$/.test(trimmed)) return ` `; + // 无效或损坏的图标 HTML — 丢弃,防止破坏 DOM + return ''; +} + /** * 渲染单个组件的 HTML */ @@ -2874,7 +2903,7 @@ function renderWidgetHtml(widget) { case 'number': contentHtml = `
-
${icon || ''}
+
${sanitizeWidgetIcon(icon) || ''}
- ${unit || ''} @@ -2959,7 +2988,7 @@ function renderWidgetHtml(widget) { default: contentHtml = `
-
${icon || ''}
+
${sanitizeWidgetIcon(icon) || ''}
-
`; break; @@ -2972,7 +3001,7 @@ function renderWidgetHtml(widget) { return `
- ${icon ? icon + ' ' : ''}${label} + ${sanitizeWidgetIcon(icon)}${escapeHtml(label)}
${contentHtml}
@@ -3515,15 +3544,13 @@ function renderWidgetManagerList() { } list.innerHTML = dataWidgets.map((w, idx) => { - const typeName = (typeof t === 'function' && w.type) ? (t('dataWidget.widgetType' + w.type.charAt(0).toUpperCase() + w.type.slice(1)) || WIDGET_TYPES[w.type]?.name) : (WIDGET_TYPES[w.type]?.name || w.type); const moveUpTitle = typeof t === 'function' ? t('dataWidget.moveUp') : '上移'; const moveDownTitle = typeof t === 'function' ? t('dataWidget.moveDown') : '下移'; const deleteTitle = typeof t === 'function' ? t('dataWidget.delete') : '删除'; return `
- ${w.icon || WIDGET_TYPES[w.type]?.icon || ''} - ${w.label} - ${typeName} + ${sanitizeWidgetIcon(w.icon) || sanitizeWidgetIcon(WIDGET_TYPES[w.type]?.icon) || ''} + ${escapeHtml(w.label)}
@@ -3698,7 +3725,7 @@ function showWidgetEditPanel(widgetId) { extraConfigHtml = `
- ${typeof t === 'function' ? t('dataWidget.secondaryExpressionHint') : '显示在主值右侧的副值'}
`; @@ -3718,7 +3745,7 @@ function showWidgetEditPanel(widgetId) {
-
@@ -3734,13 +3761,13 @@ function showWidgetEditPanel(widgetId) {
- +
- +
@@ -3775,7 +3802,7 @@ function showWidgetEditPanel(widgetId) {
- +
@@ -3803,7 +3830,7 @@ function showWidgetEditPanel(widgetId) {
-
@@ -3937,43 +3964,42 @@ async function refreshQuickActions() { } try { - console.log('refreshQuickActions: Fetching rules...'); - // 确保 SSH 主机数据已加载(用于 nohup 按钮) if (!window._sshHostsData || Object.keys(window._sshHostsData).length === 0) { await loadSshHostsData(); } + // 强制刷新 SSH 命令缓存,确保 nohup/serviceMode 等字段为最新 + await loadSshCommands(); + const result = await api.call('automation.rules.list'); - console.log('refreshQuickActions: API result:', result); if (result.code === 0 && result.data && result.data.rules) { // 过滤出启用且标记为可手动触发的规则 const allRules = result.data.rules; - console.log('refreshQuickActions: All rules:', allRules.map(r => ({ id: r.id, enabled: r.enabled, manual_trigger: r.manual_trigger }))); const manualRules = allRules.filter(r => r.enabled && r.manual_trigger); - console.log('refreshQuickActions: Manual rules count:', manualRules.length); if (manualRules.length > 0) { - // 检查每个规则是否包含 nohup SSH 命令 - const cardsHtml = await Promise.all(manualRules.map(async rule => { - const iconValue = rule.icon || '⚡'; - const iconHtml = iconValue.startsWith('/sdcard/') - ? `icon` - : iconValue; + // 串行检查每个规则的 nohup 状态并生成卡片,避免多路 ssh.exec 并发导致后端串行/覆盖、结果错位 + const cardsHtml = []; + for (const rule of manualRules) { + const iconValue = rule.icon || 'ri-thunderstorms-line'; + let iconHtml; + if (iconValue.startsWith('/sdcard/')) { + iconHtml = `icon`; + } else if (iconValue.startsWith('ri-')) { + iconHtml = ``; + } else { + iconHtml = ``; + } - // 检查是否有 nohup SSH 命令动作 const nohupInfo = await checkRuleHasNohupSsh(rule); - - // 基础卡片 + nohup 控制按钮(带运行状态) let nohupBtns = ''; let isRunning = false; if (nohupInfo) { - // 检测进程是否正在运行 try { const host = window._sshHostsData?.[nohupInfo.hostId]; if (host) { - console.log('Checking process status:', nohupInfo.checkCmd); const checkResult = await api.call('ssh.exec', { host: host.host, port: host.port, @@ -3982,36 +4008,24 @@ async function refreshQuickActions() { command: nohupInfo.checkCmd, timeout_ms: 5000 }); - const stdout = checkResult.data?.stdout?.trim() || ''; - console.log('Process check result:', stdout, 'code:', checkResult.code, 'full result:', checkResult); - // 检查 stdout 是否包含 'running'(兼容可能的额外输出) + const stdout = (checkResult.data?.stdout || '').trim(); isRunning = stdout.includes('running'); } } catch (e) { - console.warn('Check process status failed:', e); + console.warn('Check process status failed for rule', rule.id, e); isRunning = false; } const statusIcon = isRunning ? '' : ''; const statusTitle = isRunning ? '进程运行中' : '进程未运行'; - - // 服务模式状态显示(只有进程运行时才显示服务状态栏) let serviceStatusHtml = ''; if (nohupInfo.serviceMode && nohupInfo.varName && isRunning) { - const serviceStatusId = `service-status-${escapeHtml(rule.id)}`; serviceStatusHtml = ` -
+
...
`; } - - // 停止按钮:进程未运行时禁用 - const stopBtnDisabled = isRunning ? '' : 'disabled'; - const stopBtnClass = isRunning ? 'btn-stop' : 'btn-stop btn-disabled'; - - // 状态徽章 + 底部操作栏(传递 pidFile 用于精确停止) - // 日志文件路径统一使用 nohupInfo.logFile(基于 cmd.name) nohupBtns = ` ${statusIcon} ${serviceStatusHtml} @@ -4019,22 +4033,18 @@ async function refreshQuickActions() { -
`; } - // 如果进程正在运行,点击卡片时提示而不是触发 const cardOnClick = (nohupInfo && isRunning) ? `showToast('进程正在运行中,请先停止', 'warning')` : `triggerQuickAction('${escapeHtml(rule.id)}')`; - - // 移除名称开头的emoji (包括常见emoji和零宽字符) const cleanName = rule.name.replace(/^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F1E0}-\u{1F1FF}\u200D]+\s*/gu, '').trim(); - - return ` + cardsHtml.push(`
${escapeHtml(cleanName)}
${nohupBtns}
- `; - })); + `); + } container.innerHTML = cardsHtml.join(''); // 更新服务状态 @@ -4136,12 +4146,15 @@ async function updateQuickActionServiceStatus() { } } +// 触发快捷操作后的冷却时间(毫秒),避免连续触发导致后端只执行最后一个 +let _quickActionTriggerCooldownUntil = 0; +let _quickActionLastTriggeredId = ''; + /** * 触发快捷操作 * @param {string} ruleId - 规则 ID */ async function triggerQuickAction(ruleId) { - // 获取卡片元素 const card = event?.currentTarget || document.getElementById(`quick-action-${ruleId}`); if (!card) { console.error('triggerQuickAction: card not found for ruleId=', ruleId); @@ -4149,12 +4162,17 @@ async function triggerQuickAction(ruleId) { return; } - // 检查是否已经在执行中(防止重复点击) if (card.classList.contains('triggering')) { showToast(typeof t === 'function' ? t('toast.processing') : 'Operation in progress...', 'warning'); return; } + const now = Date.now(); + if (now < _quickActionTriggerCooldownUntil && ruleId !== _quickActionLastTriggeredId) { + showToast('请等待几秒后再触发其他模型', 'warning'); + return; + } + try { // 添加按下效果并禁用点击 card.classList.add('triggering'); @@ -4164,19 +4182,18 @@ async function triggerQuickAction(ruleId) { const iconEl = card.querySelector('.quick-action-icon'); const originalIcon = iconEl?.innerHTML; if (iconEl) { - iconEl.innerHTML = ''; + iconEl.innerHTML = ''; } - console.log('triggerQuickAction: calling API for ruleId=', ruleId); const result = await api.call('automation.rules.trigger', { id: ruleId }); - console.log('triggerQuickAction: result=', result); if (result.code === 0) { showToast('操作已执行', 'success'); - // 对于 nohup 命令,需要等待更长时间让进程启动并创建 PID 文件 - // 先显示执行中状态,然后延迟刷新获取实际状态 + _quickActionLastTriggeredId = ruleId; + _quickActionTriggerCooldownUntil = Date.now() + 5000; // 5 秒内勿触发其他规则,避免后端串行导致第二个未执行 card.classList.add('is-running'); - setTimeout(() => refreshQuickActions(), 2500); // 等待 2.5 秒让进程启动 + card.style.pointerEvents = ''; + setTimeout(() => refreshQuickActions(), 2500); } else { showToast((result.message || '执行失败'), 'error'); card.style.pointerEvents = ''; // 失败时恢复点击 @@ -4205,7 +4222,6 @@ async function triggerQuickAction(ruleId) { async function checkRuleHasNohupSsh(rule) { // 列表 API 只返回 actions_count,需要获取完整规则 if (!rule.actions_count || rule.actions_count === 0) { - console.log('checkRuleHasNohupSsh: rule', rule.id, 'has no actions'); return null; } @@ -4213,12 +4229,10 @@ async function checkRuleHasNohupSsh(rule) { try { const detailResult = await api.call('automation.rules.get', { id: rule.id }); if (detailResult.code !== 0 || !detailResult.data || !detailResult.data.actions) { - console.log('checkRuleHasNohupSsh: failed to get rule details for', rule.id); return null; } const actions = detailResult.data.actions; - console.log('checkRuleHasNohupSsh: rule', rule.id, 'actions=', actions); // 确保 SSH 命令已加载 if (Object.keys(sshCommands).length === 0) { @@ -4229,45 +4243,45 @@ async function checkRuleHasNohupSsh(rule) { for (const action of actions) { let sshCmdId = null; - // 方式1: 动作本身是 ssh_cmd_ref 类型 - if (action.type === 'ssh_cmd_ref' && action.ssh_ref?.cmd_id) { - sshCmdId = action.ssh_ref.cmd_id; + // 方式1: 动作本身是 ssh_cmd_ref 类型(兼容两种 API 返回格式) + // rules.get 返回 action.cmd_id(直接字段),actions.get 返回 action.ssh_ref.cmd_id(嵌套) + if (action.type === 'ssh_cmd_ref' && (action.ssh_ref?.cmd_id || action.cmd_id)) { + sshCmdId = action.ssh_ref?.cmd_id || action.cmd_id; } // 方式2: 动作有 template_id,需要查询模板获取实际类型 else if (action.template_id) { try { const tplResult = await api.call('automation.actions.get', { id: action.template_id }); if (tplResult.code === 0 && tplResult.data) { - console.log('checkRuleHasNohupSsh: template', action.template_id, '=', tplResult.data); if (tplResult.data.type === 'ssh_cmd_ref' && tplResult.data.ssh_ref?.cmd_id) { sshCmdId = tplResult.data.ssh_ref.cmd_id; } } } catch (e) { - console.warn('checkRuleHasNohupSsh: failed to get template', action.template_id); + // template fetch failed, skip } } if (sshCmdId) { const cmdId = String(sshCmdId); - console.log('checkRuleHasNohupSsh: looking for cmdId=', cmdId); // 在所有主机的命令中查找 for (const [hostId, cmds] of Object.entries(sshCommands)) { const cmd = cmds.find(c => String(c.id) === cmdId); if (cmd) { - console.log('checkRuleHasNohupSsh: found cmd=', cmd.name, 'nohup=', cmd.nohup, 'serviceMode=', cmd.serviceMode); if (cmd.nohup) { // 找到了 nohup 命令 - // 文件名统一使用 cmd.name(用户可读的命令名称) - // varName 只用于服务模式的状态变量 - const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; + // safeName:优先从 cmd.name 提取英文数字,fallback 到 cmd.id + // (纯中文名如"嵌入模型拉起"提取不到任何字符,需要用 id) + const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || String(cmd.id).replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; const logFile = `/tmp/ts_nohup_${safeName}.log`; const pidFile = `/tmp/ts_nohup_${safeName}.pid`; const varName = cmd.varName || ''; // 服务模式变量名 - // 使用 PID 文件检测进程状态(最可靠) - // 检查 PID 文件存在且进程仍在运行 - const checkCmd = `[ -f ${pidFile} ] && kill -0 $(cat ${pidFile}) 2>/dev/null && echo 'running' || echo 'stopped'`; + // 检测进程状态:多重 fallback 策略 + // 问题:nohup cmd & 的 $! 获取到的 PID 可能是中间 shell 进程, + // 真实主进程 PID 往往是 $! - 1(如 vLLM 等多进程服务) + // 策略:1) kill -0 PID 2) kill -0 PID-1 3) pgrep 按日志文件名兜底 + const checkCmd = `if [ -f ${pidFile} ]; then PID=$(cat ${pidFile}); if kill -0 $PID 2>/dev/null; then echo running; elif kill -0 $((PID-1)) 2>/dev/null; then echo running; else echo stopped; fi; else echo stopped; fi`; return { logFile: logFile, @@ -4364,29 +4378,26 @@ async function quickActionRefreshLog(logFile, hostId) { if (!contentEl) return; try { - // 使用 tail -n 200 限制行数,避免日志过大 const result = await api.call('ssh.exec', { host: host.host, port: host.port, user: host.username, keyid: host.keyid, - command: `if [ -f ${logFile} ]; then tail -n 200 ${logFile}; else echo '[日志文件不存在或为空]'; fi`, - timeout_ms: 10000 + command: `if [ -f ${logFile} ]; then cat ${logFile}; else echo '[日志文件不存在或为空]'; fi`, + timeout_ms: 15000 }); - - if (result.code === 0 && result.data) { - const output = result.data.stdout || result.data.stderr || '[空]'; - // 只有内容变化时才更新(避免闪烁) - if (output !== quickActionLastContent) { - contentEl.textContent = output; - contentEl.scrollTop = contentEl.scrollHeight; - quickActionLastContent = output; - } - } else { - contentEl.textContent = '[获取失败] ' + (result.message || ''); + if (result.code !== 0 || !result.data) { + contentEl.textContent = '[获取失败] ' + (result.message || 'code=' + result.code); + return; + } + const output = (result.data.stdout || result.data.stderr || '').trim() || '[空]'; + if (output !== quickActionLastContent) { + contentEl.textContent = output; + contentEl.scrollTop = contentEl.scrollHeight; + quickActionLastContent = output; } } catch (e) { - contentEl.textContent = '[错误] ' + e.message; + contentEl.textContent = '[错误] ' + e.message + '\n\n若设备繁忙可稍后重试。'; } } @@ -4500,8 +4511,9 @@ function closeQuickLogModal() { /** * 快捷操作 - 终止进程(基于 PID 文件精确停止) + * 支持杀进程组(vLLM 等多进程服务),SIGTERM → 等待 → SIGKILL 回退 */ -async function quickActionStopProcess(pidFile, hostId, cmdName) { +async function quickActionStopProcess(pidFile, hostId, cmdName, varName) { const host = window._sshHostsData?.[hostId]; if (!host) { showToast('主机不存在', 'error'); @@ -4514,21 +4526,66 @@ async function quickActionStopProcess(pidFile, hostId, cmdName) { try { showToast('正在终止进程...', 'info'); - // 使用 PID 文件精确终止进程 + + // 终止进程:支持 PID 和 PID-1 双重检测 + // 原因:nohup cmd & 的 $! 可能记录了中间 shell PID,真实主进程 PID = $! - 1 + // 逻辑:读取 PID 文件 → 找到活着的真实 PID → SIGTERM 进程组 → 等待 → SIGKILL 回退 + const killCmd = `if [ -f ${pidFile} ]; then ` + + `RAW_PID=$(cat ${pidFile}); ` + + // 确定真实 PID:先检查 RAW_PID,再检查 RAW_PID-1 + `if kill -0 $RAW_PID 2>/dev/null; then PID=$RAW_PID; ` + + `elif kill -0 $((RAW_PID-1)) 2>/dev/null; then PID=$((RAW_PID-1)); ` + + `else rm -f ${pidFile}; echo "ALREADY_STOPPED"; exit 0; fi; ` + + // 先尝试 SIGTERM 进程组(kill 负 PID),回退到单进程 kill + `kill -- -$PID 2>/dev/null; kill $PID 2>/dev/null; ` + + // 等待最多 3 秒 + `for i in 1 2 3 4 5 6; do sleep 0.5; kill -0 $PID 2>/dev/null || break; done; ` + + // 检查是否还活着 + `if kill -0 $PID 2>/dev/null; then ` + + // 仍在运行,SIGKILL 进程组 + `kill -9 -- -$PID 2>/dev/null; kill -9 $PID 2>/dev/null; ` + + `sleep 0.5; ` + + `if kill -0 $PID 2>/dev/null; then ` + + `echo "STILL_RUNNING"; ` + + `else ` + + `rm -f ${pidFile}; echo "FORCE_KILLED"; ` + + `fi; ` + + `else ` + + `rm -f ${pidFile}; echo "TERMINATED"; ` + + `fi; ` + + `else echo "NO_PID_FILE"; fi`; + const result = await api.call('ssh.exec', { host: host.host, port: host.port, user: host.username, keyid: host.keyid, - command: `if [ -f ${pidFile} ]; then kill $(cat ${pidFile}) 2>/dev/null && rm -f ${pidFile} && echo "已终止进程" || echo "进程已不存在"; else echo "PID 文件不存在"; fi`, - timeout_ms: 10000 + command: killCmd, + timeout_ms: 15000 }); if (result.code === 0 && result.data) { - const output = result.data.stdout || result.data.stderr || '操作完成'; - showToast(output.trim(), output.includes('已终止') ? 'success' : 'info'); + const output = (result.data.stdout || '').trim(); + const msgMap = { + 'TERMINATED': '服务已停止', + 'FORCE_KILLED': '服务已强制终止', + 'ALREADY_STOPPED': '进程已不在运行,已清理 PID 文件', + 'NO_PID_FILE': 'PID 文件不存在', + 'STILL_RUNNING': '无法终止进程,请手动处理' + }; + const msg = msgMap[output] || (output || '操作完成'); + const isSuccess = ['TERMINATED', 'FORCE_KILLED', 'ALREADY_STOPPED'].includes(output); + showToast(msg, isSuccess ? 'success' : (output === 'STILL_RUNNING' ? 'error' : 'info')); + // 停止成功后,清除服务模式状态变量(否则"就绪"标签会残留) + if (isSuccess && varName) { + try { + await api.call('automation.variables.set', { name: `${varName}.status`, value: 'stopped' }); + } catch (e) { + console.warn('Failed to clear service status variable:', e); + } + } // 刷新状态 - setTimeout(() => refreshQuickActions(), 1000); + setTimeout(() => refreshQuickActions(), 1500); } else { showToast((result.message || '操作失败'), 'error'); } @@ -9105,7 +9162,7 @@ function refreshCommandsList() { const varBtnHtml = cmd.varName ? `` : ''; // 服务模式按钮(日志、停止) - const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; + const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || String(cmd.id).replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; const serviceActionsHtml = (cmd.nohup && cmd.serviceMode) ? ` @@ -9159,23 +9216,17 @@ async function updateServiceStatusInList() { const serviceModeTags = document.querySelectorAll('.service-mode-status'); if (serviceModeTags.length === 0) return; - console.log(`[ServiceStatus] Updating ${serviceModeTags.length} service status tags`); - for (const tag of serviceModeTags) { const varName = tag.dataset.var; const statusId = tag.dataset.statusId; const statusEl = document.getElementById(statusId); - console.log(`[ServiceStatus] Processing: varName=${varName}, statusId=${statusId}, statusEl=${statusEl ? 'found' : 'NOT FOUND'}`); - if (!varName || !statusEl) { - console.warn(`[ServiceStatus] Skipping: varName=${varName}, statusEl=${!!statusEl}`); continue; } try { const result = await api.call('automation.variables.get', { name: `${varName}.status` }); - console.log(`[ServiceStatus] ${varName}.status =`, result?.data?.value); if (result && result.data && result.data.value !== undefined) { const status = result.data.value; @@ -9542,7 +9593,7 @@ function validateCommandId(input) { return false; } - input.style.borderColor = 'var(--success-color)'; + input.style.borderColor = ''; errorSpan.style.display = 'none'; return true; } @@ -9550,7 +9601,7 @@ function validateCommandId(input) { async function saveCommand() { const cmdId = document.getElementById('cmd-edit-id').value.trim(); const name = document.getElementById('cmd-name').value.trim(); - const command = document.getElementById('cmd-command').value.trim(); + let command = document.getElementById('cmd-command').value.trim(); const desc = document.getElementById('cmd-desc').value.trim(); const icon = document.getElementById('cmd-icon').value; const nohup = document.getElementById('cmd-nohup')?.checked || false; @@ -9574,6 +9625,24 @@ async function saveCommand() { return; } + /* nohup 模式下自动检测并剥离用户多余的 nohup 包装。 + * 后端会在 nohup=true 时自动添加 nohup/重定向/PID 追踪, + * 如果用户的命令里已经包含这些,会导致双重包装,日志只能读到 PID。 */ + if (nohup && command) { + let cleaned = command; + // 去掉开头的 nohup(后端会自己加) + cleaned = cleaned.replace(/^\s*nohup\s+/, ''); + // 去掉尾部的 > /tmp/... 2>&1 & echo $! > /tmp/... 等 nohup 尾巴 + cleaned = cleaned.replace(/\s*>\s*\/tmp\/ts_nohup_\S+\.log\s+2>&1\s*&\s*echo\s+\$!\s*>\s*\/tmp\/ts_nohup_\S+\.pid\s*$/, ''); + // 更宽泛:去掉尾部的 > 任意路径.log 2>&1 & echo $! > 任意路径.pid + cleaned = cleaned.replace(/\s*>\s*\S+\.log\s+2>&1\s*&\s*echo\s+\$!\s*>\s*\S+\.pid\s*$/, ''); + if (cleaned !== command) { + document.getElementById('cmd-command').value = cleaned; + command = cleaned; + showToast('已自动去除命令中多余的 nohup 包装(后端会自动添加)', 'info'); + } + } + /* ID 验证(必填) */ if (!cmdId) { showToast('请填写指令 ID', 'warning'); @@ -10469,8 +10538,8 @@ async function executeCommand(idx) { let nohupLogFile = null; let nohupPidFile = null; if (cmd.nohup) { - // 基于命令名生成固定文件名(每次执行会覆盖) - const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; + // 基于命令名生成固定文件名(每次执行会覆盖),纯中文名 fallback 到 cmd.id + const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || String(cmd.id).replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; nohupLogFile = `/tmp/ts_nohup_${safeName}.log`; nohupPidFile = `/tmp/ts_nohup_${safeName}.pid`; @@ -14044,26 +14113,12 @@ let modalLogSubscribed = false; const MAX_MODAL_LOG_ENTRIES = 1000; function showTerminalLogsModal() { - console.log('[Modal] showTerminalLogsModal called - START'); const modal = document.getElementById('terminal-logs-modal'); - console.log('[Modal] showTerminalLogsModal called, modal:', modal); - - if (!modal) { - console.error('[Modal] Modal element not found!'); - return; - } + if (!modal) return; modal.style.display = 'flex'; - console.log('[Modal] Modal display set to flex'); - modalLogEntries.length = 0; // 清空但保持引用 - - console.log('[Modal] About to call subscribeToModalLogs...'); - console.log('[Modal] typeof subscribeToModalLogs:', typeof subscribeToModalLogs); - - // 启动实时订阅 + modalLogEntries.length = 0; subscribeToModalLogs(); - - console.log('[Modal] showTerminalLogsModal - END'); } function closeTerminalLogsModal() { @@ -14077,24 +14132,18 @@ function closeTerminalLogsModal() { // 订阅模态框日志 function subscribeToModalLogs() { - console.log('[Modal] subscribeToModalLogs called'); const levelFilter = document.getElementById('modal-log-level-filter')?.value || '3'; const minLevel = parseInt(levelFilter); - console.log('[Modal] Level filter:', minLevel); if (window.ws && window.ws.readyState === WebSocket.OPEN) { - console.log('[Modal] WebSocket is open, sending log_subscribe...'); window.ws.send({ type: 'log_subscribe', minLevel: minLevel }); modalLogSubscribed = true; updateModalWsStatus(true); - console.log('[Modal] Subscription sent, loading history...'); - // 订阅成功后加载历史日志 loadModalHistoryLogs(); } else { - console.warn('[Modal] WebSocket not ready, readyState:', window.ws?.readyState); updateModalWsStatus(false); setTimeout(subscribeToModalLogs, 1000); } @@ -14102,9 +14151,7 @@ function subscribeToModalLogs() { // 取消订阅模态框日志 function unsubscribeFromModalLogs() { - console.log('[Modal] unsubscribeFromModalLogs called, subscribed:', modalLogSubscribed); if (window.ws && window.ws.readyState === WebSocket.OPEN && modalLogSubscribed) { - console.log('[Modal] Sending log_unsubscribe...'); window.ws.send({ type: 'log_unsubscribe' }); } modalLogSubscribed = false; @@ -14113,10 +14160,7 @@ function unsubscribeFromModalLogs() { // 加载历史日志 async function loadModalHistoryLogs() { - if (!window.ws || window.ws.readyState !== WebSocket.OPEN) { - console.error('[Modal] WebSocket 未连接'); - return; - } + if (!window.ws || window.ws.readyState !== WebSocket.OPEN) return; const levelFilter = document.getElementById('modal-log-level-filter')?.value || '3'; @@ -14166,13 +14210,7 @@ function debounceRenderModalLogs() { function renderModalLogs() { const container = document.getElementById('modal-log-container'); - console.log('[Modal] renderModalLogs called, container:', container); - console.log('[Modal] modalLogEntries length:', modalLogEntries.length); - - if (!container) { - console.error('[Modal] Container not found!'); - return; - } + if (!container) return; // 获取过滤条件 const levelFilter = parseInt(document.getElementById('modal-log-level-filter')?.value || '3'); @@ -14245,8 +14283,6 @@ function clearModalLogs() { // 处理模态框实时日志消息 function handleModalLogMessage(msg) { - console.log('[Modal] Received log message:', msg); - const logEntry = { level: msg.level || 3, levelName: getLevelName(msg.level || 3), @@ -14256,9 +14292,7 @@ function handleModalLogMessage(msg) { task: msg.task || '' }; - // 追加日志(限制最大数量) modalLogEntries.push(logEntry); - console.log('[Modal] Added log, total entries:', modalLogEntries.length); if (modalLogEntries.length > MAX_MODAL_LOG_ENTRIES) { modalLogEntries.shift(); // 移除最旧的日志 @@ -16816,10 +16850,15 @@ async function refreshRules() { ${rules.map(r => { - const iconValue = r.icon || '⚡'; - const iconHtml = iconValue.startsWith('/sdcard/') - ? `` - : ``; + const iconValue = r.icon || 'ri-thunderstorms-line'; + let iconHtml; + if (iconValue.startsWith('/sdcard/')) { + iconHtml = `` + } else if (iconValue.startsWith('ri-')) { + iconHtml = ``; + } else { + iconHtml = ``; + } const manualBadge = r.manual_trigger ? '' + (typeof t === 'function' ? t('common.manual') : '手动') + '' : ''; const enabledStr = typeof t === 'function' ? t('common.enabled') : '启用'; const disabledStr = typeof t === 'function' ? t('common.disabled') : '禁用'; @@ -17130,11 +17169,11 @@ async function refreshActions() { ${getActionTypeLabel(a.type)} ${a.async ? '' + asyncStr + '' : '' + syncStr + ''} ${a.description || '-'} - - - - - + + + + + `).join('')} @@ -19195,14 +19234,14 @@ function showAddSourceModal() {
-
+
-
+
@@ -19998,19 +20037,32 @@ async function submitAddSource() { } } -// 规则图标:后端仍存 emoji,前端显示用 RemixIcon +// 规则图标:直接存储 RemixIcon 类名(ri-xxx),保留旧 emoji 映射以兼容历史数据 const RULE_ICON_LIST = [ - { emoji: '⚡', ri: 'ri-thunderstorms-line' }, { emoji: '🔔', ri: 'ri-notification-line' }, { emoji: '💡', ri: 'ri-lightbulb-line' }, - { emoji: '🔌', ri: 'ri-plug-line' }, { emoji: '🌡️', ri: 'ri-temp-hot-line' }, { emoji: '⏰', ri: 'ri-timer-line' }, - { emoji: '📊', ri: 'ri-bar-chart-line' }, { emoji: '🎯', ri: 'ri-focus-line' }, { emoji: '🚀', ri: 'ri-rocket-line' }, - { emoji: '⚙️', ri: 'ri-settings-line' }, { emoji: '🔧', ri: 'ri-tools-line' }, { emoji: '🎵', ri: 'ri-music-line' }, - { emoji: '📱', ri: 'ri-smartphone-line' }, { emoji: '🖥️', ri: 'ri-computer-line' }, { emoji: '🌐', ri: 'ri-global-line' }, - { emoji: '🔒', ri: 'ri-lock-line' }, { emoji: '🛡️', ri: 'ri-shield-line' }, { emoji: '📝', ri: 'ri-file-text-line' }, - { emoji: '🎬', ri: 'ri-movie-line' }, { emoji: '🔄', ri: 'ri-refresh-line' } + 'ri-thunderstorms-line', 'ri-notification-line', 'ri-lightbulb-line', + 'ri-plug-line', 'ri-temp-hot-line', 'ri-timer-line', + 'ri-bar-chart-line', 'ri-focus-line', 'ri-rocket-line', + 'ri-settings-line', 'ri-tools-line', 'ri-music-line', + 'ri-smartphone-line', 'ri-computer-line', 'ri-global-line', + 'ri-lock-line', 'ri-shield-line', 'ri-file-text-line', + 'ri-movie-line', 'ri-refresh-line' ]; -function getRuleIconRi(emoji) { - const o = RULE_ICON_LIST.find(x => x.emoji === emoji); - return o ? o.ri : 'ri-thunderstorms-line'; +// 旧版 emoji → ri 映射(向后兼容历史数据) +const _EMOJI_TO_RI = { + '⚡': 'ri-thunderstorms-line', '🔔': 'ri-notification-line', '💡': 'ri-lightbulb-line', + '🔌': 'ri-plug-line', '🌡️': 'ri-temp-hot-line', '⏰': 'ri-timer-line', + '📊': 'ri-bar-chart-line', '🎯': 'ri-focus-line', '🚀': 'ri-rocket-line', + '⚙️': 'ri-settings-line', '🔧': 'ri-tools-line', '🎵': 'ri-music-line', + '📱': 'ri-smartphone-line', '🖥️': 'ri-computer-line', '🌐': 'ri-global-line', + '🔒': 'ri-lock-line', '🛡️': 'ri-shield-line', '📝': 'ri-file-text-line', + '🎬': 'ri-movie-line', '🔄': 'ri-refresh-line' +}; +function getRuleIconRi(icon) { + if (!icon) return 'ri-thunderstorms-line'; + // 已经是 ri-xxx 格式,直接返回 + if (icon.startsWith('ri-')) return icon; + // 旧版 emoji → ri 映射 + return _EMOJI_TO_RI[icon] || 'ri-thunderstorms-line'; } /** @@ -20031,8 +20083,8 @@ function showAddRuleModal(ruleData = null) { const modal = document.createElement('div'); modal.id = 'add-rule-modal'; modal.className = 'modal'; - const iconPickerHtml = RULE_ICON_LIST.map((x, i) => - `` + const iconPickerHtml = RULE_ICON_LIST.map((ri, i) => + `` ).join(''); modal.innerHTML = `
- +
${iconPickerHtml}
@@ -20077,7 +20129,7 @@ function showAddRuleModal(ruleData = null) {
- +
@@ -20148,15 +20200,16 @@ function showAddRuleModal(ruleData = null) { document.getElementById('rule-cooldown').value = ruleData.cooldown_ms || 0; document.getElementById('rule-enabled').checked = ruleData.enabled !== false; - // 填充图标 - const icon = ruleData.icon || '⚡'; - document.getElementById('rule-icon').value = icon; + // 填充图标(兼容旧 emoji 和新 ri-xxx) + const icon = ruleData.icon || 'ri-thunderstorms-line'; if (icon.startsWith('/sdcard/')) { + document.getElementById('rule-icon').value = icon; document.getElementById('rule-icon-type').value = 'image'; document.getElementById('rule-icon-path').value = icon; switchRuleIconType('image'); updateRuleIconPreview(icon); } else { + // selectRuleIcon 内部会自动将旧 emoji 转换为 ri-xxx document.getElementById('rule-icon-type').value = 'emoji'; selectRuleIcon(icon); } @@ -20237,12 +20290,15 @@ function switchRuleIconType(type) { } function selectRuleIcon(icon) { - document.getElementById('rule-icon').value = icon; + // icon 可以是 ri-xxx 或旧版 emoji,统一转为 ri-xxx + const riIcon = getRuleIconRi(icon); + document.getElementById('rule-icon').value = riIcon; document.getElementById('rule-icon-type').value = 'emoji'; + // 预览输入框显示图标类名 const input = document.getElementById('rule-emoji-input'); - if (input) input.value = icon; + if (input) input.value = riIcon; document.querySelectorAll('#add-rule-modal .icon-btn').forEach(btn => { - btn.classList.toggle('selected', btn.getAttribute('data-emoji') === icon); + btn.classList.toggle('selected', btn.getAttribute('data-icon') === riIcon); }); } @@ -20250,11 +20306,13 @@ function selectRuleIconFromInput() { const input = document.getElementById('rule-emoji-input'); const icon = input.value.trim(); if (icon) { - document.getElementById('rule-icon').value = icon; + // 如果用户输入了 ri-xxx 类名则直接使用,否则通过映射转换 + const riIcon = icon.startsWith('ri-') ? icon : getRuleIconRi(icon); + document.getElementById('rule-icon').value = riIcon; document.getElementById('rule-icon-type').value = 'emoji'; - // 取消预设按钮的选中状态 + // 更新按钮选中状态 document.querySelectorAll('#add-rule-modal .icon-btn').forEach(btn => { - btn.classList.remove('selected'); + btn.classList.toggle('selected', btn.getAttribute('data-icon') === riIcon); }); } } @@ -20281,7 +20339,7 @@ function updateRuleIconPreview(path) { } function clearRuleIconImage() { - document.getElementById('rule-icon').value = '⚡'; + document.getElementById('rule-icon').value = 'ri-thunderstorms-line'; document.getElementById('rule-icon-path').value = ''; document.getElementById('rule-icon-type').value = 'emoji'; updateRuleIconPreview(null); @@ -20936,7 +20994,7 @@ async function submitAddRule(originalId = null) { const isEdit = !!originalId; const id = document.getElementById('rule-id').value.trim(); const name = document.getElementById('rule-name').value.trim(); - const icon = document.getElementById('rule-icon').value || '⚡'; + const icon = document.getElementById('rule-icon').value || 'ri-thunderstorms-line'; const logic = document.getElementById('rule-logic').value; const cooldown = parseInt(document.getElementById('rule-cooldown').value) || 0; const enabled = document.getElementById('rule-enabled').checked;