LedgerPage.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. "use client";
  2. import { useMemo, useState } from "react";
  3. import Link from "next/link";
  4. import { ChevronRight, GitBranch, Info, Network } from "lucide-react";
  5. import { AppShell } from "@/components/AppShell";
  6. import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
  7. import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
  8. import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
  9. import {
  10. assetDetailText,
  11. assetSummaryText,
  12. compactScoreItemsList,
  13. compactScoreItemsText,
  14. displayScoreLabel,
  15. metricLabel,
  16. platformLabel,
  17. ruleHeadline,
  18. sourceEvidence,
  19. sourceLabel,
  20. type BusinessSection
  21. } from "@/lib/flow-ledger/business";
  22. import type { DemandSummary, FlowLedgerRow, VideoRef } from "@/lib/flow-ledger/types";
  23. import { useFlowLedger } from "./useFlowLedger";
  24. export function LedgerPage({ runId }: { runId: string }) {
  25. const { ledger, loading, error, reload } = useFlowLedger(runId);
  26. const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
  27. const rows = ledger?.rows || [];
  28. // 表头「打分规则」按钮用:取维度最全(含 query相关性+平台+抖音适老性)且带落库门槛的视频作样本,
  29. // 避免取到退化视频(query=None/走硬门无门槛)导致规则少项或阈值回退默认。
  30. const sampleScore = useMemo(() => {
  31. let best: { row: FlowLedgerRow; video: VideoRef } | null = null;
  32. let bestRank = 0;
  33. for (const row of rows) {
  34. for (const video of row.firstRoundVideos) {
  35. const mainCount = video.scoreItems.filter((s) => s.group === "main").length;
  36. const rank = mainCount + (video.scoreThresholds ? 0.5 : 0);
  37. if (rank > bestRank) {
  38. best = { row, video };
  39. bestRank = rank;
  40. }
  41. }
  42. }
  43. return best;
  44. }, [rows]);
  45. return (
  46. <AppShell
  47. title="本次流程摘要"
  48. titleExtra={ledger ? <HeaderSummaryChips rows={rows} summary={ledger.summary} dataOriginLabel={ledger.dataOriginLabel} /> : null}
  49. showBack
  50. backHref="/runs"
  51. onRefresh={reload}
  52. >
  53. {loading ? <LoadingState /> : null}
  54. {error ? <ErrorState message={error} /> : null}
  55. {!loading && !error && !rows.length ? <EmptyState label="没有可展示的搜索流程记录" /> : null}
  56. {ledger ? (
  57. <>
  58. <QueryFailureNotice summary={ledger.summary} />
  59. <div className="ledger-layout demand-collapsed">
  60. <DemandPanel
  61. demand={ledger.demandSummary}
  62. onOpen={() => setDrawer(demandDrawer(ledger.demandSummary))}
  63. />
  64. <section className="table-wrap">
  65. <table className="ledger-table">
  66. <thead>
  67. <tr>
  68. <th className="group-source sticky-source">需求 -&gt; query</th>
  69. <th className="group-videos">query -&gt; 渠道</th>
  70. <th className="group-rules">
  71. <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
  72. <span>渠道 -&gt; 判断</span>
  73. {sampleScore ? (
  74. <button className="mini-button" style={{ fontWeight: 400 }} type="button" onClick={() => setDrawer(scoreRuleDrawer(sampleScore.row, sampleScore.video, true))}>
  75. <Info size={13} />
  76. 打分规则
  77. </button>
  78. ) : null}
  79. </div>
  80. </th>
  81. <th className="group-walk">
  82. <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
  83. <span>判断 -&gt; 游走</span>
  84. <Link className="mini-button" style={{ fontWeight: 400 }} href={`/runs/${encodeURIComponent(runId)}/walk`}>
  85. <Network size={13} />
  86. 全run游走图
  87. </Link>
  88. </div>
  89. </th>
  90. <th className="group-assets">最终收获</th>
  91. </tr>
  92. </thead>
  93. <tbody>
  94. {rows.map((row) => (
  95. <QueryGroupRows key={row.id} runId={runId} row={row} />
  96. ))}
  97. <DeducedSourceRows rows={rows} demand={ledger.demandSummary} />
  98. </tbody>
  99. </table>
  100. </section>
  101. </div>
  102. <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
  103. </>
  104. ) : null}
  105. </AppShell>
  106. );
  107. }
  108. function QueryFailureNotice({ summary }: { summary: Record<string, unknown> }) {
  109. const count = Number(summary.query_failure_count || 0);
  110. if (!count) return null;
  111. const examples = Array.isArray(summary.query_failure_examples)
  112. ? summary.query_failure_examples
  113. .map((item) => (typeof item === "object" && item ? String((item as { search_query?: unknown }).search_query || "") : ""))
  114. .filter(Boolean)
  115. .slice(0, 3)
  116. : [];
  117. return (
  118. <section className="source-block open">
  119. <div className="source-summary">
  120. 已完成;有 {count} 条搜索词失败{examples.length ? `:${examples.join("、")}` : ""}。
  121. </div>
  122. </section>
  123. );
  124. }
  125. function DemandPanel({ demand, onOpen }: { demand?: DemandSummary; onOpen: () => void }) {
  126. // 默认缩进成左侧竖条;点击弹出原始需求抽屉。
  127. return (
  128. <aside className="demand-side-panel collapsed">
  129. <div className="demand-panel-head">
  130. <button className="icon-button demand-collapse-button" type="button" onClick={onOpen} aria-label="查看原始需求">
  131. <ChevronRight size={16} />
  132. </button>
  133. <button className="demand-rail-label" type="button" onClick={onOpen}>
  134. 原始需求
  135. </button>
  136. </div>
  137. </aside>
  138. );
  139. }
  140. function demandDrawer(demand?: DemandSummary): BusinessDrawerState {
  141. if (!demand) {
  142. return { title: "原始需求", sections: [{ label: "原始需求", value: "当前运行没有写入原始需求单摘要", tone: "neutral" }] };
  143. }
  144. const sections: BusinessSection[] = [
  145. { label: "需求", value: demand.name || "需求待确认", tone: "info" },
  146. ];
  147. if (demand.id) sections.push({ label: "需求单", value: String(demand.id), tone: "neutral" });
  148. if (demand.description) sections.push({ label: "需求描述", value: demand.description, tone: "neutral" });
  149. if (demand.seedTerms.length) sections.push({ label: "Pattern 词", items: demand.seedTerms, tone: "neutral" });
  150. if (demand.reason) sections.push({ label: "形成原因", value: demand.reason, tone: "neutral" });
  151. const meta = demandMetaItems(demand);
  152. if (meta.length) sections.push({ label: "来源信息", items: meta, tone: "neutral" });
  153. if (demand.categoryPaths.length) sections.push({ label: "分类树路径", items: demand.categoryPaths, tone: "neutral" });
  154. return { title: "原始需求", sections };
  155. }
  156. function demandMetaItems(demand?: DemandSummary): string[] {
  157. if (!demand) return [];
  158. return [
  159. demand.id ? `需求单:${demand.id}` : "",
  160. demand.source ? `来源:${demand.source}` : "",
  161. demand.sourcePostId ? `来源帖子:${demand.sourcePostId}` : "",
  162. demand.itemsetIds.length ? `itemset:${demand.itemsetIds.join(" / ")}` : "",
  163. demand.support ? `支持度:${demand.support}` : ""
  164. ].filter(Boolean);
  165. }
  166. function HeaderSummaryChips({
  167. rows,
  168. summary,
  169. dataOriginLabel,
  170. }: {
  171. rows: FlowLedgerRow[];
  172. summary: Record<string, unknown>;
  173. dataOriginLabel: string;
  174. }) {
  175. const walkVideo = Number(summary.walk_video_total || 0);
  176. const walkPooled = Number(summary.walk_pooled_count || 0);
  177. const walkActions = Number(summary.walk_action_total || 0);
  178. const walkDepth = Number(summary.walk_max_depth || 0);
  179. const poolCount = Number(summary.pool_count || 0);
  180. const reviewCount = Number(summary.review_count || 0);
  181. const technicalRetryCount = Number(summary.technical_retry_count || 0);
  182. return (
  183. <div className="header-summary-chips">
  184. <span className="chip blue">搜索词 {rows.length}</span>
  185. <span className="chip green">首轮视频 {rows.reduce((sum, row) => sum + row.firstRoundVideoTotal, 0)}</span>
  186. <span className="chip yellow">待复看 {reviewCount}</span>
  187. <span className="chip red">技术问题 {technicalRetryCount}</span>
  188. <span className="chip pool">入池 {poolCount}</span>
  189. <span className="chip green">游走到 {walkVideo} 条视频(入池 {walkPooled})</span>
  190. <span className="chip">游走 {walkActions} 次 · 最深 {walkDepth} 层</span>
  191. <span className="chip blue">{dataOriginLabel}</span>
  192. </div>
  193. );
  194. }
  195. function QueryGroupRows({
  196. runId,
  197. row
  198. }: {
  199. runId: string;
  200. row: FlowLedgerRow;
  201. }) {
  202. const videos: Array<VideoRef | null> = row.firstRoundVideos.length ? row.firstRoundVideos : [null];
  203. const rowSpan = videos.length;
  204. return (
  205. <>
  206. {videos.map((video, index) => (
  207. <tr className={`ledger-row ${index === 0 ? "query-start-row" : "query-video-row"}`} key={`${row.id}-${video?.id || "empty"}-${index}`}>
  208. {index === 0 ? <SourceCell row={row} rowSpan={rowSpan} /> : null}
  209. <VideoDetailCell runId={runId} video={video} />
  210. <VideoDecisionCell video={video} />
  211. <VideoWalkCell runId={runId} video={video} />
  212. {index === 0 ? <AssetCell row={row} rowSpan={rowSpan} /> : null}
  213. </tr>
  214. ))}
  215. </>
  216. );
  217. }
  218. function SourceCell({ row, rowSpan }: { row: FlowLedgerRow; rowSpan?: number }) {
  219. const details = row.source.details.length ? row.source.details : [sourceEvidence(row)];
  220. return (
  221. <td className="source-cell sticky-source" rowSpan={rowSpan}>
  222. <div className="cell-stack">
  223. <span className={`source-kind-badge ${sourceKindClass(row.source.sourceKind)}`}>{sourceLabel(row.source.sourceKind)}</span>
  224. <strong className="source-query-text">{row.query.text}</strong>
  225. <span className="query-video-count">首轮搜到 {row.firstRoundVideoTotal} 条视频</span>
  226. {row.walkSummary.expansionCount ? (
  227. <span className="query-video-count">游走 {row.walkSummary.expansionCount} 次 · 最深 {row.walkSummary.maxDepth} 层</span>
  228. ) : null}
  229. <div className="source-detail-list">
  230. {details.map((item) => (
  231. <span className="muted" key={item}>{item}</span>
  232. ))}
  233. </div>
  234. </div>
  235. </td>
  236. );
  237. }
  238. function VideoDetailCell({ runId, video }: { runId: string; video: VideoRef | null }) {
  239. if (!video) {
  240. return (
  241. <td className="videos-cell video-detail-cell">
  242. <span className="muted">这个 query 没有搜到可展示的视频</span>
  243. </td>
  244. );
  245. }
  246. return (
  247. <td className="videos-cell video-detail-cell">
  248. <Link className="video-line-card" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.contentDiscoveryId || video.id)}`}>
  249. <span className="video-thumb small">{platformLabel(video.platform)}</span>
  250. <span className="video-line-main">
  251. <b>{video.title}</b>
  252. <small>{video.author} · {metricLabel(video)}</small>
  253. {video.tags.length ? (
  254. <span className="tag-row">
  255. {video.tags.slice(0, 4).map((tag) => <em key={tag}>{tag}</em>)}
  256. </span>
  257. ) : null}
  258. </span>
  259. </Link>
  260. </td>
  261. );
  262. }
  263. function sourceKindClass(kind: string): string {
  264. if (kind === "seed_term") return "seed";
  265. if (kind === "piaoquan_topic_point" || kind === "query_seed_point") return "piaoquan";
  266. if (kind === "category_leaf_element") return "category";
  267. return "unknown";
  268. }
  269. // CFA 取词时同一个词只搜一次:分类叶子来源的某个词若与前面某来源的词完全相同,就被去重丢弃、不入库,
  270. // 前端看不到。这里据 demand 的 category_leaf_terms 比对首轮 query,把"被去重的分类词"补成占位行,
  271. // 并讲清它和前面哪条来源的哪个搜索词一模一样。
  272. function DeducedSourceRows({ rows, demand }: { rows: FlowLedgerRow[]; demand?: DemandSummary }) {
  273. const categoryTerms = demand?.categoryLeafTerms || [];
  274. const deduped = categoryTerms
  275. .map((term) => {
  276. // 该分类词已真生成了分类来源 query → 正常显示,不补占位。
  277. const asCategory = rows.find((row) => row.query.text === term && row.source.sourceKind === "category_leaf_element");
  278. if (asCategory) return null;
  279. // 该词出现在前面某条非分类来源里 → 被去重,补占位并指明与谁重复。
  280. const match = rows.find((row) => row.query.text === term && row.source.sourceKind !== "category_leaf_element");
  281. return match ? { term, dupLabel: sourceLabel(match.source.sourceKind) } : null;
  282. })
  283. .filter((item): item is { term: string; dupLabel: string } => item !== null);
  284. if (!deduped.length) return null;
  285. return (
  286. <>
  287. {deduped.map(({ term, dupLabel }) => (
  288. <tr className="ledger-row deduped-source-row" key={`deduped-${term}`}>
  289. <td className="source-cell sticky-source">
  290. <div className="cell-stack">
  291. <span className={`source-kind-badge ${sourceKindClass("category_leaf_element")}`}>{sourceLabel("category_leaf_element")}</span>
  292. <span className="muted">(此处留空)</span>
  293. </div>
  294. </td>
  295. <td className="deduped-source-note" colSpan={4}>
  296. <strong style={{ fontSize: "1.05rem", fontWeight: 700, lineHeight: 1.6 }}>
  297. 「{sourceLabel("category_leaf_element")}」来源的搜索词「{term}」,与前面「{dupLabel}」来源的搜索词「{term}」完全相同(同词只搜一次),所以此处不单独显示。
  298. </strong>
  299. </td>
  300. </tr>
  301. ))}
  302. </>
  303. );
  304. }
  305. function VideoDecisionCell({ video }: { video: VideoRef | null }) {
  306. if (!video) {
  307. return (
  308. <td className="rules-cell video-decision-cell">
  309. <span className="muted">没有视频进入判断</span>
  310. </td>
  311. );
  312. }
  313. return (
  314. <td className="rules-cell video-decision-cell">
  315. <div className="cell-stack">
  316. <span className={`chip ${decisionChipClass(video.decisionAction)}`}>{video.decisionLabel}</span>
  317. {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? (
  318. <span className="muted">{technicalRetryShortText(video)}</span>
  319. ) : null}
  320. {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
  321. <div className="score-lines">
  322. {compactScoreItemsList(video.scoreItems).map((line) => (
  323. <strong key={line}>{line}</strong>
  324. )) }
  325. {compactScoreItemsList(video.scoreItems).length ? null : <strong>暂无评分</strong>}
  326. </div>
  327. </div>
  328. </td>
  329. );
  330. }
  331. function decisionChipClass(action: string): string {
  332. if (action === "ADD_TO_CONTENT_POOL") return "green";
  333. if (action === "KEEP_CONTENT_FOR_REVIEW") return "yellow";
  334. if (action === "TECHNICAL_RETRY_REQUIRED") return "red";
  335. if (action === "REJECT_CONTENT") return "red";
  336. return "";
  337. }
  338. function scoreRuleDrawer(row: FlowLedgerRow, video?: VideoRef | null, general = false): BusinessDrawerState {
  339. // 数据驱动:权重读 video.scoreItems(group main/platform/fifty),阈值读 video.scoreThresholds。
  340. // 各平台不同会自动展示不同(抖音 35/35/30+50+、入池65;快手/视频号 50/50、入池70)。
  341. // general=true:表头按钮,只讲规则、不带「当前视频结果」那一节。
  342. const items = video?.scoreItems || [];
  343. const mainItems = items.filter((it) => it.group === "main" && displayScoreLabel(it.label) !== "总分");
  344. const platformItems = items.filter((it) => it.group === "platform");
  345. const fiftyItems = items.filter((it) => it.group === "fifty");
  346. const platformWeight = mainItems.find((it) => displayScoreLabel(it.label) === "平台表现")?.weight ?? null;
  347. const pct = (w?: number | null) => (w == null || Number.isNaN(w) ? "" : `${Math.round(w * 100)}%`);
  348. const th = video?.scoreThresholds || null;
  349. const poolTotal = th?.pool_total ?? 70;
  350. const reviewTotal = th?.review_total ?? 55;
  351. const walkQuery = th?.walk_query ?? 70;
  352. const walkPlatform = th?.walk_platform ?? 65;
  353. const walkTotal = th?.walk_total ?? 70;
  354. const current = video ? compactScoreItemsText(video.scoreItems) : "";
  355. const sections: BusinessSection[] = general
  356. ? []
  357. : [
  358. {
  359. label: video ? "当前视频结果" : "本组结果",
  360. value: video
  361. ? `${video.decisionLabel}${current ? `,${current}` : ""}。`
  362. : `${ruleHeadline(row)}。入池 ${row.ruleSummary.passCount} 条,待复看 ${row.ruleSummary.reviewCount} 条,技术问题 ${row.ruleSummary.technicalRetryCount} 条,淘汰 ${row.ruleSummary.rejectCount} 条。`,
  363. tone: video?.decisionAction === "ADD_TO_CONTENT_POOL" ? "good" : video?.decisionAction === "REJECT_CONTENT" || video?.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? "bad" : row.ruleSummary.reviewCount ? "warn" : "neutral"
  364. }
  365. ];
  366. if (mainItems.length) {
  367. sections.push({
  368. label: "总分怎么来",
  369. value: `总分 = ${mainItems.map((it) => `${displayScoreLabel(it.label)} ${pct(it.weight)}`).join(" + ")}。`,
  370. tone: "info"
  371. });
  372. sections.push({
  373. label: "评分项",
  374. items: mainItems.map((it) => `${displayScoreLabel(it.label)}(${pct(it.weight)}):${it.detail || ""}`),
  375. tone: "neutral"
  376. });
  377. } else {
  378. sections.push({ label: "总分怎么来", value: "总分 = 是否契合需求 50% + 平台表现 50%。", tone: "info" });
  379. }
  380. if (platformItems.length) {
  381. sections.push({
  382. label: "平台表现的具体占比",
  383. items: platformItems.map((it) => {
  384. const within = pct(it.weight);
  385. const toTotal = it.weight != null && platformWeight != null ? `,折算到总分 ${Math.round(it.weight * platformWeight * 100)}%` : "";
  386. return `${displayScoreLabel(it.label)}:占平台表现 ${within}${toTotal}。`;
  387. }),
  388. tone: "info"
  389. });
  390. }
  391. if (fiftyItems.length) {
  392. sections.push({
  393. label: "50+老年怎么算",
  394. items: fiftyItems.map((it) => `${it.label}(占 50+ ${pct(it.weight)}):${it.detail || ""}`),
  395. tone: "neutral"
  396. });
  397. }
  398. sections.push({
  399. label: "怎么判定",
  400. items: [
  401. `入池:总分达到 ${poolTotal} 分及以上,内容可以进入后续使用。`,
  402. `待复看:${reviewTotal}-${poolTotal} 分,系统已完成判断但还需人工确认。`,
  403. "技术问题:视频下载、压缩或模型接口失败,系统没有完成内容判断。",
  404. `淘汰:低于 ${reviewTotal} 分,或内容明显不契合本次需求。`
  405. ],
  406. tone: "neutral"
  407. });
  408. sections.push({
  409. label: "和继续游走的关系",
  410. value: `入池不等于一定继续游走。继续游走要同时满足:相关性 ≥ ${walkQuery}、平台表现 ≥ ${walkPlatform}、总分 ≥ ${walkTotal}。`,
  411. tone: "warn"
  412. });
  413. return { title: "渠道判断打分规则", sections };
  414. }
  415. function VideoWalkCell({ runId, video }: { runId: string; video: VideoRef | null }) {
  416. const allowWalk = Boolean(video?.decision?.allow_walk);
  417. const status = videoWalkStatus(video, allowWalk);
  418. // 只有真正向外走过(success 扩展)且未被淘汰的视频,才给「看扩展过程」入口(单视频子树)。
  419. const canExpand = Boolean(video) && video!.walkOutCount > 0 && video!.decisionAction !== "REJECT_CONTENT";
  420. return (
  421. <td className="walk-cell">
  422. <div className="cell-stack">
  423. <strong>{status.title}</strong>
  424. <span className="muted">{status.detail}</span>
  425. {canExpand ? (
  426. <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video!.contentDiscoveryId || video!.id)}/walk`}>
  427. <GitBranch size={13} />
  428. 看扩展过程
  429. </Link>
  430. ) : null}
  431. </div>
  432. </td>
  433. );
  434. }
  435. function videoWalkStatus(video: VideoRef | null, allowWalk: boolean): { title: string; detail: string } {
  436. if (!video) return { title: "没有进入游走", detail: "没有视频可继续扩展" };
  437. if (allowWalk) return { title: "通过游走门槛", detail: "可从标签、作者或翻页继续找" };
  438. if (video.decisionAction === "ADD_TO_CONTENT_POOL") return { title: "入池但未游走", detail: "本条可沉淀,未继续向外扩展" };
  439. if (video.decisionAction === "KEEP_CONTENT_FOR_REVIEW") return { title: "未游走:待复看停止", detail: "需要人工确认后再决定是否继续" };
  440. if (video.decisionAction === "TECHNICAL_RETRY_REQUIRED") {
  441. const detail = video.technicalRetryDetail;
  442. return {
  443. title: `未游走:${detail?.stageLabel || "技术问题"}`,
  444. detail: technicalRetryShortText(video)
  445. };
  446. }
  447. if (video.decisionAction === "REJECT_CONTENT") return { title: "未游走:淘汰停止", detail: "不再继续带回新内容" };
  448. return { title: "未进入游走", detail: "当前没有继续扩展记录" };
  449. }
  450. function technicalRetryShortText(video: VideoRef): string {
  451. const detail = video.technicalRetryDetail;
  452. if (!detail) return video.decisionReasonLabel || "系统需要重试后再判断";
  453. const stage = detail.stageLabel || "技术问题";
  454. const reason = detail.briefReason || detail.failureLabel || detail.failureType || "系统需要重试后再判断";
  455. const seconds = technicalRetrySeconds(detail);
  456. return `${stage}:${reason}${seconds ? ` · ${seconds}` : ""}`;
  457. }
  458. function technicalRetrySeconds(detail: NonNullable<VideoRef["technicalRetryDetail"]>): string {
  459. const values = [
  460. detail.timings.download_seconds,
  461. detail.timings.ffmpeg_seconds,
  462. detail.timings.gemini_seconds
  463. ].filter((value): value is number => typeof value === "number" && !Number.isNaN(value));
  464. if (!values.length) return "";
  465. const total = values.reduce((sum, value) => sum + value, 0);
  466. return `${Number(total.toFixed(1))}s`;
  467. }
  468. function AssetCell({ row, rowSpan }: { row: FlowLedgerRow; rowSpan?: number }) {
  469. return (
  470. <td className="asset-cell" rowSpan={rowSpan}>
  471. <div className="cell-stack">
  472. <strong>{assetSummaryText(row)}</strong>
  473. <span className="muted">{assetDetailText(row)}</span>
  474. </div>
  475. </td>
  476. );
  477. }