feat: task timestamps, dirty state, confirmation dialog

- Task 模型增加 CreatedAt/CompletedAt,DB 读取和快照同步
- 总览页最近任务显示时间,限制5条,增加"更多 →"链接
- 任务中心增加创建时间/完成时间列
- 管控台"保存并下发"按钮:修改功能或视频源后才激活
- 下发前弹出变更摘要确认对话框
- 下发后回到管控台并显示任务ID
This commit is contained in:
tian 2026-05-12 14:26:10 +08:00
parent 7eca9454b0
commit 15f6f91fb6
8 changed files with 162 additions and 40 deletions

View File

@ -2,6 +2,7 @@ package models
import ( import (
"sync" "sync"
"time"
) )
type TaskStatus string type TaskStatus string
@ -21,13 +22,15 @@ type DeviceTaskStatus struct {
} }
type Task struct { type Task struct {
ID string `json:"task_id"` ID string `json:"task_id"`
Type string `json:"type"` Type string `json:"type"`
DeviceIDs []string `json:"device_ids"` DeviceIDs []string `json:"device_ids"`
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
Status TaskStatus `json:"status"` Status TaskStatus `json:"status"`
Devices map[string]*DeviceTaskStatus `json:"devices"` Devices map[string]*DeviceTaskStatus `json:"devices"`
Mu sync.RWMutex `json:"-"` CreatedAt string `json:"created_at"`
CompletedAt string `json:"completed_at"`
Mu sync.RWMutex `json:"-"`
} }
func NewTask(id, t string, deviceIDs []string, payload interface{}) *Task { 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, Payload: payload,
Status: TaskPending, Status: TaskPending,
Devices: devices, Devices: devices,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
} }
} }

View File

@ -98,12 +98,14 @@ func (s *TaskService) ListTasks() []models.Task {
t.Mu.RLock() t.Mu.RLock()
snap := models.Task{ snap := models.Task{
ID: t.ID, ID: t.ID,
Type: t.Type, Type: t.Type,
DeviceIDs: append([]string(nil), t.DeviceIDs...), DeviceIDs: append([]string(nil), t.DeviceIDs...),
Payload: t.Payload, Payload: t.Payload,
Status: t.Status, Status: t.Status,
Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)), CreatedAt: t.CreatedAt,
CompletedAt: t.CompletedAt,
Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)),
} }
for did, ds := range t.Devices { for did, ds := range t.Devices {
snap.Devices[did] = &models.DeviceTaskStatus{ snap.Devices[did] = &models.DeviceTaskStatus{
@ -149,6 +151,8 @@ func (s *TaskService) LoadPersistedTasks() error {
item := items[i] item := items[i]
s.tasks[item.ID] = models.NewTask(item.ID, item.Type, append([]string(nil), item.DeviceIDs...), item.Payload) 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].Status = item.Status
s.tasks[item.ID].CreatedAt = item.CreatedAt
s.tasks[item.ID].CompletedAt = item.CompletedAt
for did, ds := range item.Devices { for did, ds := range item.Devices {
if ds == nil { if ds == nil {
continue continue

View File

@ -87,7 +87,7 @@ func (r *TasksRepo) List() ([]models.Task, error) {
return nil, nil return nil, nil
} }
rows, err := r.db.Query(` 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 FROM tasks
ORDER BY created_at DESC, task_id DESC ORDER BY created_at DESC, task_id DESC
`) `)
@ -101,7 +101,8 @@ ORDER BY created_at DESC, task_id DESC
var ( var (
id, tType, payloadJSON, status string 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 return nil, err
} }
var payload any var payload any
@ -111,11 +112,13 @@ ORDER BY created_at DESC, task_id DESC
} }
} }
task := models.Task{ task := models.Task{
ID: id, ID: id,
Type: tType, Type: tType,
Payload: payload, Payload: payload,
Status: models.TaskStatus(status), Status: models.TaskStatus(status),
Devices: map[string]*models.DeviceTaskStatus{}, CreatedAt: createdAt,
CompletedAt: finishedAt,
Devices: map[string]*models.DeviceTaskStatus{},
} }
deviceRows, err := r.db.Query(` deviceRows, err := r.db.Query(`

View File

@ -485,6 +485,12 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
} }
return a / b return a / b
}, },
"shortID": func(v string) string {
if len(v) > 8 {
return v[:8]
}
return v
},
}).ParseFS(uiFS, "ui/templates/*.html") }).ParseFS(uiFS, "ui/templates/*.html")
if err != nil { if err != nil {
return nil, err return nil, err
@ -1186,12 +1192,11 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
} }
if len(taskIDs) == 1 { 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 return
} }
if len(taskIDs) > 1 { if len(taskIDs) > 1 {
// Multiple devices: redirect to tasks list. http.Redirect(w, r, "/ui/console?task="+taskIDs[0]+"&msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建下发任务", len(taskIDs))), http.StatusFound)
http.Redirect(w, r, "/ui/tasks?msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建配置下发任务", len(taskIDs))), http.StatusFound)
return return
} }
if len(errs) > 0 { if len(errs) > 0 {

View File

@ -14,9 +14,9 @@
<a class="nav-link console-tab" data-panel="video" role="tab" aria-selected="false">🎥 视频视图</a> <a class="nav-link console-tab" data-panel="video" role="tab" aria-selected="false">🎥 视频视图</a>
</li> </li>
<li style="margin-left:auto;display:flex;gap:10px;align-items:center;padding:0 12px"> <li style="margin-left:auto;display:flex;gap:10px;align-items:center;padding:0 12px">
<span class="muted small">⬤ 上次下发成功</span> <span id="console-status" class="muted small"></span>
<form method="post" action="/ui/console" style="display:inline"> <form id="console-form" method="post" action="/ui/console" style="display:inline">
<button class="btn" type="submit" style="padding:4px 14px;font-size:12px">保存并下发</button> <button id="console-save-btn" class="btn" type="button" disabled style="padding:4px 14px;font-size:12px;opacity:0.5">保存并下发</button>
</form> </form>
</li> </li>
</ul> </ul>
@ -284,6 +284,104 @@
div.innerHTML = html; div.innerHTML = html;
document.body.appendChild(div.firstElementChild); 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 += '<div style="font-weight:600;margin:8px 0 4px">' + dname + '</div>';
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + cb.value + '</div>';
});
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) {
summary += '<div style="font-size:12px;padding-left:12px;color:var(--muted)">🎥 ' + h.value + '</div>';
});
});
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 = '<div style="background:var(--surface);border-radius:8px;padding:24px;min-width:360px;max-width:500px" onclick="event.stopPropagation()">' +
'<div style="font-size:16px;font-weight:600;margin-bottom:4px">确认下发配置</div>' +
'<div style="font-size:12px;color:var(--muted);margin-bottom:16px">以下配置将被渲染并下发到设备media-server 将热更新。</div>' +
summary +
'<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn secondary" style="font-size:12px" onclick="this.closest(\'.confirm-overlay\').remove()">取消</button>' +
'<button class="btn" style="font-size:12px" id="confirm-deploy-btn">确认下发</button>' +
'</div></div>';
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) + ' 已创建';
}
})();
})(); })();
</script> </script>
<style> <style>

View File

@ -48,20 +48,26 @@
</div> </div>
<div class="card"> <div class="card">
<h2>最近任务</h2> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2 style="margin:0">最近任务</h2>
<a class="btn ghost" href="/ui/tasks" style="font-size:11px">更多 →</a>
</div>
<div class="table-wrap" style="margin-top:10px"> <div class="table-wrap" style="margin-top:10px">
<table> <table>
<thead><tr><th>任务标识</th><th>操作</th><th>状态</th><th>节点数</th></tr></thead> <thead><tr><th>任务</th><th>操作</th><th>状态</th><th>创建时间</th><th>完成时间</th></tr></thead>
<tbody> <tbody>
{{range .Tasks}} {{range $i, $t := .Tasks}}
{{if lt $i 5}}
<tr> <tr>
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td> <td><a class="mono" href="/ui/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
<td>{{taskActionLabel .Type}}</td> <td>{{taskActionLabel $t.Type}}</td>
<td><span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span></td> <td><span class="{{taskStatusClass $t.Status}}">{{taskStatusLabel $t.Status}}</span></td>
<td>{{len .DeviceIDs}}</td> <td class="muted small">{{$t.CreatedAt}}</td>
<td class="muted small">{{$t.CompletedAt}}</td>
</tr> </tr>
{{end}}
{{else}} {{else}}
<tr><td colspan="4" class="muted">暂无任务记录。</td></tr> <tr><td colspan="5" class="muted">暂无任务记录。</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>

View File

@ -4,13 +4,13 @@
<div class="table-wrap" style="margin-top:10px"> <div class="table-wrap" style="margin-top:10px">
<table> <table>
<thead> <thead>
<tr><th>任务</th><th>类型</th><th>目标设备数</th><th>状态</th></tr> <tr><th>任务</th><th>类型</th><th>设备数</th><th>状态</th><th>创建时间</th><th>完成时间</th></tr>
</thead> </thead>
<tbody> <tbody>
{{range .Tasks}} {{range .Tasks}}
<tr> <tr>
<td> <td>
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></div> <div><a class="mono" href="/ui/tasks/{{.ID}}">{{shortID .ID}}</a></div>
<div class="muted small">{{taskActionLabel .Type}}</div> <div class="muted small">{{taskActionLabel .Type}}</div>
</td> </td>
<td> <td>
@ -20,6 +20,8 @@
<td> <td>
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span> <span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
</td> </td>
<td class="muted small">{{.CreatedAt}}</td>
<td class="muted small">{{.CompletedAt}}</td>
</tr> </tr>
{{else}} {{else}}
<tr> <tr>

View File

@ -949,7 +949,7 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String()) t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
} }
body := rrTask.Body.String() body := rrTask.Body.String()
for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} { for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Fatalf("expected task page to contain %q, got:\n%s", want, body) t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
} }
@ -3366,7 +3366,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
rrAudit := httptest.NewRecorder() rrAudit := httptest.NewRecorder()
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil)) ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
for _, want := range []string{"系统设置 / 日志审计 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发设备分配", "重启视频分析服务"} { for _, want := range []string{"系统设置 / 日志审计 / 审计记录", "审计记录", "批量配置", "批量服务", "设备数", "2 台", "下发设备分配", "重启视频分析服务"} {
if !strings.Contains(rrAudit.Body.String(), want) { if !strings.Contains(rrAudit.Body.String(), want) {
t.Fatalf("expected audit HTML to contain %q", want) t.Fatalf("expected audit HTML to contain %q", want)
} }
@ -3384,7 +3384,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
rrTasks := httptest.NewRecorder() rrTasks := httptest.NewRecorder()
ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil)) ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
for _, want := range []string{"执行历史", "目标设备数"} { for _, want := range []string{"执行历史", "设备数"} {
if !strings.Contains(rrTasks.Body.String(), want) { if !strings.Contains(rrTasks.Body.String(), want) {
t.Fatalf("expected tasks HTML to contain %q", want) t.Fatalf("expected tasks HTML to contain %q", want)
} }
@ -3701,7 +3701,7 @@ func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) {
ui := newTestUI(t) ui := newTestUI(t)
html := renderPage(t, ui, "/ui/tasks") html := renderPage(t, ui, "/ui/tasks")
for _, text := range []string{"执行历史", "目标设备数"} { for _, text := range []string{"执行历史", "设备数"} {
if !strings.Contains(html, text) { if !strings.Contains(html, text) {
t.Fatalf("expected tasks text %q in html: %s", text, html) t.Fatalf("expected tasks text %q in html: %s", text, html)
} }