feat: task timestamps, dirty state, confirmation dialog
- Task 模型增加 CreatedAt/CompletedAt,DB 读取和快照同步 - 总览页最近任务显示时间,限制5条,增加"更多 →"链接 - 任务中心增加创建时间/完成时间列 - 管控台"保存并下发"按钮:修改功能或视频源后才激活 - 下发前弹出变更摘要确认对话框 - 下发后回到管控台并显示任务ID
This commit is contained in:
parent
7eca9454b0
commit
15f6f91fb6
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -14,9 +14,9 @@
|
||||
<a class="nav-link console-tab" data-panel="video" role="tab" aria-selected="false">🎥 视频视图</a>
|
||||
</li>
|
||||
<li style="margin-left:auto;display:flex;gap:10px;align-items:center;padding:0 12px">
|
||||
<span class="muted small">⬤ 上次下发成功</span>
|
||||
<form method="post" action="/ui/console" style="display:inline">
|
||||
<button class="btn" type="submit" style="padding:4px 14px;font-size:12px">保存并下发</button>
|
||||
<span id="console-status" class="muted small"></span>
|
||||
<form id="console-form" method="post" action="/ui/console" style="display:inline">
|
||||
<button id="console-save-btn" class="btn" type="button" disabled style="padding:4px 14px;font-size:12px;opacity:0.5">保存并下发</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
@ -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 += '<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>
|
||||
<style>
|
||||
|
||||
@ -48,20 +48,26 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
{{range .Tasks}}
|
||||
{{range $i, $t := .Tasks}}
|
||||
{{if lt $i 5}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{taskActionLabel .Type}}</td>
|
||||
<td><span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span></td>
|
||||
<td>{{len .DeviceIDs}}</td>
|
||||
<td><a class="mono" href="/ui/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
|
||||
<td>{{taskActionLabel $t.Type}}</td>
|
||||
<td><span class="{{taskStatusClass $t.Status}}">{{taskStatusLabel $t.Status}}</span></td>
|
||||
<td class="muted small">{{$t.CreatedAt}}</td>
|
||||
<td class="muted small">{{$t.CompletedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="muted">暂无任务记录。</td></tr>
|
||||
<tr><td colspan="5" class="muted">暂无任务记录。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<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>
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
@ -20,6 +20,8 @@
|
||||
<td>
|
||||
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
|
||||
</td>
|
||||
<td class="muted small">{{.CreatedAt}}</td>
|
||||
<td class="muted small">{{.CompletedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
|
||||
@ -949,7 +949,7 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
|
||||
t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, 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) {
|
||||
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()
|
||||
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) {
|
||||
t.Fatalf("expected audit HTML to contain %q", want)
|
||||
}
|
||||
@ -3384,7 +3384,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
|
||||
rrTasks := httptest.NewRecorder()
|
||||
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) {
|
||||
t.Fatalf("expected tasks HTML to contain %q", want)
|
||||
}
|
||||
@ -3701,7 +3701,7 @@ func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
html := renderPage(t, ui, "/ui/tasks")
|
||||
|
||||
for _, text := range []string{"执行历史", "目标设备数"} {
|
||||
for _, text := range []string{"执行历史", "设备数"} {
|
||||
if !strings.Contains(html, text) {
|
||||
t.Fatalf("expected tasks text %q in html: %s", text, html)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user