|
|
@@ -0,0 +1,213 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+// 内容资产「成品货架」:把本次入池(ADD_TO_CONTENT_POOL)的视频以交付清单形式摆出来。
|
|
|
+// 每张卡 = 可播放 iframe + 来历 + Gemini 判定 + 抖音真实热度 + 打开原帖,
|
|
|
+// 卡底预留「我方平台实测表现」位(入库后回填,与 Gemini 预测对照)。数据全部来自已加载的 content-items,无新增取数。
|
|
|
+import { useState } from "react";
|
|
|
+import { ExternalLink, Lock, MessageCircle, PlayCircle, Share2, ShieldCheck, Star, ThumbsUp } from "lucide-react";
|
|
|
+import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
|
|
|
+import type { ContentItemsResponse } from "@/lib/api/types";
|
|
|
+
|
|
|
+type AnyRec = Record<string, unknown>;
|
|
|
+
|
|
|
+const POOLED = "ADD_TO_CONTENT_POOL";
|
|
|
+
|
|
|
+function decisionAction(it: AnyRec): string {
|
|
|
+ const rd = (it.rule_decision as AnyRec) || {};
|
|
|
+ const pmr = (it.pattern_match_result as AnyRec) || {};
|
|
|
+ return String(rd.decision_action || pmr.decision_action || "");
|
|
|
+}
|
|
|
+
|
|
|
+// 数字 → 人话:≥1万 显示 X.X万,否则千分位。
|
|
|
+function fmtStat(n: unknown): string {
|
|
|
+ const v = Number(n);
|
|
|
+ if (!Number.isFinite(v)) return "—";
|
|
|
+ if (v >= 10000) return `${(v / 10000).toFixed(1)}万`;
|
|
|
+ return v.toLocaleString("en-US");
|
|
|
+}
|
|
|
+
|
|
|
+function num(v: unknown): number | null {
|
|
|
+ const n = Number(v);
|
|
|
+ return Number.isFinite(n) ? n : null;
|
|
|
+}
|
|
|
+
|
|
|
+// 单卡视频:懒加载 iframe(仅抖音有 embed),无 ID 不渲染。
|
|
|
+function AssetMedia({ item }: { item: AnyRec }) {
|
|
|
+ const [on, setOn] = useState(false);
|
|
|
+ const id = String(item.platform_content_id || "");
|
|
|
+ if (!id) return null;
|
|
|
+ const pLabel = platformLabel(item.platform);
|
|
|
+ const playerUrl = embedPlayerUrl(item);
|
|
|
+ return (
|
|
|
+ <div className="asset-media">
|
|
|
+ {!playerUrl ? (
|
|
|
+ <div className="asset-media-frame asset-media-empty muted">{pLabel}无公开嵌入播放器</div>
|
|
|
+ ) : on ? (
|
|
|
+ <iframe
|
|
|
+ className="asset-media-frame"
|
|
|
+ src={playerUrl}
|
|
|
+ title={`${pLabel}视频 ${id}`}
|
|
|
+ loading="lazy"
|
|
|
+ allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
|
|
+ allowFullScreen
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <button type="button" className="asset-media-frame asset-media-play" onClick={() => setOn(true)}>
|
|
|
+ <PlayCircle size={30} />
|
|
|
+ <span>播放</span>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function StatPill({ icon, value }: { icon: React.ReactNode; value: string }) {
|
|
|
+ return (
|
|
|
+ <span className="asset-stat">
|
|
|
+ {icon}
|
|
|
+ {value}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function AssetCard({ item }: { item: AnyRec }) {
|
|
|
+ const pmr = (item.pattern_match_result as AnyRec) || {};
|
|
|
+ const rd = (item.rule_decision as AnyRec) || {};
|
|
|
+ const st = (item.statistics as AnyRec) || {};
|
|
|
+ const title = String(item.description || item.title || `${platformLabel(item.platform)}视频`);
|
|
|
+ const author = String(item.author_display_name || item.platform_author_id || "未知作者");
|
|
|
+ const queries = (item.matched_search_queries as unknown[]) || [];
|
|
|
+ const query = queries.length ? String(queries[0]) : "";
|
|
|
+ const fit = pmr.fit_senior_50plus;
|
|
|
+ const conf = num(pmr.fit_confidence);
|
|
|
+ const rel = num(pmr.relevance_score);
|
|
|
+ const score = num(rd.score);
|
|
|
+ const reason = String(pmr.reason || "");
|
|
|
+ const tags = ((item.tags as unknown[]) || []).map((t) => String(t)).slice(0, 6);
|
|
|
+ const origin = contentUrl(item);
|
|
|
+ const fitOk = fit === true;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <article className="asset-card">
|
|
|
+ <div className="asset-card-media">
|
|
|
+ <AssetMedia item={item} />
|
|
|
+ </div>
|
|
|
+ <div className="asset-card-body">
|
|
|
+ <div className="asset-card-head">
|
|
|
+ <strong className="asset-card-title" title={title}>
|
|
|
+ {title}
|
|
|
+ </strong>
|
|
|
+ <span className="asset-status pooled">已入池</span>
|
|
|
+ </div>
|
|
|
+ <div className="asset-card-sub">
|
|
|
+ <span>{author}</span>
|
|
|
+ {query ? <span className="asset-from">来自搜索「{query}」</span> : null}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="asset-judge">
|
|
|
+ <span className="asset-judge-tag">
|
|
|
+ <ShieldCheck size={13} /> Gemini 判定
|
|
|
+ </span>
|
|
|
+ <span className={`asset-judge-fit ${fitOk ? "ok" : "no"}`}>
|
|
|
+ 适合 50+ {fit === true ? "是" : fit === false ? "否" : "—"}
|
|
|
+ </span>
|
|
|
+ <span className="asset-judge-num">置信 {conf == null ? "—" : conf.toFixed(2)}</span>
|
|
|
+ <span className="asset-judge-num">相关 {rel == null ? "—" : rel.toFixed(2)}</span>
|
|
|
+ {score != null ? <span className="asset-judge-score">{score} 分</span> : null}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {reason ? <p className="asset-reason">{reason}</p> : null}
|
|
|
+
|
|
|
+ <div className="asset-stats">
|
|
|
+ <span className="asset-stats-label">抖音热度</span>
|
|
|
+ <StatPill icon={<ThumbsUp size={13} />} value={fmtStat(st.digg_count)} />
|
|
|
+ <StatPill icon={<MessageCircle size={13} />} value={fmtStat(st.comment_count)} />
|
|
|
+ <StatPill icon={<Star size={13} />} value={fmtStat(st.collect_count)} />
|
|
|
+ <StatPill icon={<Share2 size={13} />} value={fmtStat(st.share_count)} />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {tags.length ? (
|
|
|
+ <div className="asset-tags">
|
|
|
+ {tags.map((t, i) => (
|
|
|
+ <span className="asset-tag" key={i}>
|
|
|
+ {t}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {origin ? (
|
|
|
+ <div className="asset-actions">
|
|
|
+ <a className="text-button" href={origin} target="_blank" rel="noreferrer">
|
|
|
+ <ExternalLink size={14} /> 打开原帖
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {/* 预留:我方平台实测表现(入库后回填,与 Gemini 预测对照)*/}
|
|
|
+ <div className="asset-perf">
|
|
|
+ <div className="asset-perf-head">
|
|
|
+ <Lock size={12} /> 我方平台实测表现
|
|
|
+ <span className="asset-perf-flag">入库后回填</span>
|
|
|
+ </div>
|
|
|
+ <div className="asset-perf-metrics">
|
|
|
+ {["曝光", "播放", "完播率", "互动", "转化"].map((m) => (
|
|
|
+ <span className="asset-perf-cell" key={m}>
|
|
|
+ <em>{m}</em>
|
|
|
+ <b>—</b>
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <p className="asset-perf-note">
|
|
|
+ 上方是 Gemini 的<strong>预测</strong>;上线后用真实数据验证「适合 50+」是否成立,并回流到学习复盘。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function ContentAssetShelf({
|
|
|
+ contentItems,
|
|
|
+ kept,
|
|
|
+ review,
|
|
|
+ rejected
|
|
|
+}: {
|
|
|
+ contentItems: ContentItemsResponse;
|
|
|
+ kept: number | string;
|
|
|
+ review: number | string;
|
|
|
+ rejected: number | string;
|
|
|
+}) {
|
|
|
+ const items = (contentItems.items as AnyRec[]) || [];
|
|
|
+ const pooled = items.filter((it) => decisionAction(it) === POOLED);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="asset-shelf">
|
|
|
+ <div className="asset-summary">
|
|
|
+ <div className="asset-summary-stat pooled">
|
|
|
+ <span>入池</span>
|
|
|
+ <strong>{kept}</strong>
|
|
|
+ </div>
|
|
|
+ <div className="asset-summary-stat review">
|
|
|
+ <span>待复看</span>
|
|
|
+ <strong>{review}</strong>
|
|
|
+ </div>
|
|
|
+ <div className="asset-summary-stat reject">
|
|
|
+ <span>淘汰</span>
|
|
|
+ <strong>{rejected}</strong>
|
|
|
+ </div>
|
|
|
+ <p className="asset-summary-note">本次交付的「成品」=入池资产;下面逐条可播放、看判定与抖音热度。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {pooled.length ? (
|
|
|
+ <div className="asset-shelf-list">
|
|
|
+ {pooled.map((it, i) => (
|
|
|
+ <AssetCard item={it} key={String(it.platform_content_id || i)} />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="empty-state">本次没有入池资产(成品)。可在「发现旅程」查看待复看 / 淘汰的判定过程。</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|