Просмотр исходного кода

Refine walk result video cards

Sam Lee 3 недель назад
Родитель
Сommit
367e31158a
2 измененных файлов с 320 добавлено и 85 удалено
  1. 150 0
      web2/app/globals.css
  2. 170 85
      web2/features/QueryWalkPage.tsx

+ 150 - 0
web2/app/globals.css

@@ -1781,7 +1781,157 @@ a.walk-flow-node:hover {
 
 .walk-result-videos {
   display: grid;
+  gap: 8px;
+  padding-top: 2px;
+}
+
+.walk-result-video-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+  gap: 8px;
+  align-items: start;
+}
+
+.walk-result-video-card {
+  min-width: 0;
+  display: grid;
+  gap: 8px;
+  padding: 10px;
+  border: 1px solid #e2e8f0;
+  border-radius: 5px;
+  background: #fff;
+  box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
+}
+
+.walk-result-video-card.good {
+  border-color: #86efac;
+  background: #f0fdf4;
+}
+
+.walk-result-video-card.warn {
+  border-color: #fde68a;
+  background: #fffbeb;
+}
+
+.walk-result-video-card.bad {
+  border-color: #fecaca;
+  background: #fef2f2;
+}
+
+.walk-result-video-card.has-child {
+  grid-column: 1 / -1;
+  background: #ffffff;
+}
+
+.walk-result-video-top {
+  display: flex;
+  flex-wrap: wrap;
   gap: 6px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.walk-result-status,
+.walk-result-continue,
+.walk-result-stop {
+  min-height: 24px;
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 7px;
+  border-radius: 4px;
+  font-size: 11.5px;
+  font-weight: 850;
+}
+
+.walk-result-status.good {
+  color: #065f46;
+  background: #bbf7d0;
+}
+
+.walk-result-status.warn {
+  color: #92400e;
+  background: #fde68a;
+}
+
+.walk-result-status.bad {
+  color: #991b1b;
+  background: #fecaca;
+}
+
+.walk-result-continue {
+  color: #1e40af;
+  background: #dbeafe;
+}
+
+.walk-result-stop {
+  color: #64748b;
+  background: #e2e8f0;
+}
+
+.walk-result-video-title {
+  color: #111827;
+  font-size: 13px;
+  font-weight: 850;
+  line-height: 1.45;
+  overflow-wrap: anywhere;
+}
+
+.walk-result-video-title:hover {
+  color: #1e40af;
+}
+
+.walk-result-video-meta {
+  color: #64748b;
+  font-size: 11.5px;
+  font-weight: 750;
+}
+
+.walk-score-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+}
+
+.walk-score-chips span {
+  min-height: 22px;
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 6px;
+  border: 1px solid rgba(148, 163, 184, 0.38);
+  border-radius: 4px;
+  color: #334155;
+  background: rgba(255, 255, 255, 0.72);
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.compact-tags {
+  gap: 4px;
+}
+
+.compact-tags span {
+  font-size: 10.5px;
+}
+
+.walk-card-children {
+  margin-top: 2px;
+  padding: 10px 0 0 14px;
+  border-left: 2px dashed #93c5fd;
+}
+
+.walk-card-children > .walk-story-block {
+  margin-top: 0;
+  box-shadow: none;
+}
+
+.walk-stop-note {
+  padding: 9px 10px;
+  border: 1px dashed #fbbf24;
+  border-radius: 5px;
+  color: #92400e;
+  background: #fffbeb;
+  font-size: 12px;
+  font-weight: 800;
 }
 
 .walk-result-video {

+ 170 - 85
web2/features/QueryWalkPage.tsx

@@ -1,9 +1,10 @@
 "use client";
 
 import Link from "next/link";
-import { ArrowRight, ExternalLink, Hash, ListVideo, Search, UserSearch } from "lucide-react";
+import { ExternalLink, Hash, Info, ListVideo, 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 { getFlowLedgerWalk } from "@/lib/api/client";
 import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
@@ -31,6 +32,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
   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 load = useCallback(async () => {
     setLoading(true);
@@ -67,7 +69,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
               <span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</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>
@@ -80,10 +82,11 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
             </div>
             <div className="walk-ledger-groups">
               {tree.roots.map((group, index) => (
-                <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} key={group.id} />
+                <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} onOpenScore={setDrawer} key={group.id} />
               ))}
             </div>
           </section>
+          <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
         </>
       ) : null}
     </AppShell>
@@ -105,6 +108,7 @@ function WalkGroupCard({
   runId,
   tree,
   level,
+  onOpenScore,
   seen = new Set<string>()
 }: {
   group: WalkGroup;
@@ -112,6 +116,7 @@ function WalkGroupCard({
   runId: string;
   tree: WalkTree;
   level: number;
+  onOpenScore: (drawer: BusinessDrawerState) => void;
   seen?: Set<string>;
 }) {
   const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
@@ -150,6 +155,7 @@ function WalkGroupCard({
             sourceId={group.id}
             tree={tree}
             level={level}
+            onOpenScore={onOpenScore}
             seen={nextSeen}
             key={`${text(action.id, "walk")}-${actionIndex}`}
           />
@@ -174,6 +180,7 @@ function WalkRouteCard({
   sourceId,
   tree,
   level,
+  onOpenScore,
   seen
 }: {
   action: RawRecord;
@@ -181,6 +188,7 @@ function WalkRouteCard({
   sourceId: string;
   tree: WalkTree;
   level: number;
+  onOpenScore: (drawer: BusinessDrawerState) => void;
   seen: Set<string>;
 }) {
   const edgeId = text(action.edge_id, "");
@@ -190,9 +198,8 @@ function WalkRouteCard({
   const isSuccess = status === "success";
   const routeName = routeLabel(edgeId);
   const gate = gateText(action, isSuccess);
-  const firstNode = routeTriggerNode(action);
-  const secondNode = routeResultNode(action, targetQuery, targetVideos, isSuccess);
   const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
+  const childByVideoId = new Map(childGroups.map((group) => [group.id, group]));
 
   return (
     <article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
@@ -202,21 +209,17 @@ function WalkRouteCard({
           <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
           <div>
             <strong>{routeName}</strong>
-            <p>{isSuccess ? successSentence(action, targetQuery, targetVideos) : stoppedSentence(action)}</p>
+            <p>{routeSummary(action, targetQuery, targetVideos, isSuccess)}</p>
           </div>
           <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已执行" : "未执行"}</span>
         </div>
 
-        <div className="walk-flow" aria-label={`${routeName} 的链路`}>
-          <FlowNode label="从" value={text(action.from_label, "").includes("搜索词") ? "这个搜索词" : "这条视频"} 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>
+          <button className="walk-jump-button" type="button" onClick={() => onOpenScore(scoreDrawer(action, routeName, isSuccess))}>
+            <Info size={13} />
+            看筛选口径
+          </button>
           {targetQuery.id ? (
             <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
               查看带回视频
@@ -231,57 +234,89 @@ function WalkRouteCard({
               <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 />
-              </div>
-            ))}
-          </div>
-        ) : null}
-
-        {childGroups.length ? (
-          <div className="walk-nested-children">
-            <div className="walk-nested-label">带回的视频继续触发下一层游走</div>
-            {childGroups.map((group, childIndex) => (
-              <WalkGroupCard
-                group={group}
-                index={childIndex}
-                runId={runId}
-                tree={tree}
-                level={level + 1}
-                seen={seen}
-                key={`${text(action.id, "walk")}-${group.id}`}
-              />
-            ))}
+            <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 FlowNode({ label, value, href, tone = "plain" }: { label: string; value: string; href?: string; tone?: string }) {
-  const content = (
-    <>
-      <span>{label}</span>
-      <strong>{value}</strong>
-    </>
-  );
-  if (href) {
-    return <Link className={`walk-flow-node ${tone}`} href={href}>{content}</Link>;
-  }
-  return <div className={`walk-flow-node ${tone}`}>{content}</div>;
-}
+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, "无标题视频"));
 
-function FlowArrow() {
   return (
-    <span className="walk-flow-arrow" aria-hidden="true">
-      <ArrowRight size={15} />
-    </span>
+    <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>
+        {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</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>
+          ))}
+        </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>
   );
 }
 
@@ -396,9 +431,34 @@ function scoreSummary(value: unknown): string {
   return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
 }
 
+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);
+}
+
+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);
+}
+
 function gateText(action: RawRecord, isSuccess: boolean): string {
-  const score = scoreSummary(action.score_items);
-  if (isSuccess) return score ? `通过:${firstScore(score)}` : "通过";
+  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 "未执行:待复看,需人工确认";
@@ -406,10 +466,51 @@ function gateText(action: RawRecord, isSuccess: boolean): string {
   return `未执行:${reason || "没有继续扩展"}`;
 }
 
+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, ""));
+  return {
+    title: `${routeName}筛选口径`,
+    sections: [
+      {
+        label: "这一步为什么能继续",
+        value: isSuccess
+          ? `${routeName}已通过筛选${total ? `,总分 ${total}` : ""}。系统认为这条路线值得继续带回视频。`
+          : `这一步没有继续执行。${reason ? `原因:${reason}` : "原因:没有达到继续扩展要求。"}`,
+        tone: isSuccess ? "good" : "warn"
+      },
+      {
+        label: "评分怎么看",
+        items: [
+          total ? `总分:综合判断这条路线值不值得继续。当前 ${total}。` : "总分:本次没有记录总分。",
+          query ? `是否契合需求:看内容方向和本次需求是否贴合。当前 ${query}。` : "是否契合需求:本次没有记录该分。",
+          platform ? `平台表现:看点赞、评论、转发等平台反馈。当前 ${platform}。` : "平台表现:本次没有记录该分。"
+        ],
+        tone: "info"
+      },
+      {
+        label: "对流程的影响",
+        value: isSuccess ? "通过后会生成新搜索词或翻页,并把带回的视频继续送去判断。" : "未通过时,这条路线停止,不再继续带回新视频。",
+        tone: "neutral"
+      }
+    ]
+  };
+}
+
 function firstScore(value: string): string {
   return value.split("/")[0]?.trim() || value;
 }
 
+function stopLabel(label: string): string {
+  if (label === "入池") return "本卡未继续";
+  if (label === "待复看") return "待复看停止";
+  if (label === "淘汰") return "淘汰停止";
+  return "未继续";
+}
+
 function terminalResultText(action: RawRecord): string {
   const edgeId = text(action.edge_id, "");
   if (edgeId === "decision_to_asset") return "入池";
@@ -429,51 +530,35 @@ function routeLabel(edgeId: string): string {
   return "继续游走";
 }
 
-function routeTriggerNode(action: RawRecord): { label: string; value: string } {
+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 { label: raw.startsWith("候选标签") ? "候选标签" : "视频标签", value: value || "标签待确认" };
+    return value || "标签待确认";
   }
   if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
-    return { label: "作者", value: raw.replace(/^作者:/, "") || "作者待确认" };
+    return raw.replace(/^作者:/, "") || "作者待确认";
   }
   if (edgeId === "query_next_page") {
-    return { label: "原搜索", value: raw.replace(/^翻下一页:/, "") || "下一页" };
-  }
-  return { label: "触发", value: raw || "当前内容" };
-}
-
-function routeResultNode(
-  action: RawRecord,
-  targetQuery: RawRecord,
-  targetVideos: RawRecord[],
-  isSuccess: boolean
-): { label: string; value: string; href?: string } {
-  const edgeId = text(action.edge_id, "");
-  if (!isSuccess) return { label: "停止", value: "没有继续带回新内容" };
-  if (targetQuery.id) {
-    const textValue = text(targetQuery.text, "搜索词待确认");
-    const label = edgeId === "query_next_page" ? "下一页" : "新搜索词";
-    return { label, value: textValue };
+    return cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/^从搜索词/, "").replace(/继续$/, "") || "当前搜索词";
   }
-  if (targetVideos.length) return { label: "带回", value: `${targetVideos.length} 条视频` };
-  return { label: "完成", value: cleanBusinessText(text(action.result, "已执行")) };
+  return raw || "当前内容";
 }
 
-function successSentence(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[]): string {
+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") {
-    const from = cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/继续$/, "");
-    return `${from}翻到下一页,带回 ${targetVideos.length} 条视频。`;
+    return `从搜索词 ${subject} 翻到下一页,带回 ${targetVideos.length} 条视频。`;
   }
   if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
     const queryText = text(targetQuery.text, "");
-    return queryText ? `标签生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `从标签继续搜,带回 ${targetVideos.length} 条视频。`;
+    return queryText ? `标签 ${subject} 通过筛选,生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `标签 ${subject} 通过筛选,带回 ${targetVideos.length} 条视频。`;
   }
   if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
-    return `查看作者更多作品,带回 ${targetVideos.length} 条视频。`;
+    return `作者 ${subject} 通过筛选,继续查看作品,带回 ${targetVideos.length} 条视频。`;
   }
   return cleanBusinessText(text(action.result, "已执行"));
 }