From 15f6f91fb6579fb2cf62cbf395625952ccd1268e Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Tue, 12 May 2026 14:26:10 +0800 Subject: [PATCH] feat: task timestamps, dirty state, confirmation dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 模型增加 CreatedAt/CompletedAt,DB 读取和快照同步 - 总览页最近任务显示时间,限制5条,增加"更多 →"链接 - 任务中心增加创建时间/完成时间列 - 管控台"保存并下发"按钮:修改功能或视频源后才激活 - 下发前弹出变更摘要确认对话框 - 下发后回到管控台并显示任务ID --- internal/models/task.go | 18 ++-- internal/service/task.go | 16 ++-- internal/storage/tasks_repo.go | 17 ++-- internal/web/ui.go | 11 ++- internal/web/ui/templates/console.html | 104 ++++++++++++++++++++++- internal/web/ui/templates/dashboard.html | 22 +++-- internal/web/ui/templates/tasks.html | 6 +- internal/web/ui_test.go | 8 +- 8 files changed, 162 insertions(+), 40 deletions(-) diff --git a/internal/models/task.go b/internal/models/task.go index 9845ed6..b80bf19 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -2,6 +2,7 @@ package models import ( "sync" + "time" ) type TaskStatus string @@ -21,13 +22,15 @@ type DeviceTaskStatus struct { } type Task struct { - ID string `json:"task_id"` - Type string `json:"type"` - DeviceIDs []string `json:"device_ids"` - Payload interface{} `json:"payload"` - Status TaskStatus `json:"status"` - Devices map[string]*DeviceTaskStatus `json:"devices"` - Mu sync.RWMutex `json:"-"` + ID string `json:"task_id"` + Type string `json:"type"` + DeviceIDs []string `json:"device_ids"` + Payload interface{} `json:"payload"` + Status TaskStatus `json:"status"` + Devices map[string]*DeviceTaskStatus `json:"devices"` + CreatedAt string `json:"created_at"` + CompletedAt string `json:"completed_at"` + Mu sync.RWMutex `json:"-"` } func NewTask(id, t string, deviceIDs []string, payload interface{}) *Task { @@ -45,5 +48,6 @@ func NewTask(id, t string, deviceIDs []string, payload interface{}) *Task { Payload: payload, Status: TaskPending, Devices: devices, + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), } } diff --git a/internal/service/task.go b/internal/service/task.go index a6e1b74..38553a0 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -98,12 +98,14 @@ func (s *TaskService) ListTasks() []models.Task { t.Mu.RLock() snap := models.Task{ - ID: t.ID, - Type: t.Type, - DeviceIDs: append([]string(nil), t.DeviceIDs...), - Payload: t.Payload, - Status: t.Status, - Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)), + ID: t.ID, + Type: t.Type, + DeviceIDs: append([]string(nil), t.DeviceIDs...), + Payload: t.Payload, + Status: t.Status, + CreatedAt: t.CreatedAt, + CompletedAt: t.CompletedAt, + Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)), } for did, ds := range t.Devices { snap.Devices[did] = &models.DeviceTaskStatus{ @@ -149,6 +151,8 @@ func (s *TaskService) LoadPersistedTasks() error { item := items[i] s.tasks[item.ID] = models.NewTask(item.ID, item.Type, append([]string(nil), item.DeviceIDs...), item.Payload) s.tasks[item.ID].Status = item.Status + s.tasks[item.ID].CreatedAt = item.CreatedAt + s.tasks[item.ID].CompletedAt = item.CompletedAt for did, ds := range item.Devices { if ds == nil { continue diff --git a/internal/storage/tasks_repo.go b/internal/storage/tasks_repo.go index 16a4ce5..49da5a2 100644 --- a/internal/storage/tasks_repo.go +++ b/internal/storage/tasks_repo.go @@ -87,7 +87,7 @@ func (r *TasksRepo) List() ([]models.Task, error) { return nil, nil } rows, err := r.db.Query(` -SELECT task_id, type, payload_json, status +SELECT task_id, type, payload_json, status, COALESCE(created_at,''), COALESCE(finished_at,'') FROM tasks ORDER BY created_at DESC, task_id DESC `) @@ -101,7 +101,8 @@ ORDER BY created_at DESC, task_id DESC var ( id, tType, payloadJSON, status string ) - if err := rows.Scan(&id, &tType, &payloadJSON, &status); err != nil { + var createdAt, finishedAt string + if err := rows.Scan(&id, &tType, &payloadJSON, &status, &createdAt, &finishedAt); err != nil { return nil, err } var payload any @@ -111,11 +112,13 @@ ORDER BY created_at DESC, task_id DESC } } task := models.Task{ - ID: id, - Type: tType, - Payload: payload, - Status: models.TaskStatus(status), - Devices: map[string]*models.DeviceTaskStatus{}, + ID: id, + Type: tType, + Payload: payload, + Status: models.TaskStatus(status), + CreatedAt: createdAt, + CompletedAt: finishedAt, + Devices: map[string]*models.DeviceTaskStatus{}, } deviceRows, err := r.db.Query(` diff --git a/internal/web/ui.go b/internal/web/ui.go index 44cabb2..ba78cd5 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -485,6 +485,12 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic } return a / b }, + "shortID": func(v string) string { + if len(v) > 8 { + return v[:8] + } + return v + }, }).ParseFS(uiFS, "ui/templates/*.html") if err != nil { return nil, err @@ -1186,12 +1192,11 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { } if len(taskIDs) == 1 { - http.Redirect(w, r, "/ui/tasks/"+taskIDs[0], http.StatusFound) + http.Redirect(w, r, "/ui/console?task="+taskIDs[0], http.StatusFound) return } if len(taskIDs) > 1 { - // Multiple devices: redirect to tasks list. - http.Redirect(w, r, "/ui/tasks?msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建配置下发任务", len(taskIDs))), http.StatusFound) + http.Redirect(w, r, "/ui/console?task="+taskIDs[0]+"&msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建下发任务", len(taskIDs))), http.StatusFound) return } if len(errs) > 0 { diff --git a/internal/web/ui/templates/console.html b/internal/web/ui/templates/console.html index ac8516b..73e7c65 100644 --- a/internal/web/ui/templates/console.html +++ b/internal/web/ui/templates/console.html @@ -14,9 +14,9 @@ 🎥 视频视图
  • - ⬤ 上次下发成功 -
    - + + +
  • @@ -284,6 +284,104 @@ div.innerHTML = html; document.body.appendChild(div.firstElementChild); } + + // ---- dirty state tracking ---- + var initialFeatures = {}; + var initialSources = {}; + document.querySelectorAll('.console-device-row').forEach(function(row) { + var did = row.getAttribute('data-device-id'); + initialFeatures[did] = []; + initialSources[did] = []; + row.querySelectorAll('input[type=checkbox]').forEach(function(cb) { + if (cb.checked) initialFeatures[did].push(cb.value); + }); + row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { + initialSources[did].push(h.value); + }); + }); + + function isDirty() { + var rows = document.querySelectorAll('.console-device-row'); + for (var i = 0; i < rows.length; i++) { + var did = rows[i].getAttribute('data-device-id'); + var feats = []; + rows[i].querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { feats.push(cb.value); }); + if (feats.sort().join(',') !== (initialFeatures[did]||[]).sort().join(',')) return true; + var srcs = []; + rows[i].querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { srcs.push(h.value); }); + if (srcs.sort().join(',') !== (initialSources[did]||[]).sort().join(',')) return true; + } + return false; + } + + function updateSaveButton() { + var btn = document.getElementById('console-save-btn'); + if (isDirty()) { + btn.disabled = false; + btn.style.opacity = '1'; + btn.style.cursor = 'pointer'; + } else { + btn.disabled = true; + btn.style.opacity = '0.5'; + btn.style.cursor = 'default'; + } + } + + // Listen for changes + document.querySelectorAll('.console-device-row input[type=checkbox]').forEach(function(cb) { + cb.addEventListener('change', updateSaveButton); + }); + + // Override addSourceToDeviceForm to also trigger dirty check + var _origAddSource = addSourceToDeviceForm; + addSourceToDeviceForm = function(did, src) { + _origAddSource(did, src); + updateSaveButton(); + }; + + // Confirmation dialog + document.getElementById('console-save-btn').addEventListener('click', function() { + if (!isDirty()) return; + var summary = ''; + document.querySelectorAll('.console-device-row').forEach(function(row) { + var did = row.getAttribute('data-device-id'); + var nameEl = row.querySelector('.console-device-name'); + var dname = nameEl ? nameEl.textContent : did; + summary += '
    ' + dname + '
    '; + row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { + summary += '
    ☑ ' + cb.value + '
    '; + }); + row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { + summary += '
    🎥 ' + h.value + '
    '; + }); + }); + if (!summary) { alert('未选择任何检测功能或视频源'); return; } + var overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center'; + overlay.innerHTML = '
    ' + + '
    确认下发配置
    ' + + '
    以下配置将被渲染并下发到设备,media-server 将热更新。
    ' + + summary + + '
    ' + + '' + + '' + + '
    '; + overlay.addEventListener('click', function() { overlay.remove(); }); + document.body.appendChild(overlay); + document.getElementById('confirm-deploy-btn').addEventListener('click', function() { + overlay.remove(); + document.getElementById('console-form').submit(); + }); + }); + + // Show saved task info from URL param + (function() { + var m = location.search.match(/[?&]task=([^&]+)/); + if (m) { + document.getElementById('console-status').innerHTML = '✅ 任务 ' + m[1].substring(0,8) + ' 已创建'; + } + })(); })();