feat(web): 任务详情页新增探索链路播报板

按创建时间分页平铺展示探索节点,作为探索链路图的时间轴视图,
复用同一份 exploration_nodes 数据,不影响链路图。

- 后端 NodesPage/NodesByIDs/EdgesTouching:分页下推 SQL,
  支持 kind/state 过滤、payload/origin 搜索、正倒序,
  并补出当页节点的相邻边与对端节点
- 新增 GET /api/exploration/nodes(仅本任务节点,不含继承)
- 播报板:时间轴分行、按天分组、类型快筛、搜索防抖、
  自动刷新、直播位未读提示、展开看上下游与 payload
- 补 mock,NEXT_PUBLIC_MOCK=1 下可无后端预览
This commit is contained in:
Autumn-27 committed 2026-09-17 08:42:49 -04:00
1 parent 13ce2ea6e3
commit c03e008d7b
7 files changed
+1053 -37

No files matched your search

+85
View File
@@ -858,6 +858,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/exploration/findings/{id}", s.deleteFinding)
mux.HandleFunc("GET /api/exploration/intents", s.intents)
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
mux.HandleFunc("GET /api/exploration/nodes", s.explorationNodes)
mux.HandleFunc("GET /api/exploration/activity", s.activity)
mux.HandleFunc("GET /api/exploration/activity/history", s.activityHistory)
mux.HandleFunc("GET /api/exploration/main-sessions", s.mainSessions)
@@ -2576,6 +2577,90 @@ func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)})
}
// explorationNodes serves the 播报板: this task's own exploration nodes as a
// paged time series (newest first unless ?order=asc), filterable by kind/state
// and a payload substring. Inherited nodes are deliberately out of scope — the
// board reports what this task is doing right now, and paging across the source
// tasks' stores would make the cursor meaningless.
// Response also carries the edges touching the page plus the neighbour nodes
// they point at, so each row can state where it came from and what it produced.
func (s *Server) explorationNodes(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page := atoiDefault(q.Get("page"), 1)
size := atoiDefault(q.Get("size"), 20)
t := s.m.ResolveTask(q.Get("task"))
if t == nil {
writeJSON(w, 200, map[string]any{
"items": []any{}, "total": 0, "page": page, "size": size,
"edges": []any{}, "refs": map[string]any{},
})
return
}
filter := db.NodeFilter{
Kinds: csvValues(q.Get("kind")),
States: csvValues(q.Get("state")),
Query: q.Get("q"),
Asc: q.Get("order") == "asc",
}
nodes, total, err := t.Store.NodesPage(filter, page, size)
if err != nil {
writeErr(w, 500, err.Error())
return
}
ids := make([]int64, 0, len(nodes))
onPage := make(map[int64]bool, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
onPage[n.ID] = true
}
edges, err := t.Store.EdgesTouching(ids)
if err != nil {
writeErr(w, 500, err.Error())
return
}
neighbourSet := map[int64]bool{}
for _, e := range edges {
if !onPage[e.From] {
neighbourSet[e.From] = true
}
if !onPage[e.To] {
neighbourSet[e.To] = true
}
}
neighbourIDs := make([]int64, 0, len(neighbourSet))
for id := range neighbourSet {
neighbourIDs = append(neighbourIDs, id)
}
neighbours, err := t.Store.NodesByIDs(neighbourIDs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
refs := make(map[string]TaskNodeDTO, len(neighbours))
for _, n := range neighbours {
refs[i64s(n.ID)] = taskNodeDTO(n)
}
writeJSON(w, 200, map[string]any{
"items": taskNodeDTOs(nodes),
"total": total,
"page": page,
"size": size,
"edges": edgeDTOs(edges),
"refs": refs,
})
}
// csvValues splits a comma-separated query parameter, dropping empty entries.
func csvValues(s string) []string {
out := []string{}
for _, part := range strings.Split(s, ",") {
if v := strings.TrimSpace(part); v != "" {
out = append(out, v)
}
}
return out
}
// activity returns the worker execution step log (incremental via ?since=seq).
func (s *Server) activity(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task"))