QueryVideosPage.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. "use client";
  2. import Link from "next/link";
  3. import { PanelRightOpen } from "lucide-react";
  4. import { useCallback, useEffect, useMemo, useState } from "react";
  5. import { AppShell } from "@/components/AppShell";
  6. import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
  7. import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
  8. import { getFlowLedgerVideos } from "@/lib/api/client";
  9. import type { FlowLedgerVideosResponse } from "@/lib/api/types";
  10. import { videoFromApi } from "@/lib/flow-ledger/build";
  11. import { actionLabel, mediaStatusLabel, metricLabel, methodLabel, platformLabel, scoreItemsText } from "@/lib/flow-ledger/business";
  12. import { asRecord, text } from "@/lib/flow-ledger/format";
  13. import type { VideoRef } from "@/lib/flow-ledger/types";
  14. export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: string }) {
  15. const [data, setData] = useState<FlowLedgerVideosResponse | null>(null);
  16. const [loading, setLoading] = useState(true);
  17. const [error, setError] = useState<string | null>(null);
  18. const load = useCallback(async () => {
  19. setLoading(true);
  20. setError(null);
  21. try {
  22. const debug = new URLSearchParams(window.location.search).get("debug") === "1";
  23. setData(await getFlowLedgerVideos(runId, queryId, debug));
  24. } catch (err) {
  25. setError(err instanceof Error ? err.message : String(err));
  26. } finally {
  27. setLoading(false);
  28. }
  29. }, [queryId, runId]);
  30. useEffect(() => {
  31. void load();
  32. }, [load]);
  33. const query = asRecord(data?.query);
  34. const videos = useMemo(() => (data?.videos || []).map(videoFromApi), [data]);
  35. return (
  36. <AppShell title="首轮搜到的视频" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看这个搜索词搜到了哪些内容"} runId={runId} showBack onRefresh={load}>
  37. {loading ? <LoadingState /> : null}
  38. {error ? <ErrorState message={error} /> : null}
  39. {!loading && !error && !videos.length ? <EmptyState label="这个搜索词首轮没有搜到可展示的视频" /> : null}
  40. {data ? (
  41. <>
  42. <details className="declarations" open>
  43. <summary>
  44. <span className="kw">搜索词</span> <b>{text(query.text, "搜索词待确认")}</b>
  45. <span className="decl-purpose">来源:{methodLabel(text(query.method, ""))}</span>
  46. <span className="tag-mini">首轮视频 {Number(data.summary.total || videos.length)}</span>
  47. </summary>
  48. <div className="decl-body">
  49. <Summary label="判断汇总" value={`入池 ${Number(data.summary.pool_count || 0)} · 待复看 ${Number(data.summary.review_count || 0)} · 技术问题 ${Number(data.summary.technical_retry_count || 0)} · 淘汰 ${Number(data.summary.reject_count || 0)}`} />
  50. <Summary label="视频保存" value={`${mediaStatusLabel("oss_uploaded")} ${Number(data.summary.oss_uploaded_count || 0)} · ${mediaStatusLabel("oss_upload_pending")} ${Number(data.summary.oss_pending_count || 0)} · ${mediaStatusLabel("oss_upload_failed")} ${Number(data.summary.oss_failed_count || 0)}`} />
  51. <Summary label="数据来源" value={data.data_origin_label} />
  52. </div>
  53. </details>
  54. <section className="detail-table-wrap">
  55. <table className="detail-table">
  56. <thead>
  57. <tr>
  58. <th>#</th>
  59. <th>视频</th>
  60. <th>作者 / 平台</th>
  61. <th>互动</th>
  62. <th>判断</th>
  63. <th>评分详情</th>
  64. <th>视频保存</th>
  65. <th>操作</th>
  66. </tr>
  67. </thead>
  68. <tbody>
  69. {videos.map((video, index) => (
  70. <VideoRow
  71. key={`${video.id}-${index}`}
  72. index={index}
  73. runId={runId}
  74. video={video}
  75. />
  76. ))}
  77. </tbody>
  78. </table>
  79. </section>
  80. </>
  81. ) : null}
  82. </AppShell>
  83. );
  84. }
  85. function Summary({ label, value }: { label: string; value: string }) {
  86. return (
  87. <div className="decl-section">
  88. <div className="decl-label">{label}</div>
  89. <div className="decl-row">{value}</div>
  90. </div>
  91. );
  92. }
  93. function VideoRow({ index, runId, video }: { index: number; runId: string; video: VideoRef }) {
  94. return (
  95. <tr>
  96. <td>{index + 1}</td>
  97. <td>
  98. <Link className="title-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.contentDiscoveryId || video.id)}`}>
  99. {video.title}
  100. </Link>
  101. </td>
  102. <td>
  103. <strong>{video.author}</strong>
  104. <div className="muted">{platformLabel(video.platform)}</div>
  105. </td>
  106. <td>
  107. <span className="chip">{metricLabel(video)}</span>
  108. </td>
  109. <td>
  110. <span className="chip blue">{actionLabel(video.decisionAction)}</span>
  111. {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? (
  112. <div className="muted">{technicalRetryShortText(video)}</div>
  113. ) : null}
  114. {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
  115. </td>
  116. <td>
  117. <div className="score-mini">{scoreItemsText(video.scoreItems)}</div>
  118. </td>
  119. <td>
  120. <span className={`chip ${video.mediaStatus === "oss_uploaded" ? "green" : "yellow"}`}>{video.mediaStatusLabel}</span>
  121. {video.mediaFailureReason ? <div className="muted">{video.mediaFailureReason}</div> : null}
  122. </td>
  123. <td>
  124. <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.contentDiscoveryId || video.id)}`}>
  125. <PanelRightOpen size={13} />
  126. 看详情
  127. </Link>
  128. </td>
  129. </tr>
  130. );
  131. }
  132. function technicalRetryShortText(video: VideoRef): string {
  133. const detail = video.technicalRetryDetail;
  134. if (!detail) return "系统需要重试后再判断";
  135. const stage = detail.stageLabel || "技术问题";
  136. const reason = detail.briefReason || detail.failureLabel || detail.failureType || "系统需要重试后再判断";
  137. return `${stage}:${reason}`;
  138. }