| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- "use client";
- import { useCallback, useEffect, useMemo, useState } from "react";
- import { AppShell } from "@/components/AppShell";
- import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
- import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
- import { getFlowLedgerVideo } from "@/lib/api/client";
- import type { FlowLedgerVideoResponse } from "@/lib/api/types";
- import { videoFromApi } from "@/lib/flow-ledger/build";
- import { actionLabel, displayScoreLabel, mediaStatusLabel, metricLabel, platformLabel, scoreItemRows } from "@/lib/flow-ledger/business";
- import type { ScoreItem, VideoRef } from "@/lib/flow-ledger/types";
- export function VideoDetailPage({ runId, contentId }: { runId: string; contentId: string }) {
- const [data, setData] = useState<FlowLedgerVideoResponse | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const debug = new URLSearchParams(window.location.search).get("debug") === "1";
- setData(await getFlowLedgerVideo(runId, contentId, debug));
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setLoading(false);
- }
- }, [contentId, runId]);
- useEffect(() => {
- void load();
- }, [load]);
- const video = useMemo(() => (data?.video ? videoFromApi(data.video) : undefined), [data]);
- const scoreRows = useMemo(() => scoreItemRows(video?.scoreItems || []), [video]);
- const isTechnical = video?.decisionAction === "TECHNICAL_RETRY_REQUIRED";
- const totalItem = scoreRows.find((item) => displayScoreLabel(item.label) === "总分");
- const mainScores = scoreRows.filter((item) => item.group === "main");
- const platformItems = scoreRows.filter((item) => item.group === "platform");
- const fiftyItems = scoreRows.filter((item) => item.group === "fifty");
- const platformWeight = mainScores.find((item) => displayScoreLabel(item.label) === "平台表现")?.weight ?? null;
- const fiftyWeight = mainScores.find((item) => isFifty(item.label))?.weight ?? null;
- return (
- <AppShell title="视频详情" subtitle="查看这条内容为什么入池、淘汰或需要复看" runId={runId} showBack onRefresh={load}>
- {loading ? <LoadingState /> : null}
- {error ? <ErrorState message={error} /> : null}
- {!loading && !error && !video ? <EmptyState label="没有找到这个视频" /> : null}
- {video ? (
- <>
- <details className="declarations" open>
- <summary>
- <span className="kw">视频</span> <b>{video.title}</b>
- <span className="decl-purpose">作者:{video.author} · 平台:{platformLabel(video.platform)}</span>
- </summary>
- <div className="decl-body">
- <Summary label="互动" value={metricLabel(video)} />
- <Summary label="标签" value={video.tags.length ? video.tags.join(" / ") : "无标签"} />
- </div>
- </details>
- <section className="video-detail-grid">
- <div className="video-panel">
- <h2>内容快照</h2>
- <p>{video.title}</p>
- <div className="chip-row">
- <span className="chip">作者:{video.author}</span>
- <span className="chip blue">{metricLabel(video)}</span>
- <span className={`chip ${video.mediaStatus === "oss_uploaded" ? "green" : "yellow"}`}>{video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}</span>
- </div>
- {video.ossUrl ? (
- <div className="video-embed">
- <iframe
- src={video.ossUrl}
- title={video.title}
- loading="lazy"
- allow="autoplay; fullscreen; picture-in-picture"
- allowFullScreen
- />
- </div>
- ) : (
- <span className="muted">视频暂未保存到 OSS,等待补传后可打开。</span>
- )}
- </div>
- <div className="video-panel">
- <div className="score-panel-head">
- <h2>评分详情</h2>
- <span className="score-decision">判断 <b className={`score-decision-chip ${decisionTone(video.decisionAction)}`}>{actionLabel(video.decisionAction)}</b></span>
- </div>
- {isTechnical ? (
- <TechnicalRetryDetails detail={video.technicalRetryDetail} />
- ) : !scoreRows.length ? (
- <span className="muted">暂无评分</span>
- ) : (
- <>
- <p className="score-rule-lead">{leadText(mainScores)}。当前结果:{actionLabel(video.decisionAction)}</p>
- {totalItem ? (
- <div className="score-total-banner">
- <div>
- <span>总分</span>
- <b>{totalItem.scoreLabel}</b>
- </div>
- <small>最终用于决定入池 / 待复看 / 淘汰</small>
- </div>
- ) : null}
- <div className="score-module-grid">
- {mainScores.filter((item) => displayScoreLabel(item.label) !== "总分").map((item) => (
- <MainCard key={item.label} item={item} />
- ))}
- </div>
- {platformItems.length ? (
- <div className="score-rule-card">
- <h3>平台表现怎么算</h3>
- <p>平台表现占总分 {pct(platformWeight)}。{platformLabel(video.platform)}按下列指标加权合成,再折算进总分。</p>
- <div className="score-breakdown-list">
- {platformItems.map((item) => (
- <div className="score-breakdown-row" key={item.label}>
- <span>{displayScoreLabel(item.label)}</span>
- <b>{item.scoreLabel}{item.weight != null ? ` × ${pct(item.weight)}` : ""}</b>
- {item.detail ? <small className="score-breakdown-raw">{item.detail}</small> : null}
- {breakdownToTotal(item.weight, platformWeight) ? (
- <small>{breakdownToTotal(item.weight, platformWeight)}</small>
- ) : null}
- </div>
- ))}
- </div>
- </div>
- ) : null}
- {fiftyItems.length ? (
- <div className="score-rule-card score-rule-fifty">
- <h3>50+ 老年受众怎么算(来自热点宝)</h3>
- <p>占总分 {pct(fiftyWeight)}。看作者粉丝里 50 岁以上的 TGI 偏好和占比。</p>
- <div className="score-breakdown-list">
- {fiftyItems.map((item) => (
- <div className="score-breakdown-row" key={item.label}>
- <span>{item.label}</span>
- <b>{item.scoreLabel}{item.weight != null ? ` × ${pct(item.weight)}` : ""}</b>
- {item.detail ? <small>{item.detail}</small> : null}
- </div>
- ))}
- </div>
- </div>
- ) : null}
- </>
- )}
- </div>
- </section>
- </>
- ) : null}
- </AppShell>
- );
- }
- function MainCard({ item }: { item: ScoreItem }) {
- const label = displayScoreLabel(item.label);
- const muted = Boolean(item.status) && item.status !== "ok";
- return (
- <div className={`score-module ${scoreModuleTone(item.label)}${muted ? " score-muted" : ""}`}>
- <span>{label}</span>
- <b>{item.score == null ? item.scoreLabel : `${item.scoreLabel}${item.weight != null ? ` × ${pct(item.weight)}` : ""}`}</b>
- {item.weight != null ? <em>占总分 {pct(item.weight)}{muted ? "(未计入)" : ""}</em> : null}
- {item.detail ? <small>{item.detail}</small> : null}
- </div>
- );
- }
- function isFifty(label: string): boolean {
- return label === "50+老年受众" || displayScoreLabel(label) === "50+老年受众";
- }
- function leadText(mainScores: ScoreItem[]): string {
- const parts = mainScores
- .filter((item) => displayScoreLabel(item.label) !== "总分")
- .map((item) => `${displayScoreLabel(item.label)} ${pct(item.weight)}`.trim());
- return `本条采用 V4 评分:总分 = ${parts.join(" + ")}`;
- }
- function pct(weight?: number | null): string {
- if (weight == null || Number.isNaN(weight)) return "";
- return `${Math.round(weight * 100)}%`;
- }
- function breakdownToTotal(componentWeight?: number | null, platformWeight?: number | null): string {
- if (componentWeight == null || platformWeight == null) return "";
- return `占平台 ${pct(componentWeight)} → 折算总分 ${Math.round(componentWeight * platformWeight * 100)}%`;
- }
- function scoreModuleTone(label: string): string {
- const displayLabel = displayScoreLabel(label);
- if (displayLabel === "总分") return "score-total";
- if (displayLabel === "是否契合需求") return "score-demand";
- if (displayLabel === "平台表现") return "score-platform";
- if (isFifty(label)) return "score-fifty";
- return "score-other";
- }
- function decisionTone(action: string): string {
- if (action === "ADD_TO_CONTENT_POOL") return "is-pool";
- if (action === "KEEP_CONTENT_FOR_REVIEW") return "is-review";
- if (action === "REJECT_CONTENT") return "is-reject";
- return "is-tech";
- }
- function Summary({ label, value }: { label: string; value: string }) {
- return (
- <div className="decl-section">
- <div className="decl-label">{label}</div>
- <div className="decl-row">{value}</div>
- </div>
- );
- }
|