From 0a02189ddb1ac2ef2795be1532617cf8b7b08761 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 10 Feb 2026 18:47:05 +0800 Subject: [PATCH] =?UTF-8?q?perf(webui):=20Lighthouse=2090=E2=86=9297=20?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96=20+=20bug=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建时优化: - CMakeLists.txt: 新增 minify+gzip 构建流水线(源文件→复制→Python压缩→gzip→SPIFFS) - tools/minify_web.py: 纯 Python JS/CSS 压缩+gzip 脚本,无 Node.js 依赖 - 总资源体积从 ~1.6MB 降至 ~250KB(压缩率 84%) 服务端优化: - ts_http_server.c: 支持 gzip 预压缩文件发送(Accept-Encoding 检测) - ts_http_server.c: 静态资源 Cache-Control 7天长期缓存,index.html no-cache - 新增 font/woff2、font/woff Content-Type 前端加载优化: - index.html: JS 脚本添加 defer,消除渲染阻塞 - index.html: 语言包动态加载(仅加载当前语言,切换时按需加载) - index.html: 首屏关键 CSS 内联 + 主 CSS 异步 preload - terminal.js: xterm.js 从 移除,改为首次打开终端时按需加载 Bug 修复: - ts_api_system.c: CPU 监控改用 delta 算法,修复始终显示 0% 的问题 - ts_fan.c: 风扇曲线模式切换时重置迟滞状态,修复不自动调速 - zh-CN.js: SSH 翻译 key 迁移到正确命名空间,修复中文界面显示英文 - zh-CN.js/en-US.js/app.js: 清除全站残留 emoji(29+ 处) 文档: - README/README_EN: 添加 Lighthouse 97 徽章 - frontend_modification.md: 记录 v77 性能优化详情 --- README.md | 1 + README_EN.md | 1 + components/ts_api/src/ts_api_system.c | 56 +++--- components/ts_drivers/src/ts_fan.c | 23 ++- components/ts_net/src/ts_http_server.c | 83 +++++++-- components/ts_webui/CMakeLists.txt | 49 +++++- components/ts_webui/web/index.html | 104 ++++++++--- components/ts_webui/web/js/app.js | 60 +++---- components/ts_webui/web/js/lang/en-US.js | 38 ++-- components/ts_webui/web/js/lang/zh-CN.js | 106 ++++++++---- components/ts_webui/web/js/terminal.js | 44 +++++ docs/FLASH_VIA_SERIAL.md | 152 ++++++++++++++++ tools/minify_web.py | 212 +++++++++++++++++++++++ 13 files changed, 792 insertions(+), 137 deletions(-) create mode 100644 docs/FLASH_VIA_SERIAL.md create mode 100644 tools/minify_web.py diff --git a/README.md b/README.md index 0a94135..7251e54 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [English](README_EN.md) | [中文](README.md) [![Build Status](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml) +![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) [![License](https://img.shields.io/github/license/thomas-hiddenpeak/TianshanOS)](LICENSE) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) ![C](https://img.shields.io/badge/C-99-blue?logo=c) diff --git a/README_EN.md b/README_EN.md index 49d2eee..cc97ef2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -3,6 +3,7 @@ [English](README_EN.md) | [中文](README.md) [![Build Status](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml) +![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) [![Release](https://img.shields.io/github/v/release/thomas-hiddenpeak/TianshanOS)](https://github.com/thomas-hiddenpeak/TianshanOS/releases/latest) [![License](https://img.shields.io/github/license/thomas-hiddenpeak/TianshanOS)](LICENSE) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) diff --git a/components/ts_api/src/ts_api_system.c b/components/ts_api/src/ts_api_system.c index 88ebbc0..38d884a 100644 --- a/components/ts_api/src/ts_api_system.c +++ b/components/ts_api/src/ts_api_system.c @@ -231,6 +231,16 @@ static esp_err_t api_system_tasks(const cJSON *params, ts_api_result_t *result) /** * @brief system.cpu - Get CPU core statistics */ +/** + * CPU 使用率采用增量计算(delta): + * 记录上一次采样的 IDLE 任务运行时间和总运行时间, + * 通过两次采样的差值计算出当前(最近一个周期)的 CPU 使用率, + * 而非自启动以来的累计平均值。 + */ +static uint32_t s_prev_total_runtime = 0; +static uint32_t s_prev_idle_runtime[2] = {0, 0}; /* ESP32-S3 最多 2 核 */ +static bool s_cpu_prev_valid = false; + static esp_err_t api_system_cpu(const cJSON *params, ts_api_result_t *result) { cJSON *data = cJSON_CreateObject(); @@ -257,21 +267,14 @@ static esp_err_t api_system_cpu(const cJSON *params, ts_api_result_t *result) uint32_t total_runtime; UBaseType_t actual_count = uxTaskGetSystemState(task_array, task_count, &total_runtime); - /* Calculate per-core CPU usage */ - uint32_t core_runtime[2] = {0, 0}; /* ESP32-S3 has max 2 cores */ + /* 只需要每个核心的 IDLE 任务运行时间 */ uint32_t idle_runtime[2] = {0, 0}; - /* Sum up runtime for each core and track idle tasks */ for (UBaseType_t i = 0; i < actual_count; i++) { - BaseType_t core_id = task_array[i].xCoreID; - uint32_t task_runtime = task_array[i].ulRunTimeCounter; - - if (core_id >= 0 && core_id < num_cores) { - core_runtime[core_id] += task_runtime; - - /* Check if this is an IDLE task */ - if (strncmp(task_array[i].pcTaskName, "IDLE", 4) == 0) { - idle_runtime[core_id] = task_runtime; + if (strncmp(task_array[i].pcTaskName, "IDLE", 4) == 0) { + BaseType_t core_id = task_array[i].xCoreID; + if (core_id >= 0 && core_id < num_cores) { + idle_runtime[core_id] = task_array[i].ulRunTimeCounter; } } } @@ -279,26 +282,39 @@ static esp_err_t api_system_cpu(const cJSON *params, ts_api_result_t *result) cJSON *cores = cJSON_AddArrayToObject(data, "cores"); uint32_t total_usage_sum = 0; + /* 增量计算:使用两次采样的差值 */ + uint32_t delta_total = total_runtime - s_prev_total_runtime; + for (int i = 0; i < num_cores; i++) { cJSON *core = cJSON_CreateObject(); cJSON_AddNumberToObject(core, "id", i); - /* Calculate CPU usage percentage (100% - idle%) */ uint32_t cpu_percent = 0; - if (core_runtime[i] > 0) { - /* Usage = (total - idle) / total * 100 */ - uint32_t busy_time = core_runtime[i] - idle_runtime[i]; - cpu_percent = (busy_time * 100) / core_runtime[i]; + + if (s_cpu_prev_valid && delta_total > 0) { + /* 每个核心可用时间 = 总时间增量(timer 对所有核心公用) */ + uint32_t delta_idle = idle_runtime[i] - s_prev_idle_runtime[i]; + + /* 使用率 = 100% - 空闲率 */ + if (delta_idle < delta_total) { + cpu_percent = ((delta_total - delta_idle) * 100) / delta_total; + } + /* 限幅保护(避免计数器回绕时出错) */ + if (cpu_percent > 100) cpu_percent = 100; } cJSON_AddNumberToObject(core, "usage", cpu_percent); - cJSON_AddNumberToObject(core, "runtime", core_runtime[i]); - cJSON_AddNumberToObject(core, "idle_runtime", idle_runtime[i]); - cJSON_AddItemToArray(cores, core); total_usage_sum += cpu_percent; } + /* 保存本次采样数据,供下次增量计算使用 */ + s_prev_total_runtime = total_runtime; + for (int i = 0; i < num_cores && i < 2; i++) { + s_prev_idle_runtime[i] = idle_runtime[i]; + } + s_cpu_prev_valid = true; + /* Overall CPU usage (average across cores) */ cJSON_AddNumberToObject(data, "total_usage", num_cores > 0 ? total_usage_sum / num_cores : 0); cJSON_AddNumberToObject(data, "total_runtime", total_runtime); diff --git a/components/ts_drivers/src/ts_fan.c b/components/ts_drivers/src/ts_fan.c index ae935a3..9fad95e 100644 --- a/components/ts_drivers/src/ts_fan.c +++ b/components/ts_drivers/src/ts_fan.c @@ -514,13 +514,34 @@ esp_err_t ts_fan_set_mode(ts_fan_id_t fan, ts_fan_mode_t mode) if (fan >= TS_FAN_MAX) return ESP_ERR_INVALID_ARG; if (!s_fans[fan].initialized) return ESP_ERR_INVALID_STATE; + ts_fan_mode_t old_mode = s_fans[fan].mode; s_fans[fan].mode = mode; if (mode == TS_FAN_MODE_OFF) { update_pwm(&s_fans[fan], 0); } - TS_LOGI(TAG, "Fan %d mode set to %d", fan, mode); + /* 切换到曲线/自动模式时,重置迟滞状态以允许立即生效 */ + if ((mode == TS_FAN_MODE_CURVE || mode == TS_FAN_MODE_AUTO) && old_mode != mode) { + /* 将 last_stable_temp 设为一个远离实际温度的值,确保首次评估通过迟滞检查 */ + s_fans[fan].last_stable_temp = -1000; /* -100.0°C,远低于任何实际温度 */ + s_fans[fan].last_speed_change_time = 0; /* 允许立即调速 */ + + /* 立即获取当前温度 */ + if (s_auto_temp_enabled) { + ts_temp_data_t temp_data; + int16_t current_temp = ts_temp_get_effective(&temp_data); + if (current_temp > TS_TEMP_MIN_VALID) { + s_fans[fan].temperature = current_temp; + } + } + + TS_LOGI(TAG, "Fan %d mode -> %d: hysteresis reset, temp=%d", + fan, mode, s_fans[fan].temperature); + } else { + TS_LOGI(TAG, "Fan %d mode set to %d", fan, mode); + } + return ESP_OK; } diff --git a/components/ts_net/src/ts_http_server.c b/components/ts_net/src/ts_http_server.c index 23a56a3..69c76c9 100644 --- a/components/ts_net/src/ts_http_server.c +++ b/components/ts_net/src/ts_http_server.c @@ -218,16 +218,33 @@ esp_err_t ts_http_send_json(ts_http_request_t *req, int status, const char *json return ts_http_send_response(req, status, "application/json", json); } +/** + * @brief 检查客户端是否支持 gzip 编码 + */ +static bool client_accepts_gzip(httpd_req_t *req) +{ + char buf[128]; + if (httpd_req_get_hdr_value_str(req, "Accept-Encoding", buf, sizeof(buf)) == ESP_OK) { + return (strstr(buf, "gzip") != NULL); + } + return false; +} + +/** + * @brief 判断文件是否为可 gzip 压缩的文本资源 + */ +static bool is_compressible(const char *ext) +{ + if (!ext) return false; + return (strcmp(ext, ".js") == 0 || strcmp(ext, ".css") == 0 || + strcmp(ext, ".html") == 0 || strcmp(ext, ".json") == 0); +} + esp_err_t ts_http_send_file(ts_http_request_t *req, const char *filepath) { if (!req || !filepath) return ESP_ERR_INVALID_ARG; - ssize_t size = ts_storage_size(filepath); - if (size < 0) { - return ts_http_send_error(req, 404, "File not found"); - } - - // Detect content type + /* ===== Content-Type 检测 ===== */ const char *ext = strrchr(filepath, '.'); const char *content_type = "application/octet-stream"; if (ext) { @@ -239,18 +256,61 @@ esp_err_t ts_http_send_file(ts_http_request_t *req, const char *filepath) else if (strcmp(ext, ".jpg") == 0 || strcmp(ext, ".jpeg") == 0) content_type = "image/jpeg"; else if (strcmp(ext, ".svg") == 0) content_type = "image/svg+xml"; else if (strcmp(ext, ".ico") == 0) content_type = "image/x-icon"; + else if (strcmp(ext, ".woff2") == 0) content_type = "font/woff2"; + else if (strcmp(ext, ".woff") == 0) content_type = "font/woff"; } + /* ===== 尝试发送 gzip 预压缩版本 ===== */ + const char *actual_filepath = filepath; + char gz_path[140]; + bool use_gzip = false; + + if (is_compressible(ext) && client_accepts_gzip(req->req)) { + snprintf(gz_path, sizeof(gz_path), "%s.gz", filepath); + if (ts_storage_exists(gz_path)) { + actual_filepath = gz_path; + use_gzip = true; + } + } + + ssize_t size = ts_storage_size(actual_filepath); + if (size < 0) { + /* .gz 不存在时回退到原始文件 */ + actual_filepath = filepath; + use_gzip = false; + size = ts_storage_size(filepath); + if (size < 0) { + return ts_http_send_error(req, 404, "File not found"); + } + } + + /* ===== 设置响应头 ===== */ httpd_resp_set_type(req->req, content_type); - // 对于小文件(<32KB),直接发送 + if (use_gzip) { + httpd_resp_set_hdr(req->req, "Content-Encoding", "gzip"); + httpd_resp_set_hdr(req->req, "Vary", "Accept-Encoding"); + } + + /* Cache-Control:index.html 不缓存(确保更新),其他静态资源长期缓存 */ + const char *basename = strrchr(filepath, '/'); + if (basename && strcmp(basename, "/index.html") == 0) { + httpd_resp_set_hdr(req->req, "Cache-Control", "no-cache"); + } else if (ext) { + /* JS/CSS/字体/图片:缓存 7 天 */ + httpd_resp_set_hdr(req->req, "Cache-Control", "public, max-age=604800, immutable"); + } + + /* ===== 发送文件内容 ===== */ + + /* 对于小文件(<32KB),直接发送 */ if (size < 32 * 1024) { char *buf = TS_MALLOC_PSRAM(size); if (!buf) { return ts_http_send_error(req, 500, "Memory allocation failed"); } - if (ts_storage_read_file(filepath, buf, size) != size) { + if (ts_storage_read_file(actual_filepath, buf, size) != size) { free(buf); return ts_http_send_error(req, 500, "Failed to read file"); } @@ -260,13 +320,12 @@ esp_err_t ts_http_send_file(ts_http_request_t *req, const char *filepath) return ret; } - // 对于大文件,使用分块传输(chunked transfer) - FILE *f = fopen(filepath, "r"); + /* 对于大文件,使用分块传输(chunked transfer) */ + FILE *f = fopen(actual_filepath, "r"); if (!f) { return ts_http_send_error(req, 500, "Failed to open file"); } - // 分块大小:8KB #define CHUNK_SIZE 8192 char *chunk = TS_MALLOC_PSRAM(CHUNK_SIZE); if (!chunk) { @@ -288,7 +347,7 @@ esp_err_t ts_http_send_file(ts_http_request_t *req, const char *filepath) } } while (read_len == CHUNK_SIZE && ret == ESP_OK); - // 发送结束标记(空块) + /* 发送结束标记(空块) */ if (ret == ESP_OK) { httpd_resp_send_chunk(req->req, NULL, 0); } diff --git a/components/ts_webui/CMakeLists.txt b/components/ts_webui/CMakeLists.txt index 6946e3a..1e10d88 100644 --- a/components/ts_webui/CMakeLists.txt +++ b/components/ts_webui/CMakeLists.txt @@ -19,8 +19,53 @@ idf_component_register( json ) -# Embed web files +# =========================================================================== +# Web 资源构建时优化:minify + gzip 预压缩 +# +# 流程:源文件 -> 复制到 build/web -> minify(JS/CSS) -> gzip -> SPIFFS 打包 +# 原始源文件不会被修改,所有处理在构建目录中进行 +# =========================================================================== set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/web") +set(WEB_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/web_optimized") +set(MINIFY_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/minify_web.py") + if(EXISTS "${WEB_SRC_DIR}") - spiffs_create_partition_image(www ${WEB_SRC_DIR} FLASH_IN_PROJECT) + + # 收集所有源文件(用于依赖追踪) + file(GLOB_RECURSE WEB_ALL_FILES "${WEB_SRC_DIR}/*") + + # 步骤 1:复制 web 目录到构建目录 + add_custom_command( + OUTPUT "${WEB_BUILD_DIR}/.stamp_copy" + COMMAND ${CMAKE_COMMAND} -E remove_directory "${WEB_BUILD_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${WEB_SRC_DIR}" "${WEB_BUILD_DIR}" + COMMAND ${CMAKE_COMMAND} -E touch "${WEB_BUILD_DIR}/.stamp_copy" + DEPENDS ${WEB_ALL_FILES} + COMMENT "Copying web assets to build directory" + VERBATIM + ) + + # 步骤 2+3:Minify JS/CSS + Gzip 预压缩(纯 Python,无外部依赖) + find_program(PYTHON3 python3) + if(PYTHON3 AND EXISTS "${MINIFY_SCRIPT}") + add_custom_command( + OUTPUT "${WEB_BUILD_DIR}/.stamp_optimize" + COMMAND ${PYTHON3} "${MINIFY_SCRIPT}" "${WEB_BUILD_DIR}" --gzip + COMMAND ${CMAKE_COMMAND} -E touch "${WEB_BUILD_DIR}/.stamp_optimize" + DEPENDS "${WEB_BUILD_DIR}/.stamp_copy" "${MINIFY_SCRIPT}" + COMMENT "Minifying + gzipping web resources" + VERBATIM + ) + set(FINAL_STAMP "${WEB_BUILD_DIR}/.stamp_optimize") + message(STATUS "Web optimization enabled (minify + gzip)") + else() + set(FINAL_STAMP "${WEB_BUILD_DIR}/.stamp_copy") + message(STATUS "Web optimization skipped (python3 or script not found)") + endif() + + add_custom_target(optimize_web ALL DEPENDS ${FINAL_STAMP}) + + # 从优化后的构建目录创建 SPIFFS 镜像 + spiffs_create_partition_image(www ${WEB_BUILD_DIR} FLASH_IN_PROJECT) + endif() diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index ff8340f..da0230a 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -4,13 +4,48 @@ TianshanOS - + + + + + - - - - +
@@ -283,28 +318,53 @@

电压保护设置

} }; window.selectLanguage = function(lang) { - if (i18n.setLanguage(lang)) { - var langs = i18n.getSupportedLanguages(), nameEl = document.getElementById('lang-name'); - if (nameEl && langs[lang]) nameEl.textContent = langs[lang].name.split(' ')[0]; - i18n.translateDOM(); - var m = document.getElementById('lang-menu'); - if (m) m.classList.add('hidden'); + /* 动态加载语言包后切换 */ + function doSwitch() { + if (i18n.setLanguage(lang)) { + var langs = i18n.getSupportedLanguages(), nameEl = document.getElementById('lang-name'); + if (nameEl && langs[lang]) nameEl.textContent = langs[lang].name.split(' ')[0]; + i18n.translateDOM(); + /* 通知 app.js 重新渲染当前页面 */ + if (typeof window.reloadCurrentPage === 'function') window.reloadCurrentPage(); + var m = document.getElementById('lang-menu'); + if (m) m.classList.add('hidden'); + } + } + /* 如果语言包尚未加载,先动态加载 */ + if (!window._loadedLangs || !window._loadedLangs[lang]) { + var s = document.createElement('script'); + s.src = '/js/lang/' + lang + '.js'; + s.onload = function() { + window._loadedLangs = window._loadedLangs || {}; + window._loadedLangs[lang] = true; + doSwitch(); + }; + document.head.appendChild(s); + } else { + doSwitch(); } }; - document.addEventListener('DOMContentLoaded', function() { + /* 动态加载当前语言包,初始化 i18n */ + (function loadInitialLang() { i18n.init(); - var langs = i18n.getSupportedLanguages(), cur = i18n.getLanguage(), nameEl = document.getElementById('lang-name'); - if (nameEl && langs[cur]) nameEl.textContent = cur === 'zh-CN' ? '中文' : 'EN'; - i18n.translateDOM(); - }); + var cur = i18n.getLanguage(); + var s = document.createElement('script'); + s.src = '/js/lang/' + cur + '.js'; + s.onload = function() { + window._loadedLangs = {}; + window._loadedLangs[cur] = true; + var nameEl = document.getElementById('lang-name'); + if (nameEl) nameEl.textContent = cur === 'zh-CN' ? '中文' : 'EN'; + i18n.translateDOM(); + }; + document.head.appendChild(s); + })(); })(); - - - - - - + + + + diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 649ce0c..93b0389 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -8171,13 +8171,13 @@ async function deleteFile(path) { // 模块描述信息 const CONFIG_MODULE_INFO = { - net: { name: '网络', icon: '🌐', description: '以太网和主机名配置' }, - dhcp: { name: 'DHCP', icon: '📡', description: 'DHCP 服务器配置' }, - wifi: { name: 'WiFi', icon: '📶', description: 'WiFi AP 配置' }, - led: { name: 'LED', icon: '💡', description: 'LED 亮度和效果配置' }, - fan: { name: '风扇', icon: '🌀', description: '风扇控制配置' }, - device: { name: '设备', icon: '🖥️', description: 'AGX 设备控制配置' }, - system: { name: '系统', icon: '⚙️', description: '系统和控制台配置' } + net: { name: '网络', icon: 'ri-global-line', description: '以太网和主机名配置' }, + dhcp: { name: 'DHCP', icon: 'ri-router-line', description: 'DHCP 服务器配置' }, + wifi: { name: 'WiFi', icon: 'ri-wifi-line', description: 'WiFi AP 配置' }, + led: { name: 'LED', icon: 'ri-lightbulb-line', description: 'LED 亮度和效果配置' }, + fan: { name: '风扇', icon: 'ri-tornado-line', description: '风扇控制配置' }, + device: { name: '设备', icon: 'ri-computer-line', description: 'AGX 设备控制配置' }, + system: { name: '系统', icon: 'ri-settings-line', description: '系统和控制台配置' } }; // 配置项的用户友好描述 @@ -10205,7 +10205,7 @@ async function nohupCheckProcess() { return; } // 使用 PID 文件检查进程状态,并显示进程详情 - await executeNohupHelperCommand(`if [ -f ${currentNohupInfo.pidFile} ]; then PID=$(cat ${currentNohupInfo.pidFile}); if kill -0 $PID 2>/dev/null; then echo "进程运行中 (PID: $PID)"; ps -p $PID -o pid,user,%cpu,%mem,etime,args --no-headers 2>/dev/null || ps -p $PID 2>/dev/null; else echo "进程已退出 (PID: $PID)"; fi; else echo "❌ PID 文件不存在"; fi`); + await executeNohupHelperCommand(`if [ -f ${currentNohupInfo.pidFile} ]; then PID=$(cat ${currentNohupInfo.pidFile}); if kill -0 $PID 2>/dev/null; then echo "进程运行中 (PID: $PID)"; ps -p $PID -o pid,user,%cpu,%mem,etime,args --no-headers 2>/dev/null || ps -p $PID 2>/dev/null; else echo "进程已退出 (PID: $PID)"; fi; else echo "PID 文件不存在"; fi`); } /* nohup 快捷操作:停止进程(使用 PID 文件) */ @@ -10227,7 +10227,7 @@ async function nohupStopProcess() { await executeNohupHelperCommand(`if [ -f ${currentNohupInfo.pidFile} ]; then kill $(cat ${currentNohupInfo.pidFile}) 2>/dev/null && rm -f ${currentNohupInfo.pidFile} && echo "进程已停止"; else echo "PID 文件不存在"; fi`); // 再次检查进程状态 - await executeNohupHelperCommand(`[ -f ${currentNohupInfo.pidFile} ] && kill -0 $(cat ${currentNohupInfo.pidFile}) 2>/dev/null && echo "⚠️ 进程仍在运行" || echo "✅ 确认:进程已停止"`); + await executeNohupHelperCommand(`[ -f ${currentNohupInfo.pidFile} ] && kill -0 $(cat ${currentNohupInfo.pidFile}) 2>/dev/null && echo "进程仍在运行" || echo "确认:进程已停止"`); } /* 执行 nohup 辅助命令 */ @@ -10357,7 +10357,7 @@ async function stopServiceProcess(idx, safeName) { document.getElementById('cancel-exec-btn').style.display = 'none'; document.getElementById('nohup-actions').style.display = 'none'; - resultPre.textContent = `🛑 停止服务: ${cmd.name}\n\n`; + resultPre.textContent = `停止服务: ${cmd.name}\n\n`; resultSection.scrollIntoView({ behavior: 'smooth' }); try { @@ -12637,7 +12637,7 @@ async function verifyConfigPack() { } resultBox.className = 'result-box'; - resultBox.textContent = '🔄 验证中...'; + resultBox.textContent = '验证中...'; resultBox.classList.remove('hidden'); preview.classList.add('hidden'); @@ -12686,7 +12686,7 @@ async function importConfigPack() { } resultBox.className = 'result-box'; - resultBox.textContent = '🔄 导入中...'; + resultBox.textContent = '导入中...'; resultBox.classList.remove('hidden'); try { @@ -12776,7 +12776,7 @@ function closeConfigPackApplyConfirm() { */ async function applyConfigPackFromPath(path) { closeConfigPackApplyConfirm(); - showToast('🔄 正在应用配置...', 'info'); + showToast('正在应用配置...', 'info'); try { const result = await api.call('config.pack.apply', { path }, 'POST'); @@ -13113,7 +13113,7 @@ async function exportConfigPack() { resultBox.className = 'result-box'; resultBox.style.visibility = 'visible'; - resultBox.textContent = `🔄 生成配置包中 (${okFiles.length} 个文件)...`; + resultBox.textContent = `生成配置包中 (${okFiles.length} 个文件)...`; document.getElementById('pack-export-tscfg').value = ''; try { @@ -13443,7 +13443,7 @@ async function generateCertKeypair() { const force = window._certPkiStatus?.has_private_key; resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = '🔄 正在生成密钥对...'; + resultBox.textContent = '正在生成密钥对...'; btn.disabled = true; try { @@ -13490,7 +13490,7 @@ async function generateCSR() { const btn = document.getElementById('csr-gen-btn'); resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = '🔄 正在生成 CSR...'; + resultBox.textContent = '正在生成 CSR...'; btn.disabled = true; try { @@ -13545,7 +13545,7 @@ async function installCertificate() { const resultBox = document.getElementById('cert-install-result'); resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = '🔄 正在安装证书...'; + resultBox.textContent = '正在安装证书...'; try { const result = await api.certInstall(certPem); @@ -13587,7 +13587,7 @@ async function installCAChain() { const resultBox = document.getElementById('ca-install-result'); resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = '🔄 正在安装 CA 证书链...'; + resultBox.textContent = '正在安装 CA 证书链...'; try { const result = await api.certInstallCA(caPem); @@ -15713,11 +15713,11 @@ async function checkForUpdates() { const localParts = parseVersion(localVersion); const serverParts = parseVersion(serverVersion); if (serverParts.major > localParts.major) { - updateType = '🔴 主版本更新'; + updateType = '主版本更新'; } else if (serverParts.minor > localParts.minor) { - updateType = '🟡 功能更新'; + updateType = '功能更新'; } else { - updateType = '🟢 补丁更新'; + updateType = '补丁更新'; } } @@ -18184,7 +18184,7 @@ async function showImageSelectModal(title, onSelect) {
@@ -18323,7 +18323,7 @@ async function showVariableSelectModal(targetInputId, mode = 'insert') { @@ -19381,7 +19381,7 @@ async function testRestConnection() { btn.disabled = true; btn.innerHTML = ' 测试中...'; resultPanel.style.display = 'block'; - statusSpan.innerHTML = '🔄 正在请求...'; + statusSpan.innerHTML = ' 正在请求...'; try { // 通过 ESP32 代理请求(避免 CORS) @@ -19499,7 +19499,7 @@ async function testSioConnection() { // 显示连接阶段状态 const statusText = event ? `正在连接并等待事件: ${event}` : '正在连接并自动发现事件...'; - statusSpan.innerHTML = `🔄 ${statusText}`; + statusSpan.innerHTML = ` ${statusText}`; try { // 通过 ESP32 测试 Socket.IO 连接 @@ -20386,7 +20386,7 @@ async function openConditionVarSelector(rowId) { @@ -20562,12 +20562,12 @@ async function addActionTemplateRow(templateId = '', delayMs = 0, repeatMode = '
diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index 5926356..63bb417 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -2266,7 +2266,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { getVariablesFailed: 'Failed to get variables', getStatusFailed: 'Failed to get status', selectActionType: 'Please select action type', - networkFailed: '🔌 Network connection failed', + networkFailed: 'Network connection failed', loadingVariables: 'Loading...', loadingOptions: '-- Loading --', sourceNoData: 'No variable data for this source', @@ -2303,12 +2303,12 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { selectDeviceHint: 'Select LED device to control', controlType: 'Control Type', colorFill: 'Color Fill', - effectAnim: '🎬 Effect Animation', + effectAnim: 'Effect Animation', brightnessOnly: 'Brightness Only', turnOff: 'Turn Off', - textDisplay: '📝 Text Display', - imageDisplay: '📷 Display Image', - qrcodeDisplay: '📱 Display QR Code', + textDisplay: 'Text Display', + imageDisplay: 'Display Image', + qrcodeDisplay: 'Display QR Code', filterDisplay: 'Post Filter', filterStop: 'Stop Filter', textStop: 'Stop Text', @@ -2478,15 +2478,15 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { qrBgImage: 'Background Image (optional)', qrBgNone: 'None', filterLabel: 'Filter', - filterPulse: '💓 Pulse', - filterBreathing: '💨 Breathing', + filterPulse: 'Pulse', + filterBreathing: 'Breathing', filterBlink: 'Blink', - filterWave: '🌊 Wave', - filterScanline: '📺 Scanline', - filterGlitch: '⚡ Glitch', - filterRainbow: '🌈 Rainbow', - filterSparkle: '✨ Sparkle', - filterPlasma: '🎆 Plasma', + filterWave: 'Wave', + filterScanline: 'Scanline', + filterGlitch: 'Glitch', + filterRainbow: 'Rainbow', + filterSparkle: 'Sparkle', + filterPlasma: 'Plasma', filterSepia: 'Sepia', filterPosterize: 'Posterize', filterContrast: 'Contrast', @@ -2523,7 +2523,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { editTempCurve: 'Edit Temperature Curve', manualModeHint: 'Switch to manual mode to adjust', // Curve management modal - curveManagement: '📈 Fan Curve Management', + curveManagement: 'Fan Curve Management', selectFan: 'Select Fan', fanN: 'Fan {id}', bindTempVar: 'Bind Temperature Variable', @@ -2534,9 +2534,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { bind: 'Bind', unbind: 'Unbind', tempSpeedCurve: 'Temperature-Speed Curve', - addPoint: '➕ Add Point', + addPoint: '+ Add Point', curveHint: 'Uses min speed below lowest point, max speed above highest point', - curvePreview: '📈 Curve Preview', + curvePreview: 'Curve Preview', minDuty: 'Min Duty Cycle (%)', maxDuty: 'Max Duty Cycle (%)', minDutyHint: 'Minimum speed at this value', @@ -2647,7 +2647,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { minute1: '1 min', noWidgets: 'No widgets', addedWidgets: 'Added Widgets', - addNewWidget: '➕ Add New Widget', + addNewWidget: '+ Add New Widget', selectWidgetHint: 'Select a widget to edit
or add a new one', moveUp: 'Move Up', moveDown: 'Move Down', @@ -2672,7 +2672,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { icon: 'Icon', iconPlaceholder: 'emoji', color: 'Color', - layoutWidth: '📐 Layout Width', + layoutWidth: 'Layout Width', unit: 'Unit', decimals: 'Decimals', minValue: 'Min Value', @@ -2950,7 +2950,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { selectedItems: 'Selected {count} items', displayStats: 'Showing {filtered}/{total} entries', // Modal Titles - newCommand: '➕ New Command', + newCommand: '+ New Command', editCommand: 'Edit Command', commandVariables: 'Command Variables', sourceVariables: '{source} Variables', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 5122388..3e0071e 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -561,7 +561,51 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { savingConfig: '正在保存配置...', configExistsCheckOverwrite: '配置 {id} 已存在,请勾选「覆盖」选项', savedConfig: '已保存配置', - restartToTakeEffect: '重启系统后生效' + restartToTakeEffect: '重启系统后生效', + // 命令编辑器标签(ssh.XXX 引用) + cmdId: '指令 ID', + cmdName: '指令名称', + cmdIdHint: '唯一标识符,仅限字母、数字、下划线、连字符,不能以 _ 或 - 开头/结尾', + cmdIdPlaceholder: '例如:restart_nginx, check_status', + cmdNamePlaceholder: '例如:重启服务', + cmdCommandPlaceholder: '例如:sudo systemctl restart nginx', + cmdDescPlaceholder: '简要说明这个指令的作用', + multiLineHint: '支持多行命令,每行一条', + advancedOptions: '高级选项(模式匹配)', + // nohup 与服务模式 + nohupTitle: '后台执行(nohup)', + nohupHint: '命令将在服务器后台运行,SSH 断开后不受影响。适合重启、长时间任务等场景', + serviceModeLabel: '服务模式(监测就绪状态)', + serviceModeHint: '启动后持续监测日志,检测到就绪字符串后更新变量状态', + readyPatternRequired: '就绪匹配模式', + cmdReadyPatternPlaceholder: '例如:Running on|Server started', + readyPatternHint: '日志中出现此字符串时标记为就绪(支持 | 分隔多个模式)', + serviceFailPatternHint: '日志中出现此字符串时标记为失败(可选,支持 | 分隔多个模式)', + cmdFailPatternPlaceholder: '例如:error|failed|Exception', + readyTimeoutLabel: '超时(秒)', + readyTimeoutHint: '超过此时间未匹配到就绪模式则标记为 timeout', + readyIntervalLabel: '检测间隔(毫秒)', + readyIntervalHint: '每隔多久检测一次日志文件', + serviceLogHint: '服务启动后,系统将监测日志文件:', + serviceLogPath: '/tmp/ts_nohup_[命令名].log', + serviceStatusHint: '变量 [变量名].status 会根据日志匹配自动更新状态', + // 变量与模式匹配 + varNameLabel: '存储变量名', + cmdVarNamePlaceholder: '例如:ping_test', + successPatternLabel: '成功匹配模式', + cmdExpectPatternPlaceholder: '例如:active (running)', + successPatternHint: '输出中包含此文本时标记为成功', + failPatternLabel: '失败匹配模式', + failPatternHint: '输出中包含此文本时标记为失败', + extractPatternLabel: '提取模式', + cmdExtractPatternPlaceholder: '例如:version: (.*)', + extractPatternHint: '从输出中提取匹配内容,使用 (.*) 捕获组', + stopOnMatchLabel: '匹配后自动停止', + stopOnMatchHint: '适用于 ping 等持续运行的命令,匹配成功后自动终止', + timeoutLabel: '超时(秒)', + timeoutHint: '超时仅在设置了成功/失败模式或勾选了"匹配后停止"时有效', + cancelBtn: '取消', + saveBtn: '保存' }, // 安全 @@ -1491,7 +1535,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { statusChecking: '检测中', statusTimeout: '超时', statusFailed: '失败', - statusIdle: '⏸️ 未启动', + statusIdle: '未启动', statusStopped: '已停止', // 错误消息 hostNotFound: '主机信息不存在', @@ -1628,7 +1672,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { deployingKey: '正在部署密钥...', deploySuccess: '部署成功!现在可以使用密钥 "{keyId}" 免密登录 {target}', authVerified: '公钥认证已验证', - authSkipped: '⚠ 公钥认证验证跳过', + authSkipped: '公钥认证验证跳过', revokingKey: '正在撤销密钥...', revokeSuccess: '撤销成功!已从 {target} 移除 {count} 个匹配的公钥', keyNotFound: '该公钥未在 {target} 上找到', @@ -2227,7 +2271,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { getVariablesFailed: '获取变量失败', getStatusFailed: '获取状态失败', selectActionType: '请选择动作类型', - networkFailed: '🔌 网络连接失败', + networkFailed: '网络连接失败', loadingVariables: '加载中...', loadingOptions: '-- 加载中 --', sourceNoData: '该数据源暂无变量数据', @@ -2263,14 +2307,14 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { selectDevice: '-- 选择设备 --', selectDeviceHint: '选择要控制的 LED 设备', controlType: '控制类型', - colorFill: '🎨 纯色填充', - effectAnim: '🎬 程序动画', - brightnessOnly: '☀️ 仅调节亮度', + colorFill: '纯色填充', + effectAnim: '程序动画', + brightnessOnly: '仅调节亮度', turnOff: '⏹ 关闭', - textDisplay: '📝 文本显示', - imageDisplay: '📷 显示图像', - qrcodeDisplay: '📱 显示QR码', - filterDisplay: '🎨 后处理滤镜', + textDisplay: '文本显示', + imageDisplay: '显示图像', + qrcodeDisplay: '显示QR码', + filterDisplay: '后处理滤镜', filterStop: '⏹ 停止滤镜', textStop: '⏹ 停止文本', turnOffDevice: '⏹ 关闭设备', @@ -2439,18 +2483,18 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { qrBgImage: '背景图(可选)', qrBgNone: '无', filterLabel: '滤镜', - filterPulse: '💓 脉冲', - filterBreathing: '💨 呼吸', + filterPulse: '脉冲', + filterBreathing: '呼吸', filterBlink: '闪烁', - filterWave: '🌊 波浪', - filterScanline: '📺 扫描线', - filterGlitch: '⚡ 故障艺术', - filterRainbow: '🌈 彩虹', - filterSparkle: '✨ 闪耀', - filterPlasma: '🎆 等离子体', - filterSepia: '🖼️ 怀旧', - filterPosterize: '🎨 色阶分离', - filterContrast: '🔆 对比度', + filterWave: '波浪', + filterScanline: '扫描线', + filterGlitch: '故障艺术', + filterRainbow: '彩虹', + filterSparkle: '闪耀', + filterPlasma: '等离子体', + filterSepia: '怀旧', + filterPosterize: '色阶分离', + filterContrast: '对比度', filterInvert: '反色', filterGrayscale: '⬜ 灰度', // 变量动作 placeholder @@ -2484,10 +2528,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { editTempCurve: '编辑温度曲线', manualModeHint: '切换到手动模式后可调节', // 曲线管理模态框 - curveManagement: '📈 风扇曲线管理', + curveManagement: '风扇曲线管理', selectFan: '选择风扇', fanN: '风扇 {id}', - bindTempVar: '🌡️ 绑定温度变量', + bindTempVar: '绑定温度变量', unbound: '未绑定', bound: '已绑定', currentBound: '当前绑定', @@ -2495,9 +2539,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { bind: '绑定', unbind: '解绑', tempSpeedCurve: '温度-转速曲线', - addPoint: '➕ 添加点', + addPoint: '+ 添加点', curveHint: '温度低于最小点时使用最小转速,高于最大点时使用最大转速', - curvePreview: '📈 曲线预览', + curvePreview: '曲线预览', minDuty: '最小占空比 (%)', maxDuty: '最大占空比 (%)', minDutyHint: '低于此值时的最低转速', @@ -2608,7 +2652,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { minute1: '1 分钟', noWidgets: '暂无组件', addedWidgets: '已添加组件', - addNewWidget: '➕ 添加新组件', + addNewWidget: '+ 添加新组件', selectWidgetHint: '选择左侧组件进行编辑
或添加新组件', moveUp: '上移', moveDown: '下移', @@ -2633,7 +2677,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { icon: '图标', iconPlaceholder: 'emoji', color: '颜色', - layoutWidth: '📐 布局宽度', + layoutWidth: '布局宽度', unit: '单位', decimals: '小数位', minValue: '最小值', @@ -2652,7 +2696,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { statusAttention: '注意', statusWarning: '警告', // 温度变量选择 - tempVariables: '🌡️ 温度变量', + tempVariables: '温度变量', otherNumericVariables: '其他数值变量', // 错误消息 setDutyLimitFailed: '设置占空比限制失败', @@ -2675,7 +2719,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { fillColor: '填充颜色', quickColors: '快捷颜色', // 原有 - on: '🔆 已开启', + on: '已开启', off: '已关闭', defaultFont: '默认', quickActionsLoadFailed: '无法加载快捷操作', @@ -2911,7 +2955,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { selectedItems: '已选择 {count} 项', displayStats: '显示 {filtered}/{total} 条', // 弹窗标题 - newCommand: '➕ 新建指令', + newCommand: '+ 新建指令', editCommand: '编辑指令', commandVariables: '指令变量', sourceVariables: '{source} 变量', diff --git a/components/ts_webui/web/js/terminal.js b/components/ts_webui/web/js/terminal.js index 0af16f9..8bf9134 100644 --- a/components/ts_webui/web/js/terminal.js +++ b/components/ts_webui/web/js/terminal.js @@ -3,6 +3,47 @@ * 基于 xterm.js 的 Web 终端实现 */ +/** + * 按需加载 xterm.js 及其插件(从 CDN) + * 仅在首次打开终端页面时触发,后续调用直接返回 + */ +const _xtermReady = (function() { + let _promise = null; + + function loadCSS(href) { + return new Promise(function(resolve, reject) { + if (document.querySelector('link[href="' + href + '"]')) { resolve(); return; } + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.onload = resolve; + link.onerror = reject; + document.head.appendChild(link); + }); + } + function loadScript(src) { + return new Promise(function(resolve, reject) { + if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; } + var s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = reject; + document.head.appendChild(s); + }); + } + + return function ensureXtermLoaded() { + if (_promise) return _promise; + if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') { + return (_promise = Promise.resolve()); + } + _promise = loadCSS('https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css') + .then(function() { return loadScript('https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js'); }) + .then(function() { return loadScript('https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js'); }); + return _promise; + }; +})(); + class WebTerminal { constructor(containerId) { this.containerId = containerId; @@ -31,6 +72,9 @@ class WebTerminal { return false; } + // 按需加载 xterm.js(首次访问终端页面时从 CDN 拉取) + await _xtermReady(); + // 创建 xterm.js 终端 this.terminal = new Terminal({ cursorBlink: true, diff --git a/docs/FLASH_VIA_SERIAL.md b/docs/FLASH_VIA_SERIAL.md new file mode 100644 index 0000000..ad9844b --- /dev/null +++ b/docs/FLASH_VIA_SERIAL.md @@ -0,0 +1,152 @@ +# 串口刷机指南 + +## 一、编译 www 和固件 + +### 1. 环境准备 + +- 已安装 **ESP-IDF v5.5+**,并执行过 `install.sh esp32s3`。 +- 在终端中激活环境后再进行编译: + +```bash +# 激活 ESP-IDF(路径按你本机安装位置调整,例如 v5.5、v5.5.2 等) +source ~/esp/v5.5.2/esp-idf/export.sh +``` + +若提示 **Python virtual environment not found**,需先在本机完成一次安装: + +```bash +cd ~/esp/v5.5.2/esp-idf +./install.sh esp32s3 +``` + +安装完成后再执行上面的 `source .../export.sh`。 + +### 2. www 与固件的关系 + +- **www** 即 `components/ts_webui/web/` 下的静态文件(HTML/JS/CSS 等)。 +- 没有单独的「编译 www」步骤:执行 **一次** `idf.py build` 时会: + - 把 `web/` 打成 SPIFFS 镜像; + - 与固件一起参与链接,写入分区 `www`(约 2MB)。 +- 因此:**改完 web 前端后,只需重新执行一次完整构建即可。** + +### 3. 编译步骤 + +在项目根目录 `TianshanOS/` 下: + +```bash +# 设置目标芯片(首次或换芯片时执行一次) +idf.py set-target esp32s3 + +# 方式 A:使用项目构建脚本(推荐) +./tools/build.sh # 增量构建,版本号不变 +./tools/build.sh --fresh # 强制更新版本号后构建 +./tools/build.sh --clean # 完整清理后构建 + +# 方式 B:直接使用 idf.py +idf.py build +``` + +构建成功后,可在 `build/` 下看到: + +- `TianshanOS.bin` — 应用固件(烧录到 ota_0) +- `bootloader.bin` — 引导程序 +- `partition-table.bin` — 分区表 +- `www.bin` — Web 资源 SPIFFS 镜像(烧录到 www 分区) + +--- + +## 二、通过串口线刷机 + +### 1. 连接设备 + +- 用 **USB 串口线** 连接电脑与 ESP32-S3 板子。 +- 确认板子进入下载模式(多数开发板会自动识别;若需手动,按说明进入 Boot 模式)。 + +### 2. 查看串口设备名 + +```bash +# Linux +ls /dev/ttyUSB* /dev/ttyACM* + +# macOS +ls /dev/cu.usb* /dev/cu.usbserial* +``` + +常见示例:Linux 为 `/dev/ttyUSB0` 或 `/dev/ttyACM0`,macOS 为 `/dev/cu.usbserial-xxxx` 或 `/dev/cu.usbmodemxxxx`。 + +### 3. 一键烧录(推荐) + +在项目根目录、且已 `source export.sh` 的前提下: + +```bash +# 将 换成你的串口,例如 /dev/ttyUSB0 或 /dev/cu.usbserial-1234 +idf.py -p flash +``` + +例如: + +```bash +idf.py -p /dev/ttyUSB0 flash +# 或 +idf.py -p /dev/cu.usbmodem113301 flash +``` + +该命令会按当前分区表自动烧录:bootloader、分区表、应用固件、www 等所需分区。 + +### 4. 烧录后打开串口监视器 + +```bash +idf.py -p monitor +``` + +或烧录与监视一起执行: + +```bash +idf.py -p flash monitor +``` + +退出监视器:`Ctrl + ]`。 + +### 5. 仅用 esptool 手动烧录(可选) + +若无法使用 `idf.py flash`,可用 esptool 按分区表手动写: + +当前 `partitions.csv` 中主要分区偏移为: + +| 分区 | 偏移 | 文件 | +|------------|----------|------| +| bootloader | 0x0 | build/bootloader/bootloader.bin | +| 分区表 | 0x8000 | build/partition_table/partition-table.bin | +| 应用 ota_0 | 0x20000 | build/TianshanOS.bin | +| www | 0x6A0000 | build/www.bin | + +在 `build` 目录下执行(把 `` 换成实际串口): + +```bash +cd build + +esptool.py --chip esp32s3 -p write_flash \ + 0x0 bootloader/bootloader.bin \ + 0x8000 partition_table/partition-table.bin \ + 0x20000 TianshanOS.bin \ + 0x6A0000 www.bin +``` + +**注意**:若分区表或分区名有改动,请以当前 `partitions.csv` 和构建产物为准,必要时用 `idf.py partition-table` 等确认。 + +--- + +## 三、常见问题 + +1. **提示找不到串口** + - 安装 CP210x/CH340 等 USB 转串口驱动。 + - 用 `ls /dev/tty*` 或 `ls /dev/cu.*` 确认设备名。 + +2. **烧录超时 / 连接失败** + - 拔插 USB,或按住 Boot 键再上电/按 Reset,使芯片进入下载模式后再执行 `idf.py -p flash`。 + +3. **只改了 web 前端** + - 执行一次 `idf.py build`(或 `./tools/build.sh`),再执行 `idf.py -p flash` 即可,www 会随固件一起更新。 + +4. **权限不足** + - Linux 下可加 udev 规则,或临时:`sudo chmod 666 /dev/ttyUSB0`(不推荐长期使用)。 diff --git a/tools/minify_web.py b/tools/minify_web.py new file mode 100644 index 0000000..0c1bd9a --- /dev/null +++ b/tools/minify_web.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +TianshanOS Web 资源优化脚本 + +对 web 目录下的 JS/CSS 文件进行就地压缩(minify)+ gzip 预压缩。 +纯 Python 实现,不依赖 Node.js / terser / csso。 + +用法: + python3 tools/minify_web.py [--gzip] + +例如: + python3 tools/minify_web.py components/ts_webui/web --gzip +""" + +import os +import re +import sys +import glob +import gzip as gzip_module + + +def minify_js(source: str) -> str: + """简易 JS 压缩:移除注释和多余空白,保留字符串内容""" + result = [] + i = 0 + n = len(source) + + while i < n: + # 字符串字面量(单引号/双引号/模板字符串) + if source[i] in ('"', "'", '`'): + quote = source[i] + result.append(source[i]) + i += 1 + while i < n: + if source[i] == '\\' and i + 1 < n: + result.append(source[i]) + result.append(source[i + 1]) + i += 2 + elif source[i] == quote: + result.append(source[i]) + i += 1 + break + else: + result.append(source[i]) + i += 1 + # 单行注释 + elif source[i] == '/' and i + 1 < n and source[i + 1] == '/': + # 跳到行尾 + while i < n and source[i] != '\n': + i += 1 + # 多行注释 + elif source[i] == '/' and i + 1 < n and source[i + 1] == '*': + i += 2 + while i < n - 1: + if source[i] == '*' and source[i + 1] == '/': + i += 2 + break + i += 1 + else: + i = n + # 正则表达式字面量(简化处理) + elif source[i] == '/' and i > 0 and result and result[-1] in ('=', '(', ',', '!', '&', '|', '?', ':', ';', '{', '}', '[', '\n'): + result.append(source[i]) + i += 1 + while i < n: + if source[i] == '\\' and i + 1 < n: + result.append(source[i]) + result.append(source[i + 1]) + i += 2 + elif source[i] == '/': + result.append(source[i]) + i += 1 + # Regex flags + while i < n and source[i].isalpha(): + result.append(source[i]) + i += 1 + break + else: + result.append(source[i]) + i += 1 + else: + result.append(source[i]) + i += 1 + + text = ''.join(result) + + # 合并多余空行为单个换行 + text = re.sub(r'\n\s*\n+', '\n', text) + # 移除行首空白(保留至少一个空格在关键字之间) + lines = [] + for line in text.split('\n'): + stripped = line.strip() + if stripped: + lines.append(stripped) + text = '\n'.join(lines) + + return text + + +def minify_css(source: str) -> str: + """CSS 压缩:移除注释和多余空白""" + # 移除注释 + result = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL) + # 移除多余空白 + result = re.sub(r'\s+', ' ', result) + # 移除 { } ; : , 前后多余空格 + result = re.sub(r'\s*([{}:;,>~+])\s*', r'\1', result) + # 恢复某些必要的空格(如 "and (" in media queries) + result = re.sub(r'\band\(', 'and (', result) + result = re.sub(r'\bnot\(', 'not (', result) + # 移除末尾分号(在 } 前) + result = result.replace(';}', '}') + return result.strip() + + +def process_directory(web_dir: str) -> None: + """处理 web 目录下所有 JS/CSS 文件""" + if not os.path.isdir(web_dir): + print(f"Error: {web_dir} is not a directory") + sys.exit(1) + + total_saved = 0 + + # 处理 JS 文件 + for filepath in glob.glob(os.path.join(web_dir, '**', '*.js'), recursive=True): + original_size = os.path.getsize(filepath) + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + minified = minify_js(content) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(minified) + + new_size = os.path.getsize(filepath) + saved = original_size - new_size + total_saved += saved + pct = (saved / original_size * 100) if original_size > 0 else 0 + rel_path = os.path.relpath(filepath, web_dir) + print(f" JS {rel_path}: {original_size:,} -> {new_size:,} ({pct:.1f}% saved)") + + # 处理 CSS 文件 + for filepath in glob.glob(os.path.join(web_dir, '**', '*.css'), recursive=True): + original_size = os.path.getsize(filepath) + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + minified = minify_css(content) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(minified) + + new_size = os.path.getsize(filepath) + saved = original_size - new_size + total_saved += saved + pct = (saved / original_size * 100) if original_size > 0 else 0 + rel_path = os.path.relpath(filepath, web_dir) + print(f" CSS {rel_path}: {original_size:,} -> {new_size:,} ({pct:.1f}% saved)") + + print(f"\n Total saved: {total_saved:,} bytes ({total_saved / 1024:.1f} KB)") + + +def gzip_directory(web_dir: str) -> None: + """对 web 目录下所有 JS/CSS/HTML 文件生成 .gz 副本""" + if not os.path.isdir(web_dir): + print(f"Error: {web_dir} is not a directory") + sys.exit(1) + + extensions = ('*.js', '*.css', '*.html') + total_original = 0 + total_compressed = 0 + count = 0 + + for ext in extensions: + for filepath in glob.glob(os.path.join(web_dir, '**', ext), recursive=True): + original_size = os.path.getsize(filepath) + gz_path = filepath + '.gz' + + with open(filepath, 'rb') as f_in: + with gzip_module.open(gz_path, 'wb', compresslevel=9) as f_out: + f_out.write(f_in.read()) + + gz_size = os.path.getsize(gz_path) + total_original += original_size + total_compressed += gz_size + count += 1 + + pct = (1 - gz_size / original_size) * 100 if original_size > 0 else 0 + rel_path = os.path.relpath(filepath, web_dir) + print(f" GZ {rel_path}: {original_size:,} -> {gz_size:,} ({pct:.1f}% smaller)") + + if total_original > 0: + overall_pct = (1 - total_compressed / total_original) * 100 + print(f"\n Gzipped {count} files: {total_original:,} -> {total_compressed:,} ({overall_pct:.1f}% overall)") + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [--gzip]") + sys.exit(1) + + web_dir = sys.argv[1] + do_gzip = '--gzip' in sys.argv + + print(f"Minifying web resources in {web_dir}...") + process_directory(web_dir) + + if do_gzip: + print(f"\nGzipping web resources in {web_dir}...") + gzip_directory(web_dir) + + print("Done.")