Procházet zdrojové kódy

Redesign walk page as collapsible tree

Replace the always-expanded story-block layout with a collapsible indented
tree: each starting video / route is one scannable row with status dot, count
badges, and walked / not-walked chips; brought-back videos collapse by default
and expand on demand. Full scores and reasons move into the detail drawer,
removing the triple-repeated reason text. Adds an overview bar and
collapse / routes / all expand controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee před 3 týdny
rodič
revize
01f35edf0f
2 změnil soubory, kde provedl 576 přidání a 467 odebrání
  1. 161 0
      web2/app/globals.css
  2. 415 467
      web2/features/QueryWalkPage.tsx

+ 161 - 0
web2/app/globals.css

@@ -2757,3 +2757,164 @@ a.walk-flow-node:hover {
     animation-iteration-count: 1 !important;
   }
 }
+
+/* ===================== Walk tree (游走路线图) ===================== */
+.wt-page { margin: 12px 20px 28px; }
+
+.wt-overview {
+  position: sticky;
+  top: 0;
+  z-index: 5;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 18px;
+  align-items: center;
+  padding: 12px 14px;
+  margin-bottom: 12px;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  background: #f8fbff;
+}
+.wt-overview-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
+.wt-root-chip {
+  padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 850;
+  color: #1e40af; background: #dbeafe;
+}
+.wt-overview-head strong { color: #0f172a; font-size: 15px; font-weight: 850; }
+.wt-overview-head small { color: var(--muted); font-size: 12px; }
+
+.wt-overview-stats { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
+.wt-stat {
+  display: inline-flex; gap: 5px; align-items: baseline;
+  padding: 4px 9px; border: 1px solid #e2e8f0; border-radius: 999px;
+  background: #fff; color: #475569; font-size: 11.5px; font-weight: 700;
+}
+.wt-stat b { color: #0f172a; font-size: 13px; font-weight: 850; }
+.wt-stat.go b { color: #047857; }
+.wt-stat.stop b { color: #b45309; }
+
+.wt-controls { display: flex; gap: 6px; align-items: center; }
+.wt-controls-label { color: var(--muted); font-size: 11.5px; font-weight: 700; }
+.wt-controls button {
+  padding: 4px 10px; border: 1px solid #cbd5e1; border-radius: 999px;
+  background: #fff; color: #334155; font-size: 11.5px; font-weight: 700; cursor: pointer;
+}
+.wt-controls button:hover { border-color: #93c5fd; background: #eff6ff; color: #1e40af; }
+
+/* tree */
+.wt-tree { display: grid; gap: 2px; }
+.wt-branch { display: grid; gap: 2px; }
+.wt-children {
+  margin-left: 16px;
+  padding-left: 6px;
+  border-left: 1px solid #e2e8f0;
+  display: grid;
+  gap: 2px;
+}
+
+.wt-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  min-height: 38px;
+  padding: 5px 10px 5px 4px;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  background: #fff;
+}
+.wt-row:hover { background: #f8fafc; border-color: #e2e8f0; }
+.wt-row.video { border-left: 3px solid #cbd5e1; }
+.wt-row.video.good { border-left-color: #16a34a; }
+.wt-row.video.warn { border-left-color: #f59e0b; }
+.wt-row.video.bad { border-left-color: #ef4444; }
+.wt-row.video.tech { border-left-color: #64748b; }
+.wt-row.route.go { background: #f6fef9; }
+.wt-row.route.stop { background: #fffdf7; }
+
+.wt-toggle {
+  flex: 0 0 auto;
+  width: 22px; height: 22px;
+  display: inline-flex; align-items: center; justify-content: center;
+  border: none; border-radius: 4px; background: transparent; color: #64748b; cursor: pointer;
+}
+.wt-toggle:hover { background: #e2e8f0; color: #0f172a; }
+.wt-toggle.empty { cursor: default; }
+
+.wt-row-main {
+  flex: 1 1 auto;
+  min-width: 0;
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  padding: 0;
+  border: none;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+  font: inherit;
+}
+
+.wt-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 999px; background: #cbd5e1; }
+.wt-dot.good { background: #16a34a; }
+.wt-dot.warn { background: #f59e0b; }
+.wt-dot.bad { background: #ef4444; }
+.wt-dot.tech { background: #64748b; }
+
+.wt-kicker {
+  flex: 0 0 auto;
+  padding: 1px 6px; border-radius: 4px;
+  color: #475569; background: #eef2f7; font-size: 10.5px; font-weight: 800;
+}
+.wt-edge-icon { flex: 0 0 auto; display: inline-flex; color: #475569; }
+.wt-row.route.tag .wt-edge-icon { color: #047857; }
+.wt-row.route.author .wt-edge-icon { color: #7c3aed; }
+.wt-row.route.page .wt-edge-icon { color: #2563eb; }
+
+.wt-title {
+  flex: 0 1 auto;
+  min-width: 0;
+  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
+  color: #111827; font-size: 13px; font-weight: 750;
+}
+.wt-title.route { flex: 0 0 auto; max-width: 320px; }
+.wt-newquery { flex: 0 0 auto; color: #1e40af; font-size: 12px; font-weight: 750; }
+.wt-author { flex: 0 0 auto; color: #64748b; font-size: 12px; max-width: 130px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.wt-score { flex: 0 0 auto; color: #334155; font-size: 12px; font-weight: 800; }
+
+.wt-status {
+  flex: 0 0 auto;
+  padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 800;
+  color: #475569; background: #eef2f7;
+}
+.wt-status.good { color: #047857; background: #dcfce7; }
+.wt-status.warn { color: #92400e; background: #fef3c7; }
+.wt-status.bad { color: #b91c1c; background: #fee2e2; }
+.wt-status.tech { color: #334155; background: #e2e8f0; }
+
+.wt-walk { flex: 0 0 auto; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; }
+.wt-walk.go { color: #047857; background: #dcfce7; }
+.wt-walk.stop { color: #92400e; background: #fef3c7; }
+.wt-reason { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #94782e; font-size: 11.5px; }
+
+.wt-row-right { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
+.wt-badge {
+  padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 800;
+  color: #475569; background: #f1f5f9; border: 1px solid #e2e8f0;
+}
+.wt-badge.go { color: #047857; background: #f0fdf4; border-color: #bbf7d0; }
+.wt-badge.stop { color: #b45309; background: #fffbeb; border-color: #fde68a; }
+.wt-badge.muted { color: #94a3b8; background: #f8fafc; }
+
+.wt-links { display: inline-flex; gap: 6px; }
+.wt-link {
+  display: inline-flex; gap: 3px; align-items: center;
+  padding: 3px 8px; border: 1px solid #cbd5e1; border-radius: 4px;
+  color: #1e40af; background: #fff; font-size: 11px; font-weight: 700;
+}
+.wt-link:hover { border-color: #93c5fd; background: #eff6ff; }
+
+@media (max-width: 720px) {
+  .wt-page { margin: 10px 10px 24px; }
+  .wt-author, .wt-reason { display: none; }
+  .wt-overview-stats { margin-left: 0; }
+}

+ 415 - 467
web2/features/QueryWalkPage.tsx

@@ -1,40 +1,41 @@
 "use client";
 
 import Link from "next/link";
-import { ExternalLink, Hash, Info, ListVideo, Search, UserSearch } from "lucide-react";
+import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, Search, UserSearch } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { AppShell } from "@/components/AppShell";
 import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
-import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
 import { getFlowLedgerWalk } from "@/lib/api/client";
 import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
-import { videoFromApi } from "@/lib/flow-ledger/build";
-import { methodLabel } from "@/lib/flow-ledger/business";
-import { asRecord, text } from "@/lib/flow-ledger/format";
-
-type WalkGroup = {
+import { methodLabel, type BusinessTone } from "@/lib/flow-ledger/business";
+import { asArray, asRecord, text } from "@/lib/flow-ledger/format";
+
+// ---------------------------------------------------------------------------
+// tree model
+// ---------------------------------------------------------------------------
+type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
+type VideoNode = {
+  key: string;
   id: string;
-  kind: "video" | "query";
-  video?: RawRecord;
+  video: RawRecord;
   title: string;
   author: string;
   decisionLabel: string;
-  terminalLabel: string;
-  actions: RawRecord[];
+  decisionAction: string;
+  score: string;
+  tags: string[];
+  routes: RouteNode[];
 };
 
-type WalkTree = {
-  roots: WalkGroup[];
-  childrenByActionId: Map<string, WalkGroup[]>;
-  totalGroups: number;
-};
+type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
 
 export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
   const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
+  const [expanded, setExpanded] = useState<Set<string>>(new Set());
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -54,556 +55,503 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
   }, [load]);
 
   const query = asRecord(data?.query);
-  const actions = useMemo(() => [...(data?.actions || [])].sort((a, b) => actionDepth(a) - actionDepth(b)), [data?.actions]);
-  const tree = useMemo(() => buildWalkTree(actions), [actions]);
+  const actions = useMemo(() => (data?.actions || []).filter(isRecord), [data?.actions]);
+  const forest = useMemo(() => buildForest(actions), [actions]);
+  const keyBuckets = useMemo(() => collectKeys(forest), [forest]);
+
+  // default: show every video with its routes (the flow), brought-back videos collapsed.
+  useEffect(() => {
+    setExpanded(new Set(keyBuckets.videoKeys));
+  }, [keyBuckets]);
+
+  const toggle = useCallback((key: string) => {
+    setExpanded((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) next.delete(key);
+      else next.add(key);
+      return next;
+    });
+  }, []);
+
+  const setDepth = useCallback(
+    (mode: "collapse" | "routes" | "all") => {
+      if (mode === "collapse") setExpanded(new Set());
+      else if (mode === "routes") setExpanded(new Set(keyBuckets.videoKeys));
+      else setExpanded(new Set(keyBuckets.allKeys));
+    },
+    [keyBuckets]
+  );
+
+  const overview = useMemo(() => buildOverview(data?.summary, forest), [data?.summary, forest]);
+  const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0;
 
   return (
-    <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
+    <AppShell
+      title="游走路线图"
+      subtitle={query.text ? `搜索词:${text(query.text)}` : "看系统从这个搜索词向外找了哪些内容"}
+      runId={runId}
+      showBack
+      onRefresh={load}
+    >
       {loading ? <LoadingState /> : null}
       {error ? <ErrorState message={error} /> : null}
-      {!loading && !error && !actions.length ? <EmptyState label="这个搜索词本轮没有继续扩展" /> : null}
-      {data ? (
-        <>
-          <details className="declarations" open>
-            <summary>
-              <span className="kw">游走</span> <b>{text(query.text, "搜索词待确认")}</b>
-              <span className="decl-purpose">{methodLabel(text(query.method, ""))}</span>
-              <span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</span>
-            </summary>
-            <div className="decl-body">
-              <Summary label="怎么看" value="先看每次游走用了什么标签、作者或翻页;再看它带回的视频是否入池、待复看或淘汰。带回的视频如果继续游走,会在视频卡下面继续缩进展开。" />
-              <Summary label="本轮概况" value={`已游走 ${Number(data.summary.success_count || 0)} 条路线 · 未游走 ${Number(data.summary.skipped_count || 0)} 条路线 · 新增搜索词 ${Number(data.summary.extension_query_count || 0)} 个`} />
-            </div>
-          </details>
+      {!loading && !error && !hasTree ? <EmptyState label="这个搜索词本轮没有继续向外扩展" /> : null}
 
-          <section className="walk-ledger-board">
-            <div className="walk-ledger-root">
-              <span className="chip dark">当前搜索词</span>
+      {data && hasTree ? (
+        <section className="wt-page">
+          <header className="wt-overview">
+            <div className="wt-overview-head">
+              <span className="wt-root-chip">搜索词</span>
               <strong>{text(query.text, "搜索词待确认")}</strong>
               <small>{methodLabel(text(query.method, ""))}</small>
             </div>
-            <div className="walk-ledger-groups">
-              {tree.roots.map((group, index) => (
-                <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} onOpenScore={setDrawer} key={group.id} />
+            <div className="wt-overview-stats">
+              {overview.map((stat) => (
+                <span className={`wt-stat ${stat.tone || ""}`} key={stat.label}>
+                  <b>{stat.value}</b>
+                  {stat.label}
+                </span>
               ))}
             </div>
-          </section>
+            <div className="wt-controls">
+              <span className="wt-controls-label">展开</span>
+              <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
+              <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
+              <button type="button" onClick={() => setDepth("all")}>全部展开</button>
+            </div>
+          </header>
+
+          <div className="wt-tree" role="tree">
+            {forest.rootVideos.map((node, index) => (
+              <VideoRow
+                node={node}
+                depth={0}
+                index={index}
+                runId={runId}
+                expanded={expanded}
+                onToggle={toggle}
+                onOpenDrawer={setDrawer}
+                key={node.key}
+              />
+            ))}
+            {forest.queryRoutes.map((node) => (
+              <RouteRow
+                node={node}
+                depth={0}
+                runId={runId}
+                expanded={expanded}
+                onToggle={toggle}
+                onOpenDrawer={setDrawer}
+                key={node.key}
+              />
+            ))}
+          </div>
           <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
-        </>
+        </section>
       ) : null}
     </AppShell>
   );
 }
 
-function Summary({ label, value }: { label: string; value: string }) {
-  return (
-    <div className="decl-section">
-      <div className="decl-label">{label}</div>
-      <div className="decl-row">{value}</div>
-    </div>
-  );
-}
-
-function WalkGroupCard({
-  group,
+// ---------------------------------------------------------------------------
+// rows
+// ---------------------------------------------------------------------------
+function VideoRow({
+  node,
+  depth,
   index,
   runId,
-  tree,
-  level,
-  onOpenScore,
-  seen = new Set<string>()
+  expanded,
+  onToggle,
+  onOpenDrawer
 }: {
-  group: WalkGroup;
-  index: number;
+  node: VideoNode;
+  depth: number;
+  index?: number;
   runId: string;
-  tree: WalkTree;
-  level: number;
-  onOpenScore: (drawer: BusinessDrawerState) => void;
-  seen?: Set<string>;
+  expanded: Set<string>;
+  onToggle: (key: string) => void;
+  onOpenDrawer: (drawer: BusinessDrawerState) => void;
 }) {
-  const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
-  const titleParts = splitTitleAndTags(group.title);
-  const score = group.video ? scoreSummary(asRecord(group.video.decision).score_items) : "";
-  const nextSeen = new Set(seen).add(group.id);
+  const open = expanded.has(node.key);
+  const hasRoutes = node.routes.length > 0;
+  const walked = node.routes.filter((r) => isWalked(r.action)).length;
+  const tone = decisionTone(node.decisionLabel);
 
   return (
-    <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
-      <header className="walk-story-head">
-        <span className="walk-source-kicker">{sourceKicker(group, index, level)}</span>
-        <div className="walk-story-title">
-          <h2>{titleParts.title}</h2>
-          {titleParts.tags.length ? (
-            <div className="walk-source-tags">
-              {titleParts.tags.map((tag) => <span key={tag}>{tag}</span>)}
-            </div>
-          ) : null}
-        </div>
-        <VideoLinks video={group.video} runId={runId} />
-      </header>
-
-      <div className="walk-source-facts">
-        {group.kind === "video" ? <Fact label="作者" value={group.author} /> : <Fact label="动作" value="搜索词翻页" tone="info" />}
-        {group.kind === "video" ? <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} /> : <Fact label="起点" value={querySourceName(group.title)} />}
-        {score ? <Fact label="评分" value={score} /> : null}
-        {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
+    <div className="wt-branch" role="treeitem" aria-expanded={hasRoutes ? open : undefined}>
+      <div className={`wt-row video ${tone}`}>
+        <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>
+          <span className="wt-title">{node.title}</span>
+          <span className="wt-author">{node.author}</span>
+          {node.score ? <span className="wt-score">{node.score}</span> : null}
+          <span className={`wt-status ${tone}`}>{node.decisionLabel}</span>
+        </button>
+        <span className="wt-row-right">
+          {hasRoutes ? (
+            <span className={`wt-badge ${walked ? "go" : "stop"}`}>
+              {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`}
+            </span>
+          ) : (
+            <span className="wt-badge muted">无路线</span>
+          )}
+          <RowLinks video={node.video} runId={runId} />
+        </span>
       </div>
-
-      <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
-        <div className="walk-story-routes-label">{group.kind === "video" ? "从这条视频继续往外找" : "从这个搜索词翻下一页"}</div>
-        {routeActions.length ? routeActions.map((action, actionIndex) => (
-          <WalkRouteCard
-            action={action}
-            runId={runId}
-            sourceId={group.id}
-            tree={tree}
-            level={level}
-            onOpenScore={onOpenScore}
-            seen={nextSeen}
-            key={`${text(action.id, "walk")}-${actionIndex}`}
-          />
-        )) : <div className="walk-no-route">{group.kind === "video" ? "这条视频没有继续产生标签、作者或翻页路线。" : "这个搜索词没有继续翻页或产生新路线。"}</div>}
-      </section>
-    </article>
-  );
-}
-
-function Fact({ label, value, tone = "plain" }: { label: string; value: string; tone?: string }) {
-  return (
-    <div className={`walk-fact ${tone}`}>
-      <span>{label}</span>
-      <strong>{value || "待确认"}</strong>
+      {open && hasRoutes ? (
+        <div className="wt-children">
+          {node.routes.map((route) => (
+            <RouteRow
+              node={route}
+              depth={depth + 1}
+              runId={runId}
+              expanded={expanded}
+              onToggle={onToggle}
+              onOpenDrawer={onOpenDrawer}
+              key={route.key}
+            />
+          ))}
+        </div>
+      ) : null}
     </div>
   );
 }
 
-function WalkRouteCard({
-  action,
+function RouteRow({
+  node,
+  depth,
   runId,
-  sourceId,
-  tree,
-  level,
-  onOpenScore,
-  seen
+  expanded,
+  onToggle,
+  onOpenDrawer
 }: {
-  action: RawRecord;
+  node: RouteNode;
+  depth: number;
   runId: string;
-  sourceId: string;
-  tree: WalkTree;
-  level: number;
-  onOpenScore: (drawer: BusinessDrawerState) => void;
-  seen: Set<string>;
+  expanded: Set<string>;
+  onToggle: (key: string) => void;
+  onOpenDrawer: (drawer: BusinessDrawerState) => void;
 }) {
-  const edgeId = text(action.edge_id, "");
-  const targetQuery = asRecord(action.target_query);
-  const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
-  const status = text(action.status, "");
-  const isSuccess = status === "success";
-  const routeName = routeLabel(edgeId);
-  const gate = gateText(action, isSuccess);
-  const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
-  const childByVideoId = new Map(childGroups.map((group) => [group.id, group]));
+  const open = expanded.has(node.key);
+  const walked = isWalked(node.action);
+  const hasVideos = node.videos.length > 0;
+  const edge = text(node.action.edge_id, "");
+  const targetQuery = asRecord(node.action.target_query);
 
   return (
-    <article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
-      <div className="walk-tree-route-dot" />
-      <div className="walk-tree-route-body">
-        <div className="walk-route-head">
-          <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
-          <div>
-            <strong>{routeName}</strong>
-            <p>{routeSummary(action, targetQuery, targetVideos, isSuccess)}</p>
-          </div>
-          <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已游走" : "未游走"}</span>
-        </div>
-
-        <div className="walk-route-bottom">
-          <span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
-          <button className="walk-jump-button" type="button" onClick={() => onOpenScore(scoreDrawer(action, routeName, isSuccess))}>
-            <Info size={13} />
-            看筛选口径
-          </button>
+    <div className="wt-branch" role="treeitem" aria-expanded={hasVideos ? open : undefined}>
+      <div className={`wt-row route ${edgeTone(edge)} ${walked ? "go" : "stop"}`}>
+        <Toggle open={open} disabled={!hasVideos} onClick={() => onToggle(node.key)} />
+        <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(routeDrawer(node))}>
+          <span className="wt-edge-icon">{edgeIcon(edge)}</span>
+          <span className="wt-title route">{routeObject(node.action)}</span>
+          {targetQuery.text && walked ? <span className="wt-newquery">→《{text(targetQuery.text)}》</span> : null}
+          <span className={`wt-walk ${walked ? "go" : "stop"}`}>{walked ? "已游走" : "未游走"}</span>
+          {!walked ? <span className="wt-reason">{shortReason(node.action)}</span> : null}
+        </button>
+        <span className="wt-row-right">
+          {hasVideos ? <span className="wt-badge go">带回 {node.videos.length}</span> : null}
           {targetQuery.id ? (
-            <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
-              查看带回视频
+            <Link
+              className="wt-link"
+              href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id))}/videos`}
+              onClick={(e) => e.stopPropagation()}
+            >
+              带回视频
             </Link>
           ) : null}
-          <a className="walk-jump-button" href={`#source-${encodeURIComponent(sourceId)}`}>回到起点</a>
-        </div>
-
-        {targetVideos.length ? (
-          <div className="walk-result-videos">
-            <div className="walk-result-title">
-              <ListVideo size={15} />
-              <span>带回视频 {targetVideos.length} 条</span>
-            </div>
-            <div className="walk-result-video-grid">
-              {targetVideos.map((video, videoIndex) => (
-                <WalkResultVideoCard
-                  video={video}
-                  runId={runId}
-                  tree={tree}
-                  level={level}
-                  seen={seen}
-                  child={childByVideoId.get(text(video.id, ""))}
-                  onOpenScore={onOpenScore}
-                  key={text(video.id, `${text(video.title, "video")}-${videoIndex}`)}
-                />
-              ))}
-            </div>
-          </div>
-        ) : !isSuccess ? (
-          <div className="walk-stop-note">{stoppedSentence(action)}</div>
-        ) : null}
-      </div>
-    </article>
-  );
-}
-
-function WalkResultVideoCard({
-  video,
-  runId,
-  tree,
-  level,
-  seen,
-  child,
-  onOpenScore
-}: {
-  video: RawRecord;
-  runId: string;
-  tree: WalkTree;
-  level: number;
-  seen: Set<string>;
-  child?: WalkGroup;
-  onOpenScore: (drawer: BusinessDrawerState) => void;
-}) {
-  const decision = asRecord(video.decision);
-  const label = text(decision.label, "未判断");
-  const scores = videoScoreItems(video);
-  const titleParts = splitTitleAndTags(text(video.title, "无标题视频"));
-  const detail = text(decision.action, "") === "TECHNICAL_RETRY_REQUIRED"
-    ? videoFromApi(video).technicalRetryDetail
-    : null;
-
-  return (
-    <article className={`walk-result-video-card ${decisionTone(label)} ${child ? "has-child" : ""}`}>
-      <div className="walk-result-video-top">
-        <span className={`walk-result-status ${decisionTone(label)}`}>{label}</span>
-        {detail ? <TechnicalRetryDetails detail={detail} compact /> : null}
-        {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</span>}
+        </span>
       </div>
-      <Link className="walk-result-video-title" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
-        {titleParts.title}
-      </Link>
-      {titleParts.tags.length ? (
-        <div className="walk-source-tags compact-tags">
-          {titleParts.tags.slice(0, 4).map((tag) => <span key={tag}>{tag}</span>)}
-        </div>
-      ) : null}
-      <div className="walk-result-video-meta">{text(video.author, "未知作者")}</div>
-      {scores.length ? (
-        <div className="walk-score-chips">
-          {scores.slice(0, 3).map((item) => (
-            <span key={item.label}>{shortScoreLabel(item.label)} {item.score}</span>
+      {open && hasVideos ? (
+        <div className="wt-children">
+          {node.videos.map((video) => (
+            <VideoRow
+              node={video}
+              depth={depth + 1}
+              runId={runId}
+              expanded={expanded}
+              onToggle={onToggle}
+              onOpenDrawer={onOpenDrawer}
+              key={video.key}
+            />
           ))}
         </div>
       ) : null}
-      <VideoLinks video={video} runId={runId} compact />
-
-      {child ? (
-        <div className="walk-card-children">
-          <WalkGroupCard
-            group={child}
-            index={0}
-            runId={runId}
-            tree={tree}
-            level={level + 1}
-            seen={seen}
-            onOpenScore={onOpenScore}
-          />
-        </div>
-      ) : null}
-    </article>
+    </div>
   );
 }
 
-function buildWalkTree(actions: RawRecord[]): WalkTree {
-  const groups = buildWalkGroups(actions);
-  const byId = new Map(groups.map((group) => [group.id, group]));
-  const childrenByActionId = new Map<string, WalkGroup[]>();
-  const childIds = new Set<string>();
-
-  groups.forEach((group) => {
-    group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, ""))).forEach((action) => {
-      const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
-      const children = targetVideos
-        .map((video) => byId.get(text(video.id, "")))
-        .filter((child): child is WalkGroup => child !== undefined && child.id !== group.id);
-      if (!children.length) return;
-      const uniqueChildren = [...new Map(children.map((child) => [child.id, child])).values()];
-      childrenByActionId.set(actionKey(action), uniqueChildren);
-      uniqueChildren.forEach((child) => childIds.add(child.id));
-    });
-  });
-
-  const roots = groups.filter((group) => !childIds.has(group.id));
-  return {
-    roots: roots.length ? roots : groups,
-    childrenByActionId,
-    totalGroups: groups.length
-  };
-}
-
-function buildWalkGroups(actions: RawRecord[]): WalkGroup[] {
-  const groups = new Map<string, WalkGroup>();
-  actions.forEach((action, index) => {
-    const video = asRecord(action.source_video);
-    const terminal = isTerminalEdge(text(action.edge_id, ""));
-    const id = text(video.id) || `search-${text(action.from_label, "root")}-${index}`;
-    if (!groups.has(id)) {
-      const decision = asRecord(video.decision);
-      groups.set(id, {
-        id,
-        kind: video.id ? "video" : "query",
-        video: video.id ? video : undefined,
-        title: video.id ? text(video.title, "无标题视频") : queryGroupTitle(action),
-        author: video.id ? text(video.author, "未知作者") : "搜索词翻页",
-        decisionLabel: text(decision.label, "未判断"),
-        terminalLabel: terminal ? terminalResultText(action) : "",
-        actions: []
-      });
-    }
-    const group = groups.get(id)!;
-    if (terminal && !group.terminalLabel) group.terminalLabel = terminalResultText(action);
-    group.actions.push(action);
-  });
-  return [...groups.values()];
-}
-
-function actionKey(action: RawRecord): string {
-  return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
-}
-
-function queryGroupTitle(action: RawRecord): string {
-  const from = cleanBusinessText(text(action.from_label, ""));
-  if (from) return from.replace(/^从搜索词/, "搜索词").replace(/继续$/, "");
-  return "当前搜索词";
-}
-
-function sourceKicker(group: WalkGroup, index: number, level: number): string {
-  if (level) return `第 ${level + 1} 层起点`;
-  return group.kind === "video" ? `起点视频 ${index + 1}` : "搜索词翻页";
-}
-
-function querySourceName(value: string): string {
-  return value.replace(/^搜索词《/, "").replace(/》$/, "") || "搜索词待确认";
+function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) {
+  if (disabled) return <span className="wt-toggle empty" />;
+  return (
+    <button type="button" className="wt-toggle" aria-label={open ? "收起" : "展开"} onClick={onClick}>
+      {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
+    </button>
+  );
 }
 
-function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
-  if (!video || !video.id) return null;
+function RowLinks({ video, runId, }: { video: RawRecord; runId: string }) {
+  const id = text(video.id, "");
+  if (!id) return null;
   const platformUrl = text(video.platform_url, "");
+  const platformLabel = text(video.platform_label, "原视频");
   return (
-    <div className={compact ? "walk-video-links compact" : "walk-video-links"}>
-      <Link href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
-        详情
+    <span className="wt-links">
+      <Link className="wt-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(id)}`} onClick={(e) => e.stopPropagation()}>
+        详情
       </Link>
       {platformUrl ? (
-        <a href={platformUrl} target="_blank" rel="noreferrer">
-          <ExternalLink size={12} />
-          抖音
+        <a className="wt-link" href={platformUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
+          <ExternalLink size={11} />
+          {platformLabel}
         </a>
       ) : null}
-    </div>
+    </span>
   );
 }
 
-function actionDepth(action: RawRecord): number {
-  const value = Number(action.depth || 0);
-  return Number.isFinite(value) ? value : 0;
-}
+// ---------------------------------------------------------------------------
+// build
+// ---------------------------------------------------------------------------
+function buildForest(actions: RawRecord[]): Forest {
+  const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
+  const routesBySource = new Map<string, RawRecord[]>();
+  const sourceIds = new Set<string>();
+  const targetIds = new Set<string>();
+  const queryRouteActions: RawRecord[] = [];
+
+  routable.forEach((action) => {
+    const sourceVideo = asRecord(action.source_video);
+    const sid = text(sourceVideo.id, "");
+    if (sid) {
+      sourceIds.add(sid);
+      const bucket = routesBySource.get(sid) || [];
+      bucket.push(action);
+      routesBySource.set(sid, bucket);
+    } else {
+      queryRouteActions.push(action);
+    }
+    asArray(action.target_videos)
+      .filter(isRecord)
+      .forEach((v) => {
+        const vid = text(v.id, "");
+        if (vid) targetIds.add(vid);
+      });
+  });
 
-function splitTitleAndTags(value: string): { title: string; tags: string[] } {
-  const matches = value.match(/#[^\s#]+/g) || [];
-  const title = value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim();
-  return {
-    title: title || value,
-    tags: matches.slice(0, 6)
+  const sourceVideoById = new Map<string, RawRecord>();
+  routable.forEach((action) => {
+    const sv = asRecord(action.source_video);
+    const sid = text(sv.id, "");
+    if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv);
+  });
+
+  const buildRoute = (action: RawRecord, parentKey: string, seen: Set<string>): RouteNode => {
+    const key = `${parentKey}>r:${actionKey(action)}`;
+    const videos = asArray(action.target_videos)
+      .filter(isRecord)
+      .map((v) => buildVideo(v, key, routesBySource, seen))
+      .filter((v): v is VideoNode => v !== null);
+    return { key, action, videos };
   };
-}
 
-function scoreSummary(value: unknown): string {
-  const items = Array.isArray(value) ? value.filter(isRecord) : [];
-  const visible = items.filter((item) => text(item.score_label));
-  if (!visible.length) return "";
-  return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
-}
+  function buildVideo(video: RawRecord, parentKey: string, routes: Map<string, RawRecord[]>, seen: Set<string>): VideoNode | null {
+    const id = text(video.id, "");
+    const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`;
+    const decision = asRecord(video.decision);
+    const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : [];
+    const nextSeen = new Set(seen);
+    if (id) nextSeen.add(id);
+    return {
+      key,
+      id,
+      video,
+      title: cleanTitle(text(video.title, "无标题视频")),
+      author: text(video.author, "未知作者"),
+      decisionLabel: text(decision.label, "未判断"),
+      decisionAction: text(decision.action, ""),
+      score: scoreText(decision),
+      tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean),
+      routes: childRoutes.map((a) => buildRoute(a, key, nextSeen))
+    };
+  }
 
-function actionScoreItems(action: RawRecord): { label: string; score: string }[] {
-  const items = Array.isArray(action.score_items) ? action.score_items.filter(isRecord) : [];
-  return items
-    .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
-    .filter((item) => item.score);
-}
+  const rootVideos: VideoNode[] = [...sourceIds]
+    .filter((id) => !targetIds.has(id))
+    .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set<string>()))
+    .filter((v): v is VideoNode => v !== null);
 
-function videoScoreItems(video: RawRecord): { label: string; score: string }[] {
-  const decision = asRecord(video.decision);
-  const items = Array.isArray(decision.score_items) ? decision.score_items.filter(isRecord) : [];
-  return items
-    .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
-    .filter((item) => item.score);
-}
+  const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set<string>()));
 
-function gateText(action: RawRecord, isSuccess: boolean): string {
-  const score = firstScore(scoreSummary(action.score_items));
-  if (isSuccess) {
-    const edgeId = text(action.edge_id, "");
-    const label = edgeId === "hashtag_to_query" || edgeId === "tag_query"
-      ? "标签评分通过"
-      : edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author"
-        ? "作者评分通过"
-        : edgeId === "query_next_page"
-          ? "翻页通过"
-          : "筛选通过";
-    return score ? `${label}:${score}` : label;
-  }
-  const reason = cleanBusinessText(text(action.reason_label, ""));
-  if (reason.includes("游走次数上限")) return "原因:达到游走次数上限,未继续扩展";
-  if (reason.includes("复看")) return "原因:待复看,先不继续扩展";
-  if (reason.includes("不适合继续向外扩展")) return `原因:${reason.replace(/^不适合继续向外扩展:?/, "") || "不适合继续向外扩展"},未继续扩展`;
-  return `原因:${reason || "没有达到继续扩展要求"},未继续扩展`;
+  return { rootVideos, queryRoutes };
 }
 
-function scoreDrawer(action: RawRecord, routeName: string, isSuccess: boolean): BusinessDrawerState {
-  const items = actionScoreItems(action);
-  const total = items.find((item) => item.label === "总分")?.score || "";
-  const query = items.find((item) => item.label === "是否契合需求")?.score || "";
-  const platform = items.find((item) => item.label === "平台表现")?.score || "";
-  const reason = cleanBusinessText(text(action.reason_label, ""));
+function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
+  const videoKeys = new Set<string>();
+  const routeKeys = new Set<string>();
+  const walkVideo = (node: VideoNode) => {
+    if (node.routes.length) videoKeys.add(node.key);
+    node.routes.forEach(walkRoute);
+  };
+  const walkRoute = (node: RouteNode) => {
+    if (node.videos.length) routeKeys.add(node.key);
+    node.videos.forEach(walkVideo);
+  };
+  forest.rootVideos.forEach(walkVideo);
+  forest.queryRoutes.forEach(walkRoute);
+  return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
+}
+
+function buildOverview(summary: unknown, forest: Forest): { label: string; value: string; tone?: string }[] {
+  const s = asRecord(summary);
+  return [
+    { label: "起点视频", value: String(forest.rootVideos.length) },
+    { label: "已游走", value: String(Number(s.success_count || 0)), tone: "go" },
+    { label: "未游走", value: String(Number(s.skipped_count || 0)), tone: "stop" },
+    { label: "新增搜索词", value: String(Number(s.extension_query_count || 0)) }
+  ];
+}
+
+// ---------------------------------------------------------------------------
+// drawers
+// ---------------------------------------------------------------------------
+function videoDrawer(node: VideoNode): BusinessDrawerState {
+  const decision = asRecord(node.video.decision);
+  const scoreItems = asArray(decision.score_items).filter(isRecord);
+  const reason = text(decision.reason_label, "");
   return {
-    title: `${routeName}筛选口径`,
+    title: node.title,
     sections: [
       {
-        label: "这一步为什么能继续",
-        value: isSuccess
-          ? `${routeName}已通过筛选${total ? `,总分 ${total}` : ""}。系统认为这条路线值得继续带回视频。`
-          : `这一步没有继续游走。${reason ? `原因:${reason}` : "原因:没有达到继续扩展要求。"}`,
-        tone: isSuccess ? "good" : "warn"
+        label: "这条视频的判断",
+        value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`,
+        tone: drawerTone(node.decisionLabel)
       },
+      scoreItems.length
+        ? {
+            label: "评分明细",
+            items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`),
+            tone: "info"
+          }
+        : { label: "评分明细", value: "本条没有记录评分。", tone: "info" },
+      node.tags.length ? { label: "视频标签", value: node.tags.join("  "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" }
+    ]
+  };
+}
+
+function routeDrawer(node: RouteNode): BusinessDrawerState {
+  const action = node.action;
+  const walked = isWalked(action);
+  const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean);
+  return {
+    title: `${routeKindLabel(text(action.edge_id, ""))}`,
+    sections: [
       {
-        label: "评分怎么看",
-        items: [
-          total ? `总分:综合判断这条路线值不值得继续。当前 ${total}。` : "总分:本次没有记录总分。",
-          query ? `是否契合需求:看内容方向和本次需求是否贴合。当前 ${query}。` : "是否契合需求:本次没有记录该分。",
-          platform ? `平台表现:看点赞、评论、转发等平台反馈。当前 ${platform}。` : "平台表现:本次没有记录该分。"
-        ],
-        tone: "info"
+        label: walked ? "这一步为什么继续" : "这一步为什么停下",
+        value: walked
+          ? `${routeObject(action)} 通过筛选,继续带回视频。`
+          : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`,
+        tone: walked ? "good" : "warn"
       },
-      {
-        label: "对流程的影响",
-        value: isSuccess ? "通过后会生成新搜索词或翻页,并把带回的视频继续送去判断。" : "未通过时,这条路线停止,不再继续带回新视频。",
-        tone: "neutral"
-      }
+      detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" },
+      { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" }
     ]
   };
 }
 
-function firstScore(value: string): string {
-  return value.split("/")[0]?.trim() || value;
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+function isRecord(value: unknown): value is RawRecord {
+  return typeof value === "object" && value !== null;
 }
 
-function stopLabel(label: string): string {
-  if (label === "入池") return "本卡未继续";
-  if (label === "待复看") return "待复看停止";
-  if (label === "淘汰") return "淘汰停止";
-  return "未继续";
+function actionKey(action: RawRecord): string {
+  return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`);
 }
 
-function terminalResultText(action: RawRecord): string {
-  const edgeId = text(action.edge_id, "");
-  if (edgeId === "decision_to_asset") return "入池";
-  if (edgeId === "budget_downgrade") return "待复看";
-  if (edgeId === "path_stop") return "淘汰并停止";
-  return cleanBusinessText(text(action.result, "已记录"));
+function isWalked(action: RawRecord): boolean {
+  return text(action.status, "") === "success";
 }
 
 function isTerminalEdge(edgeId: string): boolean {
   return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
 }
 
-function routeLabel(edgeId: string): string {
-  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
-  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
-  if (edgeId === "query_next_page") return "翻页继续游走";
-  return "继续游走";
+function cleanTitle(value: string): string {
+  return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
 }
 
-function routeSubject(action: RawRecord): string {
-  const edgeId = text(action.edge_id, "");
-  const raw = cleanBusinessText(text(action.trigger_label, ""));
-  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
-    const value = raw.replace(/^候选标签:/, "").replace(/^标签搜索词:/, "#").replace(/^标签:/, "");
-    return value || "标签待确认";
-  }
-  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
-    return raw.replace(/^作者:/, "") || "作者待确认";
-  }
-  if (edgeId === "query_next_page") {
-    return cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/^从搜索词/, "").replace(/继续$/, "") || "当前搜索词";
-  }
-  return raw || "当前内容";
+function scoreText(decision: RawRecord): string {
+  const label = text(decision.score_label, "");
+  if (label && label !== "未记录") return label;
+  const score = Number(decision.score);
+  return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : "";
 }
 
-function routeSummary(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[], isSuccess: boolean): string {
-  if (!isSuccess) return stoppedSentence(action);
-  const edgeId = text(action.edge_id, "");
-  const subject = routeSubject(action);
-  if (edgeId === "query_next_page") {
-    return `从搜索词 ${subject} 翻到下一页,带回 ${targetVideos.length} 条视频。`;
-  }
-  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
-    const queryText = text(targetQuery.text, "");
-    return queryText ? `标签 ${subject} 通过筛选,生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `标签 ${subject} 通过筛选,带回 ${targetVideos.length} 条视频。`;
-  }
-  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
-    return `作者 ${subject} 通过筛选,继续查看作品,带回 ${targetVideos.length} 条视频。`;
-  }
-  return cleanBusinessText(text(action.result, "已执行"));
+function decisionTone(label: string): string {
+  if (label === "入池") return "good";
+  if (label === "待复看") return "warn";
+  if (label === "淘汰") return "bad";
+  if (label.includes("重试") || label.includes("补跑")) return "tech";
+  return "plain";
 }
 
-function stoppedSentence(action: RawRecord): string {
-  return gateText(action, false);
+function drawerTone(label: string): BusinessTone {
+  if (label === "入池") return "good";
+  if (label === "淘汰") return "bad";
+  return "warn";
 }
 
-function shortScoreLabel(label: string): string {
-  if (label === "是否契合需求") return "需求";
-  if (label === "平台表现") return "平台";
-  return label;
+function edgeTone(edgeId: string): string {
+  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
+  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
+  if (edgeId === "query_next_page") return "page";
+  return "plain";
 }
 
-function cleanBusinessText(value: string): string {
-  if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
-  return value.replaceAll("“", "《").replaceAll("”", "》");
+function edgeIcon(edgeId: string) {
+  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={14} />;
+  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={14} />;
+  if (edgeId === "query_next_page") return <RotateCw size={14} />;
+  return <Search size={14} />;
 }
 
-function decisionTone(value: string): string {
-  if (value === "入池") return "good";
-  if (value === "待复看") return "warn";
-  if (value === "淘汰") return "bad";
-  return "plain";
+function routeKindLabel(edgeId: string): string {
+  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
+  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
+  if (edgeId === "query_next_page") return "翻页继续找";
+  return "继续游走";
 }
 
-function edgeTone(edgeId: string): string {
-  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
-  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
-  if (edgeId === "query_next_page") return "page";
-  return "stop";
+function routeObject(action: RawRecord): string {
+  const edge = text(action.edge_id, "");
+  const raw = cleanBusinessText(text(action.trigger_label, ""));
+  if (edge === "query_next_page") return "翻下一页";
+  if (edge === "hashtag_to_query" || edge === "tag_query") {
+    const target = asRecord(action.target_query);
+    if (target.text && isWalked(action)) return `标签`;
+    const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim();
+    return candidates ? `标签 ${candidates}` : "标签";
+  }
+  if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") {
+    return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`;
+  }
+  return raw || "继续游走";
 }
 
-function edgeIcon(edgeId: string) {
-  if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={15} />;
-  if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={15} />;
-  return <Search size={15} />;
+function shortReason(action: RawRecord): string {
+  const reason = cleanBusinessText(text(action.reason_label, ""));
+  if (!reason) return "未达到继续扩展要求";
+  if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限";
+  if (reason.includes("复看")) return "分数在复看区间";
+  return reason.replace(/^不适合继续向外扩展:?/, "");
 }
 
-function isRecord(value: unknown): value is RawRecord {
-  return typeof value === "object" && value !== null;
+function cleanBusinessText(value: string): string {
+  if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
+  return value.replaceAll("“", "《").replaceAll("”", "》");
 }