diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 560eb6d..1de6e2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,8 @@ jobs: build: name: Build ESP32-S3 Firmware runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Checkout repository @@ -87,9 +89,9 @@ jobs: run: | if [ -f build/project_description.json ]; then VERSION=$(grep -o '"project_version":[^,]*' build/project_description.json | cut -d'"' -f4) - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "📦 Build version: $VERSION" fi + echo "version=${VERSION:-0.0.0}" >> $GITHUB_OUTPUT + echo "📦 Build version: ${VERSION:-0.0.0}" - name: Print build size run: | @@ -118,11 +120,11 @@ jobs: build/config/sdkconfig.h retention-days: 7 - # 可选:发布版本时创建 Release + # 编译成功后自动创建 Release:tag 推送 或 main 分支推送 release: name: Create Release needs: build - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main' && github.event_name == 'push') runs-on: ubuntu-latest permissions: contents: write # 创建 Release 需要写权限 @@ -134,9 +136,24 @@ jobs: pattern: tianshanos-firmware-* path: firmware + - name: Check if release already exists (main branch) + if: github.ref == 'refs/heads/main' + id: check + run: | + TAG="v${{ needs.build.outputs.version }}" + if gh release view "$TAG" 2>/dev/null; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "⏭️ Release $TAG already exists, skipping" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && steps.check.outputs.skip != 'true') uses: softprops/action-gh-release@v2 with: + tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} + name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} files: firmware/**/*.bin generate_release_notes: true env: diff --git a/README.md b/README.md index 7251e54..de8a141 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \ ## 当前状态 -**版本**: 0.4.0 +**版本**: 0.4.4 **阶段**: Phase 38 完成 - WebUI 多语言支持 ### 已完成功能 diff --git a/components/ts_security/src/ts_security.c b/components/ts_security/src/ts_security.c index c97b10c..d3bdb02 100644 --- a/components/ts_security/src/ts_security.c +++ b/components/ts_security/src/ts_security.c @@ -166,6 +166,22 @@ esp_err_t ts_security_store_cert(const char *name, ts_cert_type_t type, return ret; } +/** + * @brief 驱逐指定 client_id 的所有会话(释放槽位) + * @note 仅内部使用,在 create_session 槽位满时按同用户驱逐 + */ +static void destroy_sessions_by_client(const char *client_id) +{ + if (!client_id || client_id[0] == '\0') return; + for (int i = 0; i < MAX_SESSIONS; i++) { + if (s_sessions[i].active && + strcmp(s_sessions[i].session.client_id, client_id) == 0) { + s_sessions[i].active = false; + TS_LOGI(TAG, "Evicted session for client: %s", client_id); + } + } +} + esp_err_t ts_security_create_session(const char *client_id, ts_perm_level_t level, uint32_t *session_id) { @@ -181,8 +197,19 @@ esp_err_t ts_security_create_session(const char *client_id, ts_perm_level_t leve } if (slot < 0) { - TS_LOGW(TAG, "No free session slots"); - return ESP_ERR_NO_MEM; + if (client_id && client_id[0] != '\0') { + destroy_sessions_by_client(client_id); + for (int i = 0; i < MAX_SESSIONS; i++) { + if (!s_sessions[i].active) { + slot = i; + break; + } + } + } + if (slot < 0) { + TS_LOGW(TAG, "No free session slots"); + return ESP_ERR_NO_MEM; + } } // Generate session ID diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index ee7e49d..84f94b8 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -305,6 +305,11 @@ static esp_err_t login_handler(ts_http_request_t *req, void *user_data) cJSON_AddNumberToObject(data, "session_id", session_id); cJSON_AddStringToObject(data, "username", username_copy); cJSON_AddStringToObject(data, "level", level_str); +#ifdef CONFIG_TS_SECURITY_TOKEN_EXPIRE_SEC + cJSON_AddNumberToObject(data, "expires_in", CONFIG_TS_SECURITY_TOKEN_EXPIRE_SEC); +#else + cJSON_AddNumberToObject(data, "expires_in", 86400); /* 24 hours */ +#endif cJSON_AddBoolToObject(data, "password_changed", password_changed); cJSON_AddItemToObject(response, "data", data); diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index ffc3ad4..1642e6c 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -7591,6 +7591,20 @@ button.btn-gray:hover, box-shadow: 0 2px 8px var(--blue-100); } +/* 长按拖拽排序:等待中、拖拽中、插入位置指示 */ +.drag-pending { + outline: 2px solid var(--blue-500) !important; + outline-offset: 2px; + opacity: 0.8; + transition: opacity 3s linear; +} +.drag-active-source { + opacity: 0.25 !important; +} +.drag-over-before { + border-top: 2px solid var(--blue-500) !important; +} + .dw-card-header { display: flex; justify-content: space-between; diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index 21e5ee1..4f452af 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -370,6 +370,8 @@

电压保护设置

var nameEl = document.getElementById('lang-name'); if (nameEl) nameEl.textContent = cur === 'zh-CN' ? '中文' : 'EN'; i18n.translateDOM(); + /* translateDOM 会覆盖 #user-name 为「未登录」,需在之后恢复已登录状态 */ + if (typeof updateAuthUI === 'function') setTimeout(updateAuthUI, 0); }; document.head.appendChild(s); })(); @@ -379,6 +381,7 @@

电压保护设置

+ diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index 0384a89..fa06ae8 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -222,7 +222,10 @@ class TianShanAPI { // 尝试从 localStorage 恢复 const savedToken = localStorage.getItem('ts_token'); const expires = localStorage.getItem('ts_expires'); - if (savedToken && expires && Date.now() < parseInt(expires)) { + const parsed = expires ? parseInt(expires) : NaN; + const now = Date.now(); + const valid = savedToken && expires && now < parsed; + if (valid) { this.token = savedToken; this.username = localStorage.getItem('ts_username'); this.level = localStorage.getItem('ts_level'); @@ -232,7 +235,9 @@ class TianShanAPI { } // 检查是否过期 const expires = localStorage.getItem('ts_expires'); - if (expires && Date.now() >= parseInt(expires)) { + const parsed = expires ? parseInt(expires) : NaN; + const expired = expires && Date.now() >= parsed; + if (expired) { this.logout(); // 清理过期的 token return false; } diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 26e63ed..acff31e 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -169,6 +169,9 @@ document.addEventListener('DOMContentLoaded', () => { // 语言切换时重新渲染当前页,使主内容使用新语言;下一帧恢复右上角登录态(避免 translateDOM 覆盖 #user-name) window.addEventListener('languageChanged', () => { + if (typeof stopSystemPageTimers === 'function') { + stopSystemPageTimers(); + } const loader = router.getCurrentLoader(); if (loader) loader(); setTimeout(() => updateAuthUI(), 0); @@ -194,7 +197,6 @@ document.addEventListener('DOMContentLoaded', () => { function updateAuthUI() { const loginBtn = document.getElementById('login-btn'); const userName = document.getElementById('user-name'); - if (api.isLoggedIn()) { const username = api.getUsername(); const level = api.getLevel(); @@ -771,6 +773,39 @@ async function loadSystemPage() { // 启动设备状态实时监控 startDeviceStateMonitor(); + + // 初始化设备面板长按拖拽排序 + const widgetGrid = document.getElementById('data-widgets-grid'); + if (widgetGrid && typeof initLongPressDragSort === 'function') { + const { destroy: destroyWidgetSort } = initLongPressDragSort(widgetGrid, { + itemSelector: '.dw-card', + idAttribute: 'data-widget-id', + holdMs: 1500, + onReorder(oldIdx, newIdx) { + const [moved] = dataWidgets.splice(oldIdx, 1); + dataWidgets.splice(newIdx, 0, moved); + saveDataWidgets(); + renderDataWidgets(); + } + }); + window._destroyWidgetSort = destroyWidgetSort; + } + const quickGrid = document.getElementById('quick-actions-grid'); + if (quickGrid && typeof initLongPressDragSort === 'function') { + const { destroy: destroyQuickSort } = initLongPressDragSort(quickGrid, { + itemSelector: '.quick-action-card', + idAttribute: 'data-rule-id', + holdMs: 1500, + updateDOM: true, + onReorder() { + const newOrder = [...quickGrid.querySelectorAll('.quick-action-card')] + .map(el => el.getAttribute('data-rule-id')).filter(Boolean); + try { localStorage.setItem('quick_actions_order', JSON.stringify(newOrder)); } + catch (e) { /* 静默忽略 */ } + } + }); + window._destroyQuickSort = destroyQuickSort; + } } // 单次刷新(初始加载);refreshSystemPage 为别名,供时区/服务操作等调用 @@ -4017,6 +4052,26 @@ async function refreshQuickActions() { const allRules = result.data.rules; const manualRules = allRules.filter(r => r.enabled && r.manual_trigger); + // 按 localStorage 保存的顺序排列快捷操作 + let savedOrder = []; + try { + const raw = localStorage.getItem('quick_actions_order'); + if (raw) { + savedOrder = JSON.parse(raw); + if (!Array.isArray(savedOrder)) savedOrder = []; + } + } catch (e) { savedOrder = []; } + if (savedOrder.length > 0) { + manualRules.sort((a, b) => { + const ia = savedOrder.indexOf(a.id); + const ib = savedOrder.indexOf(b.id); + if (ia === -1 && ib === -1) return 0; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + } + if (manualRules.length > 0) { // 串行检查每个规则的 nohup 状态并生成卡片,避免多路 ssh.exec 并发导致后端串行/覆盖、结果错位 const cardsHtml = []; @@ -4194,12 +4249,20 @@ let _quickActionTriggerCooldownUntil = 0; let _quickActionLastTriggeredId = ''; let quickActionsTimeoutId = null; // 用于导航时取消,避免 quick-actions-grid not found -/** 供 router 在页面切换时取消快捷操作定时器 */ +/** 供 router 在页面切换时取消快捷操作定时器,并销毁拖拽排序(防止 ghost 残留) */ window.stopSystemPageTimers = function() { if (quickActionsTimeoutId) { clearTimeout(quickActionsTimeoutId); quickActionsTimeoutId = null; } + if (typeof window._destroyWidgetSort === 'function') { + window._destroyWidgetSort(); + window._destroyWidgetSort = null; + } + if (typeof window._destroyQuickSort === 'function') { + window._destroyQuickSort(); + window._destroyQuickSort = null; + } }; /** @@ -7673,7 +7736,7 @@ async function loadFilesPage() {