VideoDetailPage.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { AppShell } from "@/components/AppShell";
  4. import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
  5. import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
  6. import { getFlowLedgerVideo } from "@/lib/api/client";
  7. import type { FlowLedgerVideoResponse } from "@/lib/api/types";
  8. import { videoFromApi } from "@/lib/flow-ledger/build";
  9. import { actionLabel, displayScoreLabel, mediaStatusLabel, metricLabel, platformLabel, scoreItemRows } from "@/lib/flow-ledger/business";
  10. import type { ScoreItem, VideoRef } from "@/lib/flow-ledger/types";
  11. export function VideoDetailPage({ runId, contentId }: { runId: string; contentId: string }) {
  12. const [data, setData] = useState<FlowLedgerVideoResponse | null>(null);
  13. const [loading, setLoading] = useState(true);
  14. const [error, setError] = useState<string | null>(null);
  15. const load = useCallback(async () => {
  16. setLoading(true);
  17. setError(null);
  18. try {
  19. const debug = new URLSearchParams(window.location.search).get("debug") === "1";
  20. setData(await getFlowLedgerVideo(runId, contentId, debug));
  21. } catch (err) {
  22. setError(err instanceof Error ? err.message : String(err));
  23. } finally {
  24. setLoading(false);
  25. }
  26. }, [contentId, runId]);
  27. useEffect(() => {
  28. void load();
  29. }, [load]);
  30. const video = useMemo(() => (data?.video ? videoFromApi(data.video) : undefined), [data]);
  31. const scoreRows = useMemo(() => scoreItemRows(video?.scoreItems || []), [video]);
  32. const isTechnical = video?.decisionAction === "TECHNICAL_RETRY_REQUIRED";
  33. const totalItem = scoreRows.find((item) => displayScoreLabel(item.label) === "总分");
  34. const mainScores = scoreRows.filter((item) => item.group === "main");
  35. const platformItems = scoreRows.filter((item) => item.group === "platform");
  36. const fiftyItems = scoreRows.filter((item) => item.group === "fifty");
  37. const platformWeight = mainScores.find((item) => displayScoreLabel(item.label) === "平台表现")?.weight ?? null;
  38. const fiftyWeight = mainScores.find((item) => isFifty(item.label))?.weight ?? null;
  39. return (
  40. <AppShell title="视频详情" subtitle="查看这条内容为什么入池、淘汰或需要复看" runId={runId} showBack onRefresh={load}>
  41. {loading ? <LoadingState /> : null}
  42. {error ? <ErrorState message={error} /> : null}
  43. {!loading && !error && !video ? <EmptyState label="没有找到这个视频" /> : null}
  44. {video ? (
  45. <>
  46. <details className="declarations" open>
  47. <summary>
  48. <span className="kw">视频</span> <b>{video.title}</b>
  49. <span className="decl-purpose">作者:{video.author} · 平台:{platformLabel(video.platform)}</span>
  50. </summary>
  51. <div className="decl-body">
  52. <Summary label="互动" value={metricLabel(video)} />
  53. <Summary label="标签" value={video.tags.length ? video.tags.join(" / ") : "无标签"} />
  54. </div>
  55. </details>
  56. <section className="video-detail-grid">
  57. <div className="video-panel">
  58. <h2>内容快照</h2>
  59. <p>{video.title}</p>
  60. <div className="chip-row">
  61. <span className="chip">作者:{video.author}</span>
  62. <span className="chip blue">{metricLabel(video)}</span>
  63. <span className={`chip ${video.mediaStatus === "oss_uploaded" ? "green" : "yellow"}`}>{video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}</span>
  64. </div>
  65. {video.ossUrl ? (
  66. <div className="video-embed">
  67. <iframe
  68. src={video.ossUrl}
  69. title={video.title}
  70. loading="lazy"
  71. allow="autoplay; fullscreen; picture-in-picture"
  72. allowFullScreen
  73. />
  74. </div>
  75. ) : (
  76. <span className="muted">视频暂未保存到 OSS,等待补传后可打开。</span>
  77. )}
  78. </div>
  79. <div className="video-panel">
  80. <div className="score-panel-head">
  81. <h2>评分详情</h2>
  82. <span className="score-decision">判断 <b className={`score-decision-chip ${decisionTone(video.decisionAction)}`}>{actionLabel(video.decisionAction)}</b></span>
  83. </div>
  84. {isTechnical ? (
  85. <TechnicalRetryDetails detail={video.technicalRetryDetail} />
  86. ) : !scoreRows.length ? (
  87. <span className="muted">暂无评分</span>
  88. ) : (
  89. <>
  90. <p className="score-rule-lead">{leadText(mainScores)}。当前结果:{actionLabel(video.decisionAction)}</p>
  91. {totalItem ? (
  92. <div className="score-total-banner">
  93. <div>
  94. <span>总分</span>
  95. <b>{totalItem.scoreLabel}</b>
  96. </div>
  97. <small>最终用于决定入池 / 待复看 / 淘汰</small>
  98. </div>
  99. ) : null}
  100. <div className="score-module-grid">
  101. {mainScores.filter((item) => displayScoreLabel(item.label) !== "总分").map((item) => (
  102. <MainCard key={item.label} item={item} />
  103. ))}
  104. </div>
  105. {platformItems.length ? (
  106. <div className="score-rule-card">
  107. <h3>平台表现怎么算</h3>
  108. <p>平台表现占总分 {pct(platformWeight)}。{platformLabel(video.platform)}按下列指标加权合成,再折算进总分。</p>
  109. <div className="score-breakdown-list">
  110. {platformItems.map((item) => (
  111. <div className="score-breakdown-row" key={item.label}>
  112. <span>{displayScoreLabel(item.label)}</span>
  113. <b>{item.scoreLabel}{item.weight != null ? ` × ${pct(item.weight)}` : ""}</b>
  114. {item.detail ? <small className="score-breakdown-raw">{item.detail}</small> : null}
  115. {breakdownToTotal(item.weight, platformWeight) ? (
  116. <small>{breakdownToTotal(item.weight, platformWeight)}</small>
  117. ) : null}
  118. </div>
  119. ))}
  120. </div>
  121. </div>
  122. ) : null}
  123. {fiftyItems.length ? (
  124. <div className="score-rule-card score-rule-fifty">
  125. <h3>50+ 老年受众怎么算(来自热点宝)</h3>
  126. <p>占总分 {pct(fiftyWeight)}。看作者粉丝里 50 岁以上的 TGI 偏好和占比。</p>
  127. <div className="score-breakdown-list">
  128. {fiftyItems.map((item) => (
  129. <div className="score-breakdown-row" key={item.label}>
  130. <span>{item.label}</span>
  131. <b>{item.scoreLabel}{item.weight != null ? ` × ${pct(item.weight)}` : ""}</b>
  132. {item.detail ? <small>{item.detail}</small> : null}
  133. </div>
  134. ))}
  135. </div>
  136. </div>
  137. ) : null}
  138. </>
  139. )}
  140. </div>
  141. </section>
  142. </>
  143. ) : null}
  144. </AppShell>
  145. );
  146. }
  147. function MainCard({ item }: { item: ScoreItem }) {
  148. const label = displayScoreLabel(item.label);
  149. const muted = Boolean(item.status) && item.status !== "ok";
  150. return (
  151. <div className={`score-module ${scoreModuleTone(item.label)}${muted ? " score-muted" : ""}`}>
  152. <span>{label}</span>
  153. <b>{item.score == null ? item.scoreLabel : `${item.scoreLabel}${item.weight != null ? ` × ${pct(item.weight)}` : ""}`}</b>
  154. {item.weight != null ? <em>占总分 {pct(item.weight)}{muted ? "(未计入)" : ""}</em> : null}
  155. {item.detail ? <small>{item.detail}</small> : null}
  156. </div>
  157. );
  158. }
  159. function isFifty(label: string): boolean {
  160. return label === "50+老年受众" || displayScoreLabel(label) === "50+老年受众";
  161. }
  162. function leadText(mainScores: ScoreItem[]): string {
  163. const parts = mainScores
  164. .filter((item) => displayScoreLabel(item.label) !== "总分")
  165. .map((item) => `${displayScoreLabel(item.label)} ${pct(item.weight)}`.trim());
  166. return `本条采用 V4 评分:总分 = ${parts.join(" + ")}`;
  167. }
  168. function pct(weight?: number | null): string {
  169. if (weight == null || Number.isNaN(weight)) return "";
  170. return `${Math.round(weight * 100)}%`;
  171. }
  172. function breakdownToTotal(componentWeight?: number | null, platformWeight?: number | null): string {
  173. if (componentWeight == null || platformWeight == null) return "";
  174. return `占平台 ${pct(componentWeight)} → 折算总分 ${Math.round(componentWeight * platformWeight * 100)}%`;
  175. }
  176. function scoreModuleTone(label: string): string {
  177. const displayLabel = displayScoreLabel(label);
  178. if (displayLabel === "总分") return "score-total";
  179. if (displayLabel === "是否契合需求") return "score-demand";
  180. if (displayLabel === "平台表现") return "score-platform";
  181. if (isFifty(label)) return "score-fifty";
  182. return "score-other";
  183. }
  184. function decisionTone(action: string): string {
  185. if (action === "ADD_TO_CONTENT_POOL") return "is-pool";
  186. if (action === "KEEP_CONTENT_FOR_REVIEW") return "is-review";
  187. if (action === "REJECT_CONTENT") return "is-reject";
  188. return "is-tech";
  189. }
  190. function Summary({ label, value }: { label: string; value: string }) {
  191. return (
  192. <div className="decl-section">
  193. <div className="decl-label">{label}</div>
  194. <div className="decl-row">{value}</div>
  195. </div>
  196. );
  197. }