| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- "use client";
- import { useMemo, useState } from "react";
- import Link from "next/link";
- import { ChevronRight, GitBranch, Info, Network } from "lucide-react";
- import { AppShell } from "@/components/AppShell";
- import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
- import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
- import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
- import {
- assetDetailText,
- assetSummaryText,
- compactScoreItemsList,
- compactScoreItemsText,
- displayScoreLabel,
- metricLabel,
- platformLabel,
- ruleHeadline,
- sourceEvidence,
- sourceLabel,
- type BusinessSection
- } from "@/lib/flow-ledger/business";
- import type { DemandSummary, FlowLedgerRow, VideoRef } from "@/lib/flow-ledger/types";
- import { useFlowLedger } from "./useFlowLedger";
- export function LedgerPage({ runId }: { runId: string }) {
- const { ledger, loading, error, reload } = useFlowLedger(runId);
- const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
- const rows = ledger?.rows || [];
- // 表头「打分规则」按钮用:取维度最全(含 query相关性+平台+抖音适老性)且带落库门槛的视频作样本,
- // 避免取到退化视频(query=None/走硬门无门槛)导致规则少项或阈值回退默认。
- const sampleScore = useMemo(() => {
- let best: { row: FlowLedgerRow; video: VideoRef } | null = null;
- let bestRank = 0;
- for (const row of rows) {
- for (const video of row.firstRoundVideos) {
- const mainCount = video.scoreItems.filter((s) => s.group === "main").length;
- const rank = mainCount + (video.scoreThresholds ? 0.5 : 0);
- if (rank > bestRank) {
- best = { row, video };
- bestRank = rank;
- }
- }
- }
- return best;
- }, [rows]);
- return (
- <AppShell
- title="本次流程摘要"
- titleExtra={ledger ? <HeaderSummaryChips rows={rows} summary={ledger.summary} dataOriginLabel={ledger.dataOriginLabel} /> : null}
- showBack
- backHref="/runs"
- onRefresh={reload}
- >
- {loading ? <LoadingState /> : null}
- {error ? <ErrorState message={error} /> : null}
- {!loading && !error && !rows.length ? <EmptyState label="没有可展示的搜索流程记录" /> : null}
- {ledger ? (
- <>
- <QueryFailureNotice summary={ledger.summary} />
- <div className="ledger-layout demand-collapsed">
- <DemandPanel
- demand={ledger.demandSummary}
- onOpen={() => setDrawer(demandDrawer(ledger.demandSummary))}
- />
- <section className="table-wrap">
- <table className="ledger-table">
- <thead>
- <tr>
- <th className="group-source sticky-source">需求 -> query</th>
- <th className="group-videos">query -> 渠道</th>
- <th className="group-rules">
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
- <span>渠道 -> 判断</span>
- {sampleScore ? (
- <button className="mini-button" style={{ fontWeight: 400 }} type="button" onClick={() => setDrawer(scoreRuleDrawer(sampleScore.row, sampleScore.video, true))}>
- <Info size={13} />
- 打分规则
- </button>
- ) : null}
- </div>
- </th>
- <th className="group-walk">
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
- <span>判断 -> 游走</span>
- <Link className="mini-button" style={{ fontWeight: 400 }} href={`/runs/${encodeURIComponent(runId)}/walk`}>
- <Network size={13} />
- 全run游走图
- </Link>
- </div>
- </th>
- <th className="group-assets">最终收获</th>
- </tr>
- </thead>
- <tbody>
- {rows.map((row) => (
- <QueryGroupRows key={row.id} runId={runId} row={row} />
- ))}
- <DeducedSourceRows rows={rows} demand={ledger.demandSummary} />
- </tbody>
- </table>
- </section>
- </div>
- <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
- </>
- ) : null}
- </AppShell>
- );
- }
- function QueryFailureNotice({ summary }: { summary: Record<string, unknown> }) {
- const count = Number(summary.query_failure_count || 0);
- if (!count) return null;
- const examples = Array.isArray(summary.query_failure_examples)
- ? summary.query_failure_examples
- .map((item) => (typeof item === "object" && item ? String((item as { search_query?: unknown }).search_query || "") : ""))
- .filter(Boolean)
- .slice(0, 3)
- : [];
- return (
- <section className="source-block open">
- <div className="source-summary">
- 已完成;有 {count} 条搜索词失败{examples.length ? `:${examples.join("、")}` : ""}。
- </div>
- </section>
- );
- }
- function DemandPanel({ demand, onOpen }: { demand?: DemandSummary; onOpen: () => void }) {
- // 默认缩进成左侧竖条;点击弹出原始需求抽屉。
- return (
- <aside className="demand-side-panel collapsed">
- <div className="demand-panel-head">
- <button className="icon-button demand-collapse-button" type="button" onClick={onOpen} aria-label="查看原始需求">
- <ChevronRight size={16} />
- </button>
- <button className="demand-rail-label" type="button" onClick={onOpen}>
- 原始需求
- </button>
- </div>
- </aside>
- );
- }
- function demandDrawer(demand?: DemandSummary): BusinessDrawerState {
- if (!demand) {
- return { title: "原始需求", sections: [{ label: "原始需求", value: "当前运行没有写入原始需求单摘要", tone: "neutral" }] };
- }
- const sections: BusinessSection[] = [
- { label: "需求", value: demand.name || "需求待确认", tone: "info" },
- ];
- if (demand.id) sections.push({ label: "需求单", value: String(demand.id), tone: "neutral" });
- if (demand.description) sections.push({ label: "需求描述", value: demand.description, tone: "neutral" });
- if (demand.seedTerms.length) sections.push({ label: "Pattern 词", items: demand.seedTerms, tone: "neutral" });
- if (demand.reason) sections.push({ label: "形成原因", value: demand.reason, tone: "neutral" });
- const meta = demandMetaItems(demand);
- if (meta.length) sections.push({ label: "来源信息", items: meta, tone: "neutral" });
- if (demand.categoryPaths.length) sections.push({ label: "分类树路径", items: demand.categoryPaths, tone: "neutral" });
- return { title: "原始需求", sections };
- }
- function demandMetaItems(demand?: DemandSummary): string[] {
- if (!demand) return [];
- return [
- demand.id ? `需求单:${demand.id}` : "",
- demand.source ? `来源:${demand.source}` : "",
- demand.sourcePostId ? `来源帖子:${demand.sourcePostId}` : "",
- demand.itemsetIds.length ? `itemset:${demand.itemsetIds.join(" / ")}` : "",
- demand.support ? `支持度:${demand.support}` : ""
- ].filter(Boolean);
- }
- function HeaderSummaryChips({
- rows,
- summary,
- dataOriginLabel,
- }: {
- rows: FlowLedgerRow[];
- summary: Record<string, unknown>;
- dataOriginLabel: string;
- }) {
- const walkVideo = Number(summary.walk_video_total || 0);
- const walkPooled = Number(summary.walk_pooled_count || 0);
- const walkActions = Number(summary.walk_action_total || 0);
- const walkDepth = Number(summary.walk_max_depth || 0);
- const poolCount = Number(summary.pool_count || 0);
- const reviewCount = Number(summary.review_count || 0);
- const technicalRetryCount = Number(summary.technical_retry_count || 0);
- return (
- <div className="header-summary-chips">
- <span className="chip blue">搜索词 {rows.length}</span>
- <span className="chip green">首轮视频 {rows.reduce((sum, row) => sum + row.firstRoundVideoTotal, 0)}</span>
- <span className="chip yellow">待复看 {reviewCount}</span>
- <span className="chip red">技术问题 {technicalRetryCount}</span>
- <span className="chip pool">入池 {poolCount}</span>
- <span className="chip green">游走到 {walkVideo} 条视频(入池 {walkPooled})</span>
- <span className="chip">游走 {walkActions} 次 · 最深 {walkDepth} 层</span>
- <span className="chip blue">{dataOriginLabel}</span>
- </div>
- );
- }
- function QueryGroupRows({
- runId,
- row
- }: {
- runId: string;
- row: FlowLedgerRow;
- }) {
- const videos: Array<VideoRef | null> = row.firstRoundVideos.length ? row.firstRoundVideos : [null];
- const rowSpan = videos.length;
- return (
- <>
- {videos.map((video, index) => (
- <tr className={`ledger-row ${index === 0 ? "query-start-row" : "query-video-row"}`} key={`${row.id}-${video?.id || "empty"}-${index}`}>
- {index === 0 ? <SourceCell row={row} rowSpan={rowSpan} /> : null}
- <VideoDetailCell runId={runId} video={video} />
- <VideoDecisionCell video={video} />
- <VideoWalkCell runId={runId} video={video} />
- {index === 0 ? <AssetCell row={row} rowSpan={rowSpan} /> : null}
- </tr>
- ))}
- </>
- );
- }
- function SourceCell({ row, rowSpan }: { row: FlowLedgerRow; rowSpan?: number }) {
- const details = row.source.details.length ? row.source.details : [sourceEvidence(row)];
- return (
- <td className="source-cell sticky-source" rowSpan={rowSpan}>
- <div className="cell-stack">
- <span className={`source-kind-badge ${sourceKindClass(row.source.sourceKind)}`}>{sourceLabel(row.source.sourceKind)}</span>
- <strong className="source-query-text">{row.query.text}</strong>
- <span className="query-video-count">首轮搜到 {row.firstRoundVideoTotal} 条视频</span>
- {row.walkSummary.expansionCount ? (
- <span className="query-video-count">游走 {row.walkSummary.expansionCount} 次 · 最深 {row.walkSummary.maxDepth} 层</span>
- ) : null}
- <div className="source-detail-list">
- {details.map((item) => (
- <span className="muted" key={item}>{item}</span>
- ))}
- </div>
- </div>
- </td>
- );
- }
- function VideoDetailCell({ runId, video }: { runId: string; video: VideoRef | null }) {
- if (!video) {
- return (
- <td className="videos-cell video-detail-cell">
- <span className="muted">这个 query 没有搜到可展示的视频</span>
- </td>
- );
- }
- return (
- <td className="videos-cell video-detail-cell">
- <Link className="video-line-card" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.contentDiscoveryId || video.id)}`}>
- <span className="video-thumb small">{platformLabel(video.platform)}</span>
- <span className="video-line-main">
- <b>{video.title}</b>
- <small>{video.author} · {metricLabel(video)}</small>
- {video.tags.length ? (
- <span className="tag-row">
- {video.tags.slice(0, 4).map((tag) => <em key={tag}>{tag}</em>)}
- </span>
- ) : null}
- </span>
- </Link>
- </td>
- );
- }
- function sourceKindClass(kind: string): string {
- if (kind === "seed_term") return "seed";
- if (kind === "piaoquan_topic_point" || kind === "query_seed_point") return "piaoquan";
- if (kind === "category_leaf_element") return "category";
- return "unknown";
- }
- // CFA 取词时同一个词只搜一次:分类叶子来源的某个词若与前面某来源的词完全相同,就被去重丢弃、不入库,
- // 前端看不到。这里据 demand 的 category_leaf_terms 比对首轮 query,把"被去重的分类词"补成占位行,
- // 并讲清它和前面哪条来源的哪个搜索词一模一样。
- function DeducedSourceRows({ rows, demand }: { rows: FlowLedgerRow[]; demand?: DemandSummary }) {
- const categoryTerms = demand?.categoryLeafTerms || [];
- const deduped = categoryTerms
- .map((term) => {
- // 该分类词已真生成了分类来源 query → 正常显示,不补占位。
- const asCategory = rows.find((row) => row.query.text === term && row.source.sourceKind === "category_leaf_element");
- if (asCategory) return null;
- // 该词出现在前面某条非分类来源里 → 被去重,补占位并指明与谁重复。
- const match = rows.find((row) => row.query.text === term && row.source.sourceKind !== "category_leaf_element");
- return match ? { term, dupLabel: sourceLabel(match.source.sourceKind) } : null;
- })
- .filter((item): item is { term: string; dupLabel: string } => item !== null);
- if (!deduped.length) return null;
- return (
- <>
- {deduped.map(({ term, dupLabel }) => (
- <tr className="ledger-row deduped-source-row" key={`deduped-${term}`}>
- <td className="source-cell sticky-source">
- <div className="cell-stack">
- <span className={`source-kind-badge ${sourceKindClass("category_leaf_element")}`}>{sourceLabel("category_leaf_element")}</span>
- <span className="muted">(此处留空)</span>
- </div>
- </td>
- <td className="deduped-source-note" colSpan={4}>
- <strong style={{ fontSize: "1.05rem", fontWeight: 700, lineHeight: 1.6 }}>
- 「{sourceLabel("category_leaf_element")}」来源的搜索词「{term}」,与前面「{dupLabel}」来源的搜索词「{term}」完全相同(同词只搜一次),所以此处不单独显示。
- </strong>
- </td>
- </tr>
- ))}
- </>
- );
- }
- function VideoDecisionCell({ video }: { video: VideoRef | null }) {
- if (!video) {
- return (
- <td className="rules-cell video-decision-cell">
- <span className="muted">没有视频进入判断</span>
- </td>
- );
- }
- return (
- <td className="rules-cell video-decision-cell">
- <div className="cell-stack">
- <span className={`chip ${decisionChipClass(video.decisionAction)}`}>{video.decisionLabel}</span>
- {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? (
- <span className="muted">{technicalRetryShortText(video)}</span>
- ) : null}
- {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
- <div className="score-lines">
- {compactScoreItemsList(video.scoreItems).map((line) => (
- <strong key={line}>{line}</strong>
- )) }
- {compactScoreItemsList(video.scoreItems).length ? null : <strong>暂无评分</strong>}
- </div>
- </div>
- </td>
- );
- }
- function decisionChipClass(action: string): string {
- if (action === "ADD_TO_CONTENT_POOL") return "green";
- if (action === "KEEP_CONTENT_FOR_REVIEW") return "yellow";
- if (action === "TECHNICAL_RETRY_REQUIRED") return "red";
- if (action === "REJECT_CONTENT") return "red";
- return "";
- }
- function scoreRuleDrawer(row: FlowLedgerRow, video?: VideoRef | null, general = false): BusinessDrawerState {
- // 数据驱动:权重读 video.scoreItems(group main/platform/fifty),阈值读 video.scoreThresholds。
- // 各平台不同会自动展示不同(抖音 35/35/30+50+、入池65;快手/视频号 50/50、入池70)。
- // general=true:表头按钮,只讲规则、不带「当前视频结果」那一节。
- const items = video?.scoreItems || [];
- const mainItems = items.filter((it) => it.group === "main" && displayScoreLabel(it.label) !== "总分");
- const platformItems = items.filter((it) => it.group === "platform");
- const fiftyItems = items.filter((it) => it.group === "fifty");
- const platformWeight = mainItems.find((it) => displayScoreLabel(it.label) === "平台表现")?.weight ?? null;
- const pct = (w?: number | null) => (w == null || Number.isNaN(w) ? "" : `${Math.round(w * 100)}%`);
- const th = video?.scoreThresholds || null;
- const poolTotal = th?.pool_total ?? 70;
- const reviewTotal = th?.review_total ?? 55;
- const walkQuery = th?.walk_query ?? 70;
- const walkPlatform = th?.walk_platform ?? 65;
- const walkTotal = th?.walk_total ?? 70;
- const current = video ? compactScoreItemsText(video.scoreItems) : "";
- const sections: BusinessSection[] = general
- ? []
- : [
- {
- label: video ? "当前视频结果" : "本组结果",
- value: video
- ? `${video.decisionLabel}${current ? `,${current}` : ""}。`
- : `${ruleHeadline(row)}。入池 ${row.ruleSummary.passCount} 条,待复看 ${row.ruleSummary.reviewCount} 条,技术问题 ${row.ruleSummary.technicalRetryCount} 条,淘汰 ${row.ruleSummary.rejectCount} 条。`,
- tone: video?.decisionAction === "ADD_TO_CONTENT_POOL" ? "good" : video?.decisionAction === "REJECT_CONTENT" || video?.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? "bad" : row.ruleSummary.reviewCount ? "warn" : "neutral"
- }
- ];
- if (mainItems.length) {
- sections.push({
- label: "总分怎么来",
- value: `总分 = ${mainItems.map((it) => `${displayScoreLabel(it.label)} ${pct(it.weight)}`).join(" + ")}。`,
- tone: "info"
- });
- sections.push({
- label: "评分项",
- items: mainItems.map((it) => `${displayScoreLabel(it.label)}(${pct(it.weight)}):${it.detail || ""}`),
- tone: "neutral"
- });
- } else {
- sections.push({ label: "总分怎么来", value: "总分 = 是否契合需求 50% + 平台表现 50%。", tone: "info" });
- }
- if (platformItems.length) {
- sections.push({
- label: "平台表现的具体占比",
- items: platformItems.map((it) => {
- const within = pct(it.weight);
- const toTotal = it.weight != null && platformWeight != null ? `,折算到总分 ${Math.round(it.weight * platformWeight * 100)}%` : "";
- return `${displayScoreLabel(it.label)}:占平台表现 ${within}${toTotal}。`;
- }),
- tone: "info"
- });
- }
- if (fiftyItems.length) {
- sections.push({
- label: "50+老年怎么算",
- items: fiftyItems.map((it) => `${it.label}(占 50+ ${pct(it.weight)}):${it.detail || ""}`),
- tone: "neutral"
- });
- }
- sections.push({
- label: "怎么判定",
- items: [
- `入池:总分达到 ${poolTotal} 分及以上,内容可以进入后续使用。`,
- `待复看:${reviewTotal}-${poolTotal} 分,系统已完成判断但还需人工确认。`,
- "技术问题:视频下载、压缩或模型接口失败,系统没有完成内容判断。",
- `淘汰:低于 ${reviewTotal} 分,或内容明显不契合本次需求。`
- ],
- tone: "neutral"
- });
- sections.push({
- label: "和继续游走的关系",
- value: `入池不等于一定继续游走。继续游走要同时满足:相关性 ≥ ${walkQuery}、平台表现 ≥ ${walkPlatform}、总分 ≥ ${walkTotal}。`,
- tone: "warn"
- });
- return { title: "渠道判断打分规则", sections };
- }
- function VideoWalkCell({ runId, video }: { runId: string; video: VideoRef | null }) {
- const allowWalk = Boolean(video?.decision?.allow_walk);
- const status = videoWalkStatus(video, allowWalk);
- // 只有真正向外走过(success 扩展)且未被淘汰的视频,才给「看扩展过程」入口(单视频子树)。
- const canExpand = Boolean(video) && video!.walkOutCount > 0 && video!.decisionAction !== "REJECT_CONTENT";
- return (
- <td className="walk-cell">
- <div className="cell-stack">
- <strong>{status.title}</strong>
- <span className="muted">{status.detail}</span>
- {canExpand ? (
- <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video!.contentDiscoveryId || video!.id)}/walk`}>
- <GitBranch size={13} />
- 看扩展过程
- </Link>
- ) : null}
- </div>
- </td>
- );
- }
- function videoWalkStatus(video: VideoRef | null, allowWalk: boolean): { title: string; detail: string } {
- if (!video) return { title: "没有进入游走", detail: "没有视频可继续扩展" };
- if (allowWalk) return { title: "通过游走门槛", detail: "可从标签、作者或翻页继续找" };
- if (video.decisionAction === "ADD_TO_CONTENT_POOL") return { title: "入池但未游走", detail: "本条可沉淀,未继续向外扩展" };
- if (video.decisionAction === "KEEP_CONTENT_FOR_REVIEW") return { title: "未游走:待复看停止", detail: "需要人工确认后再决定是否继续" };
- if (video.decisionAction === "TECHNICAL_RETRY_REQUIRED") {
- const detail = video.technicalRetryDetail;
- return {
- title: `未游走:${detail?.stageLabel || "技术问题"}`,
- detail: technicalRetryShortText(video)
- };
- }
- if (video.decisionAction === "REJECT_CONTENT") return { title: "未游走:淘汰停止", detail: "不再继续带回新内容" };
- return { title: "未进入游走", detail: "当前没有继续扩展记录" };
- }
- function technicalRetryShortText(video: VideoRef): string {
- const detail = video.technicalRetryDetail;
- if (!detail) return video.decisionReasonLabel || "系统需要重试后再判断";
- const stage = detail.stageLabel || "技术问题";
- const reason = detail.briefReason || detail.failureLabel || detail.failureType || "系统需要重试后再判断";
- const seconds = technicalRetrySeconds(detail);
- return `${stage}:${reason}${seconds ? ` · ${seconds}` : ""}`;
- }
- function technicalRetrySeconds(detail: NonNullable<VideoRef["technicalRetryDetail"]>): string {
- const values = [
- detail.timings.download_seconds,
- detail.timings.ffmpeg_seconds,
- detail.timings.gemini_seconds
- ].filter((value): value is number => typeof value === "number" && !Number.isNaN(value));
- if (!values.length) return "";
- const total = values.reduce((sum, value) => sum + value, 0);
- return `${Number(total.toFixed(1))}s`;
- }
- function AssetCell({ row, rowSpan }: { row: FlowLedgerRow; rowSpan?: number }) {
- return (
- <td className="asset-cell" rowSpan={rowSpan}>
- <div className="cell-stack">
- <strong>{assetSummaryText(row)}</strong>
- <span className="muted">{assetDetailText(row)}</span>
- </div>
- </td>
- );
- }
|