Selaa lähdekoodia

Make walk page a vertical path tree

Sam Lee 3 viikkoa sitten
vanhempi
commit
e263fd8dac
2 muutettua tiedostoa jossa 213 lisäystä ja 72 poistoa
  1. 149 14
      web2/app/globals.css
  2. 64 58
      web2/features/QueryWalkPage.tsx

+ 149 - 14
web2/app/globals.css

@@ -1024,16 +1024,13 @@ a:focus-visible {
 
 .walk-ledger-groups {
   display: grid;
-  gap: 10px;
+  gap: 12px;
   margin-top: 10px;
 }
 
-.walk-ledger-group {
+.walk-ledger-group,
+.walk-story-block {
   position: relative;
-  display: grid;
-  grid-template-columns: 22px minmax(280px, 360px) minmax(0, 1fr);
-  gap: 12px;
-  align-items: start;
 }
 
 .walk-ledger-spine {
@@ -1072,6 +1069,58 @@ a:focus-visible {
   background: #fff;
 }
 
+.walk-story-block {
+  display: grid;
+  gap: 10px;
+  padding: 12px 12px 12px 24px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.walk-story-block::before {
+  content: "";
+  position: absolute;
+  top: 18px;
+  bottom: 14px;
+  left: 10px;
+  width: 2px;
+  background: #bfdbfe;
+}
+
+.walk-story-block::after {
+  content: "";
+  position: absolute;
+  top: 24px;
+  left: 5px;
+  width: 12px;
+  height: 12px;
+  border: 2px solid #2563eb;
+  border-radius: 999px;
+  background: #fff;
+}
+
+.walk-story-head {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  gap: 10px;
+  align-items: start;
+}
+
+.walk-story-title {
+  min-width: 0;
+  display: grid;
+  gap: 7px;
+}
+
+.walk-story-title h2 {
+  margin: 0;
+  color: #111827;
+  font-size: 15px;
+  font-weight: 850;
+  line-height: 1.45;
+}
+
 .walk-source-kicker {
   width: max-content;
   padding: 3px 8px;
@@ -1137,7 +1186,7 @@ a:focus-visible {
 
 .walk-source-facts {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
+  grid-template-columns: repeat(4, minmax(0, 1fr));
   gap: 6px;
 }
 
@@ -1236,6 +1285,88 @@ a:focus-visible {
   gap: 8px;
 }
 
+.walk-story-routes {
+  position: relative;
+  display: grid;
+  gap: 8px;
+  margin-left: 18px;
+  padding-left: 16px;
+  border-left: 2px solid #dbeafe;
+}
+
+.walk-story-routes-label {
+  width: max-content;
+  margin-bottom: 1px;
+  padding: 3px 8px;
+  border-radius: 4px;
+  color: #1e40af;
+  background: #eff6ff;
+  font-size: 11px;
+  font-weight: 850;
+}
+
+.walk-tree-route {
+  position: relative;
+  display: grid;
+  grid-template-columns: 14px minmax(0, 1fr);
+  gap: 8px;
+}
+
+.walk-tree-route-dot {
+  position: relative;
+}
+
+.walk-tree-route-dot::before {
+  content: "";
+  position: absolute;
+  top: 22px;
+  left: -23px;
+  width: 21px;
+  height: 2px;
+  background: #dbeafe;
+}
+
+.walk-tree-route-dot::after {
+  content: "";
+  position: absolute;
+  top: 17px;
+  left: -5px;
+  width: 10px;
+  height: 10px;
+  border: 2px solid #60a5fa;
+  border-radius: 999px;
+  background: #fff;
+}
+
+.walk-tree-route-body {
+  display: grid;
+  gap: 9px;
+  padding: 10px 12px;
+  border: 1px solid #dbeafe;
+  border-radius: 5px;
+  background: #f8fbff;
+}
+
+.walk-tree-route.tag .walk-tree-route-body {
+  border-color: #bbf7d0;
+  background: #f0fdf4;
+}
+
+.walk-tree-route.author .walk-tree-route-body {
+  border-color: #ddd6fe;
+  background: #f5f3ff;
+}
+
+.walk-tree-route.page .walk-tree-route-body {
+  border-color: #bfdbfe;
+  background: #eff6ff;
+}
+
+.walk-tree-route.stopped .walk-tree-route-body {
+  border-color: #fde68a;
+  background: #fffbeb;
+}
+
 .walk-route-card {
   display: grid;
   gap: 9px;
@@ -1300,7 +1431,7 @@ a:focus-visible {
   padding: 8px 9px;
   border: 1px solid rgba(148, 163, 184, 0.55);
   border-radius: 4px;
-  background: rgba(255, 255, 255, 0.86);
+  background: #fff;
 }
 
 a.walk-flow-node:hover {
@@ -1609,16 +1740,15 @@ a.walk-flow-node:hover {
 }
 
 @media (max-width: 900px) {
-  .walk-ledger-group {
-    grid-template-columns: 18px minmax(0, 1fr);
+  .walk-branch-facts {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
   }
 
-  .walk-source-card,
-  .walk-route-stack {
-    grid-column: 2;
+  .walk-story-head {
+    grid-template-columns: 1fr;
   }
 
-  .walk-branch-facts {
+  .walk-source-facts {
     grid-template-columns: repeat(2, minmax(0, 1fr));
   }
 
@@ -1640,6 +1770,11 @@ a.walk-flow-node:hover {
     grid-template-columns: 1fr;
   }
 
+  .walk-story-routes {
+    margin-left: 8px;
+    padding-left: 12px;
+  }
+
   .walk-route-head {
     grid-template-columns: auto minmax(0, 1fr);
   }

+ 64 - 58
web2/features/QueryWalkPage.tsx

@@ -60,7 +60,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
               <span className="tag-mini">{groups.length ? `${groups.length} 个起点` : "没有继续扩展"}</span>
             </summary>
             <div className="decl-body">
-              <Summary label="怎么看" value="先看左侧起点视频,再沿着右侧路线看:从哪里触发、生成了什么、带回哪些视频。" />
+              <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>
@@ -98,26 +98,29 @@ function WalkGroupCard({ group, index, runId }: { group: WalkGroup; index: numbe
   const score = group.video ? scoreSummary(asRecord(group.video.decision).score_items) : "";
 
   return (
-    <article className="walk-ledger-group" id={`source-${encodeURIComponent(group.id)}`}>
-      <div className="walk-ledger-spine" />
-      <section className="walk-source-card">
-        <div className="walk-source-kicker">起点视频 {index + 1}</div>
-        <h2>{titleParts.title}</h2>
-        {titleParts.tags.length ? (
-          <div className="walk-source-tags">
-            {titleParts.tags.map((tag) => <span key={tag}>{tag}</span>)}
-          </div>
-        ) : null}
-        <div className="walk-source-facts">
-          <Fact label="作者" value={group.author} />
-          <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} />
-          {score ? <Fact label="评分" value={score} /> : null}
-          {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
+    <article className="walk-story-block" id={`source-${encodeURIComponent(group.id)}`}>
+      <header className="walk-story-head">
+        <span className="walk-source-kicker">起点视频 {index + 1}</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} />
-      </section>
+      </header>
+
+      <div className="walk-source-facts">
+        <Fact label="作者" value={group.author} />
+        <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} />
+        {score ? <Fact label="评分" value={score} /> : null}
+        {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
+      </div>
 
-      <section className="walk-route-stack" aria-label={`${group.title} 的游走路线`}>
+      <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
+        <div className="walk-story-routes-label">从这条视频继续往外找</div>
         {routeActions.length ? routeActions.map((action, actionIndex) => (
           <WalkRouteCard action={action} runId={runId} sourceId={group.id} key={`${text(action.id, "walk")}-${actionIndex}`} />
         )) : <div className="walk-no-route">这条视频没有继续产生标签、作者或翻页路线。</div>}
@@ -147,51 +150,54 @@ function WalkRouteCard({ action, runId, sourceId }: { action: RawRecord; runId:
   const secondNode = routeResultNode(action, targetQuery, targetVideos, isSuccess);
 
   return (
-    <article className={`walk-route-card ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
-      <div className="walk-route-head">
-        <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
-        <div>
-          <strong>{routeName}</strong>
-          <p>{isSuccess ? successSentence(action, targetQuery, targetVideos) : stoppedSentence(action)}</p>
+    <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>{isSuccess ? successSentence(action, targetQuery, targetVideos) : stoppedSentence(action)}</p>
+          </div>
+          <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已执行" : "未执行"}</span>
         </div>
-        <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已执行" : "未执行"}</span>
-      </div>
 
-      <div className="walk-flow" aria-label={`${routeName} 的链路`}>
-        <FlowNode label="起点" value="这条视频" href={`#source-${encodeURIComponent(sourceId)}`} />
-        <FlowArrow />
-        <FlowNode label={firstNode.label} value={firstNode.value} />
-        <FlowArrow />
-        <FlowNode label={secondNode.label} value={secondNode.value} href={secondNode.href} tone={isSuccess ? "strong" : "muted"} />
-      </div>
+        <div className="walk-flow" aria-label={`${routeName} 的链路`}>
+          <FlowNode label="从" value="这条视频" href={`#source-${encodeURIComponent(sourceId)}`} />
+          <FlowArrow />
+          <FlowNode label={firstNode.label} value={firstNode.value} />
+          <FlowArrow />
+          <FlowNode label={secondNode.label} value={secondNode.value} href={secondNode.href} tone={isSuccess ? "strong" : "muted"} />
+        </div>
 
-      <div className="walk-route-bottom">
-        <span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
-        {targetQuery.id ? (
-          <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
-            查看带回视频
-          </Link>
-        ) : null}
-        <a className="walk-jump-button" href={`#source-${encodeURIComponent(sourceId)}`}>回到起点</a>
-      </div>
+        <div className="walk-route-bottom">
+          <span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
+          {targetQuery.id ? (
+            <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
+              查看带回视频
+            </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>
-          {targetVideos.slice(0, 4).map((video) => (
-            <div className="walk-result-video" key={text(video.id, text(video.title, "video"))}>
-              <Link className="walk-result-video-main" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
-                <b>{text(video.title, "无标题视频")}</b>
-                <small>{text(video.author, "未知作者")} · {text(asRecord(video.decision).label, "未判断")}</small>
-              </Link>
-              <VideoLinks video={video} runId={runId} compact />
+        {targetVideos.length ? (
+          <div className="walk-result-videos">
+            <div className="walk-result-title">
+              <ListVideo size={15} />
+              <span>带回视频 {targetVideos.length} 条</span>
             </div>
-          ))}
-        </div>
-      ) : null}
+            {targetVideos.slice(0, 4).map((video) => (
+              <div className="walk-result-video" key={text(video.id, text(video.title, "video"))}>
+                <Link className="walk-result-video-main" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
+                  <b>{text(video.title, "无标题视频")}</b>
+                  <small>{text(video.author, "未知作者")} · {text(asRecord(video.decision).label, "未判断")}</small>
+                </Link>
+                <VideoLinks video={video} runId={runId} compact />
+              </div>
+            ))}
+          </div>
+        ) : null}
+      </div>
     </article>
   );
 }