فهرست منبع

feat(web): 全run游走图按批次分组(搜索词 → 前3/同页剩余/第N页 → 视频)

后端 _video 多吐 progressive_batch_kind/page_number/item_rank_in_page(只读,数据本就落库)。
前端全run游走图在每个搜索词下再按批次分组:批次小标题 = 批次名 + 计数 + 命中才翻/命中才看/总是看门控,
批次内按页内 rank 排序;分批后视频行不再标「起点 N」(批次头已表达位置),带回的仍标「带回」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 1 ماه پیش
والد
کامیت
b598b635fe
3فایلهای تغییر یافته به همراه97 افزوده شده و 14 حذف شده
  1. 3 0
      content_agent/flow_ledger_service.py
  2. 14 0
      web2/app/globals.css
  3. 80 14
      web2/features/walk/WalkTree.tsx

+ 3 - 0
content_agent/flow_ledger_service.py

@@ -487,6 +487,9 @@ class FlowLedgerService:
             "content_discovery_id": _text(content.get("content_discovery_id")),
             "platform_content_id": platform_id,
             "search_query_id": _text(content.get("search_query_id")),
+            "progressive_batch_kind": _text(content.get("progressive_batch_kind") or _record(content.get("raw_payload")).get("progressive_batch_kind")),
+            "progressive_page_number": _number(content.get("progressive_page_number") or _record(content.get("raw_payload")).get("progressive_page_number")),
+            "progressive_item_rank_in_page": _number(content.get("progressive_item_rank_in_page") or _record(content.get("raw_payload")).get("progressive_item_rank_in_page")),
             "title": _text(content.get("description") or content.get("title"), "无标题视频"),
             "author": _text(content.get("author_display_name") or content.get("platform_author_id"), "未知作者"),
             "platform": _text(content.get("platform"), "unknown"),

+ 14 - 0
web2/app/globals.css

@@ -2801,6 +2801,20 @@ a.walk-flow-node:hover {
 }
 .wt-controls button:hover { border-color: #93c5fd; background: #eff6ff; color: #1e40af; }
 
+/* batch grouping (全run游走图:搜索词 → 批次 → 视频) */
+.wt-group { display: grid; gap: 2px; margin-top: 12px; }
+.wt-group:first-child { margin-top: 0; }
+.wt-batch { display: grid; gap: 2px; }
+.wt-batch-head {
+  display: flex; align-items: center; gap: 8px;
+  margin: 6px 0 1px; padding: 2px 0 2px 8px;
+  border-left: 3px solid #cbd5e1;
+}
+.wt-batch-label { font-size: 12.5px; font-weight: 800; color: #334155; }
+.wt-batch-count { font-size: 11.5px; font-weight: 700; color: var(--muted); }
+.wt-batch-gate { font-size: 11px; color: #94a3b8; }
+.wt-batch-videos { display: grid; gap: 2px; margin-left: 8px; }
+
 /* tree */
 .wt-tree { display: grid; gap: 2px; }
 .wt-branch { display: grid; gap: 2px; }

+ 80 - 14
web2/features/walk/WalkTree.tsx

@@ -17,6 +17,9 @@ export type VideoNode = {
   key: string;
   id: string;
   searchQueryId: string;
+  batchKind: string;
+  pageNumber: number;
+  rankInPage: number;
   video: RawRecord;
   title: string;
   author: string;
@@ -28,7 +31,8 @@ export type VideoNode = {
 };
 export type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
 
-type QueryGroup = { id: string; label: string; videos: VideoNode[] };
+type BatchGroup = { kind: string; label: string; gate: string; videos: VideoNode[] };
+type QueryGroup = { id: string; label: string; batches: BatchGroup[] };
 
 // ---------------------------------------------------------------------------
 // component
@@ -101,17 +105,28 @@ export function WalkTree({
                   <span className="wt-root-chip">搜索词</span>
                   <strong>{group.label}</strong>
                 </div>
-                {group.videos.map((node, index) => (
-                  <VideoRow
-                    node={node}
-                    depth={0}
-                    index={index}
-                    runId={runId}
-                    expanded={expanded}
-                    onToggle={toggle}
-                    onOpenDrawer={setDrawer}
-                    key={node.key}
-                  />
+                {group.batches.map((batch) => (
+                  <div className="wt-batch" key={batch.kind || "未分批"}>
+                    <div className="wt-batch-head">
+                      <span className="wt-batch-label">{batch.label}</span>
+                      <span className="wt-batch-count">{batch.videos.length} 条</span>
+                      {batch.gate ? <span className="wt-batch-gate">· {batch.gate}</span> : null}
+                    </div>
+                    <div className="wt-batch-videos">
+                      {batch.videos.map((node) => (
+                        <VideoRow
+                          node={node}
+                          depth={0}
+                          hideRootIndex
+                          runId={runId}
+                          expanded={expanded}
+                          onToggle={toggle}
+                          onOpenDrawer={setDrawer}
+                          key={node.key}
+                        />
+                      ))}
+                    </div>
+                  </div>
                 ))}
               </div>
             ))
@@ -151,6 +166,7 @@ function VideoRow({
   node,
   depth,
   index,
+  hideRootIndex,
   runId,
   expanded,
   onToggle,
@@ -159,6 +175,7 @@ function VideoRow({
   node: VideoNode;
   depth: number;
   index?: number;
+  hideRootIndex?: boolean;
   runId: string;
   expanded: Set<string>;
   onToggle: (key: string) => void;
@@ -175,7 +192,9 @@ function VideoRow({
         <Toggle open={open} disabled={!hasRoutes} onClick={() => onToggle(node.key)} />
         <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(videoDrawer(node))}>
           <span className={`wt-dot ${tone}`} />
-          <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "带回"}</span>
+          {depth === 0 && hideRootIndex ? null : (
+            <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "带回"}</span>
+          )}
           <span className="wt-title">{node.title}</span>
           <span className="wt-author">{node.author}</span>
           {node.score ? <span className="wt-score">{node.score}</span> : null}
@@ -363,6 +382,9 @@ export function buildForest(actions: RawRecord[]): Forest {
       key,
       id,
       searchQueryId: text(video.search_query_id, ""),
+      batchKind: text(video.progressive_batch_kind, ""),
+      pageNumber: Number(video.progressive_page_number) || 0,
+      rankInPage: Number(video.progressive_item_rank_in_page) || 0,
       video,
       title: cleanTitle(text(video.title, "无标题视频")),
       author: text(video.author, "未知作者"),
@@ -394,10 +416,54 @@ function groupRoots(rootVideos: VideoNode[], queryLabels: Record<string, string>
   return [...map.entries()].map(([id, videos]) => ({
     id,
     label: queryLabels[id] || (id ? id : "其它来源"),
-    videos
+    batches: groupBatches(videos)
   }));
 }
 
+function groupBatches(videos: VideoNode[]): BatchGroup[] {
+  const map = new Map<string, VideoNode[]>();
+  videos.forEach((node) => {
+    const kind = node.batchKind || "";
+    if (!map.has(kind)) map.set(kind, []);
+    map.get(kind)!.push(node);
+  });
+  return [...map.entries()]
+    .map(([kind, items]) => ({
+      kind,
+      label: batchLabel(kind),
+      gate: batchGate(kind),
+      videos: items.slice().sort((a, b) => a.rankInPage - b.rankInPage),
+      sortKey: batchSortKey(kind)
+    }))
+    .sort((a, b) => a.sortKey - b.sortKey)
+    .map(({ sortKey: _sortKey, ...rest }) => rest);
+}
+
+function batchSortKey(kind: string): number {
+  if (kind === "initial_top3") return 10;
+  if (kind === "page_remainder") return 11;
+  const page = /^page_(\d+)$/.exec(kind);
+  if (page) return Number(page[1]) * 10;
+  if (kind === "author_works") return 9000;
+  return 9999;
+}
+
+function batchLabel(kind: string): string {
+  if (kind === "initial_top3") return "前 3 条";
+  if (kind === "page_remainder") return "同页剩余";
+  const page = /^page_(\d+)$/.exec(kind);
+  if (page) return `第 ${Number(page[1])} 页`;
+  if (kind === "author_works") return "作者作品";
+  return "未分批";
+}
+
+function batchGate(kind: string): string {
+  if (kind === "initial_top3") return "总是看";
+  if (kind === "page_remainder") return "命中才看";
+  if (/^page_\d+$/.test(kind)) return "命中才翻";
+  return "";
+}
+
 function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
   const videoKeys = new Set<string>();
   const routeKeys = new Set<string>();