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

+123
View File
@@ -785,6 +785,129 @@ func (s *ExplorationStore) Nodes(limit int) ([]*Node, error) {
return scanNodes(rows)
}
// NodeFilter narrows a NodesPage query. Empty fields include every value.
type NodeFilter struct {
Kinds []string // exploration_nodes.kind
States []string // exploration_nodes.state
Query string // case-insensitive substring over payload text / origin
Asc bool // true = oldest first (replay); default newest first (broadcast)
}
// NodesPage returns one 1-based page of this exploration's nodes plus the total
// matching count. The 播报板 reads the graph as a time series, so it pages in SQL
// rather than pulling the whole graph like Nodes does. Ordering is by id, which
// is BIGSERIAL and therefore creation order — stable when several nodes share a
// created_at second.
func (s *ExplorationStore) NodesPage(f NodeFilter, page, size int) ([]*Node, int, error) {
if page < 1 {
page = 1
}
if size <= 0 {
size = 20
}
if size > 200 {
size = 200
}
offset := (page - 1) * size
conds := []string{"exploration_id=$1"}
args := []any{s.expID}
addIn := func(column string, values []string) {
if len(values) == 0 {
return
}
marks := make([]string, 0, len(values))
for _, v := range values {
args = append(args, v)
marks = append(marks, "$"+fmt.Sprint(len(args)))
}
conds = append(conds, column+" IN ("+strings.Join(marks, ",")+")")
}
addIn("kind", f.Kinds)
addIn("state", f.States)
if q := strings.TrimSpace(f.Query); q != "" {
args = append(args, "%"+q+"%")
mark := "$" + fmt.Sprint(len(args))
conds = append(conds, "(payload::text ILIKE "+mark+" OR COALESCE(origin,'') ILIKE "+mark+")")
}
where := " WHERE " + strings.Join(conds, " AND ")
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
order := "DESC"
if f.Asc {
order = "ASC"
}
args = append(args, size, offset)
rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes`+where+
` ORDER BY id `+order+
` LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
nodes, err := scanNodes(rows)
if err != nil {
return nil, 0, err
}
return nodes, total, nil
}
// NodesByIDs loads the given nodes of this exploration in id order. Used to
// resolve the neighbours of a 播报板 page without fetching the whole graph.
func (s *ExplorationStore) NodesByIDs(ids []int64) ([]*Node, error) {
if len(ids) == 0 {
return nil, nil
}
args := make([]any, 0, len(ids)+1)
args = append(args, s.expID)
marks := make([]string, 0, len(ids))
for _, id := range ids {
args = append(args, id)
marks = append(marks, "$"+fmt.Sprint(len(args)))
}
rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes
WHERE exploration_id=$1 AND id IN (`+strings.Join(marks, ",")+`) ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// EdgesTouching returns every edge with one end among ids — the 播报板 uses it to
// spell out where a node came from and what it produced.
func (s *ExplorationStore) EdgesTouching(ids []int64) ([]Edge, error) {
if len(ids) == 0 {
return nil, nil
}
args := make([]any, 0, len(ids)+1)
args = append(args, s.expID)
marks := make([]string, 0, len(ids))
for _, id := range ids {
args = append(args, id)
marks = append(marks, "$"+fmt.Sprint(len(args)))
}
list := strings.Join(marks, ",")
rows, err := s.db.Query(`SELECT src_id, rel, dst_id FROM exploration_edges
WHERE exploration_id=$1 AND (src_id IN (`+list+`) OR dst_id IN (`+list+`))`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Edge
for rows.Next() {
var e Edge
if err := rows.Scan(&e.From, &e.Rel, &e.To); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Edge is one row from exploration_edges.
type Edge struct {
From int64