Explorar el Código

feat(web-v3): 内容资产改「成品货架」——交付摘要 + 资产卡(播放/判定/抖音热度/原帖)+ 预留平台实测位

成品货架(本期):
- 顶部交付摘要:入池/待复看/淘汰 三色计数
- 每条入池资产一张卡:懒加载竖屏 iframe「播放」+ 标题/作者 + 来自搜索「词」+ Gemini判定(适合50+/置信/相关/分)+ 判定理由 + 抖音真实热度(赞/评/藏/转,≥1万显示X.X万)+ 话题标签 + 打开原帖
- 数据全部取自已加载的 content-items(decision_action=ADD_TO_CONTENT_POOL),无新增取数/后端改动

平台实测表现(预留):
- 每卡底部占位:曝光/播放/完播率/互动/转化(入库后回填),并点明「Gemini 预测 vs 上线实测」对照、回流学习复盘

替换原来只有计数的空面板;StagePanel asset 分支接 ContentAssetShelf。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee hace 1 día
padre
commit
77d5087b61

+ 110 - 0
web/app/globals.css

@@ -1775,3 +1775,113 @@ a {
 }
 .panel-nav-tab:hover { border-color: #b9c4d6; background: #fafbfd; }
 .panel-nav-tab.active { border-color: #2360ad; background: #eaf3ff; color: #2360ad; box-shadow: inset 0 0 0 1px #d8e8ff; }
+
+/* ===== 内容资产「成品货架」 ===== */
+.asset-shelf { display: flex; flex-direction: column; gap: 14px; }
+
+/* 交付摘要条 */
+.asset-summary {
+  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+  padding: 12px 14px; border: 1px solid #e4e9f2; border-radius: 12px;
+  background: linear-gradient(180deg, #fbfcfe 0%, #f6f9fd 100%);
+}
+.asset-summary-stat {
+  display: flex; flex-direction: column; gap: 2px; align-items: center;
+  min-width: 92px; padding: 6px 14px; border-radius: 10px; border: 1px solid #e4e9f2; background: #ffffff;
+}
+.asset-summary-stat span { font-size: 11px; font-weight: 800; color: #8491a6; }
+.asset-summary-stat strong { font-size: 22px; font-weight: 950; color: #172033; line-height: 1; }
+.asset-summary-stat.pooled { border-color: #bfe6cf; background: #f0fbf4; }
+.asset-summary-stat.pooled strong { color: #12805a; }
+.asset-summary-stat.review { border-color: #f3dcad; background: #fff9ec; }
+.asset-summary-stat.review strong { color: #9a6512; }
+.asset-summary-stat.reject { border-color: #f1c9c5; background: #fdf1f0; }
+.asset-summary-stat.reject strong { color: #ad2e23; }
+.asset-summary-note { margin: 0 0 0 auto; font-size: 12px; color: #7a8699; max-width: 360px; text-align: right; }
+
+/* 卡片列表(每行一卡,左视频右信息) */
+.asset-shelf-list { display: flex; flex-direction: column; gap: 12px; }
+.asset-card {
+  display: flex; gap: 16px; align-items: flex-start;
+  padding: 14px; border: 1px solid #e4e9f2; border-radius: 14px; background: #ffffff;
+  box-shadow: 0 1px 2px rgba(20, 39, 66, 0.04); transition: box-shadow 0.15s, border-color 0.15s;
+}
+.asset-card:hover { border-color: #cdd9ec; box-shadow: 0 4px 14px rgba(20, 39, 66, 0.08); }
+
+/* 左:竖屏视频 */
+.asset-card-media { flex: 0 0 auto; }
+.asset-media-frame {
+  width: 158px; height: 281px; border-radius: 12px; border: none; display: block; background: #000;
+}
+.asset-media-play {
+  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
+  color: #5d6b82; background: rgba(125, 135, 155, 0.07); border: 1px dashed #cdd5e3; cursor: pointer;
+  font-size: 13px; font-weight: 800;
+}
+.asset-media-play:hover { color: #2360ad; border-color: #9cbce8; background: #f4f9ff; }
+.asset-media-empty {
+  display: flex; align-items: center; justify-content: center; text-align: center; padding: 10px;
+  background: rgba(125, 135, 155, 0.06); border: 1px dashed #cdd5e3; font-size: 12px;
+}
+
+/* 右:信息区 */
+.asset-card-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
+.asset-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
+.asset-card-title {
+  font-size: 15px; font-weight: 900; color: #172033; line-height: 1.4;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.asset-status {
+  flex: 0 0 auto; font-size: 11px; font-weight: 900; padding: 3px 10px; border-radius: 999px; white-space: nowrap;
+}
+.asset-status.pooled { color: #12805a; background: #e2f6ea; border: 1px solid #bfe6cf; }
+.asset-card-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; font-weight: 700; color: #6d7c93; }
+.asset-from { color: #2360ad; }
+
+/* Gemini 判定行 */
+.asset-judge { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
+.asset-judge-tag { display: inline-flex; align-items: center; gap: 4px; font-weight: 900; color: #5a4ba8; }
+.asset-judge-fit { font-weight: 900; padding: 2px 9px; border-radius: 999px; }
+.asset-judge-fit.ok { color: #12805a; background: #e2f6ea; }
+.asset-judge-fit.no { color: #ad2e23; background: #fdeceb; }
+.asset-judge-num { color: #6d7c93; font-weight: 700; }
+.asset-judge-score {
+  margin-left: auto; font-weight: 950; color: #172033;
+  padding: 2px 10px; border-radius: 8px; background: #eef2f8; border: 1px solid #e0e6f0;
+}
+
+.asset-reason {
+  margin: 0; font-size: 12.5px; line-height: 1.6; color: #4a566e;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+
+/* 抖音热度 */
+.asset-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
+.asset-stats-label { font-size: 11px; font-weight: 900; color: #8491a6; }
+.asset-stat { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; color: #51607a; }
+.asset-stat svg { color: #9aa6ba; }
+
+/* 标签 */
+.asset-tags { display: flex; flex-wrap: wrap; gap: 6px; }
+.asset-tag { font-size: 11px; font-weight: 700; color: #5d6b82; background: #f1f4f9; border-radius: 6px; padding: 2px 8px; }
+
+.asset-actions { display: flex; gap: 8px; }
+
+/* 预留:我方平台实测表现 */
+.asset-perf {
+  margin-top: 2px; padding: 10px 12px; border-radius: 10px;
+  border: 1px dashed #cfd8e6; background: repeating-linear-gradient(135deg, #fafbfd, #fafbfd 8px, #f5f7fb 8px, #f5f7fb 16px);
+}
+.asset-perf-head { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 900; color: #6a7488; }
+.asset-perf-flag {
+  font-size: 10px; font-weight: 900; color: #8a7322; background: #fdf3d6; border: 1px solid #f0dca6;
+  border-radius: 999px; padding: 1px 8px; margin-left: 2px;
+}
+.asset-perf-metrics { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
+.asset-perf-cell {
+  display: flex; flex-direction: column; align-items: center; gap: 1px; min-width: 64px;
+  padding: 5px 8px; border-radius: 8px; background: rgba(255,255,255,0.7); border: 1px solid #e6ebf3;
+}
+.asset-perf-cell em { font-style: normal; font-size: 10.5px; font-weight: 800; color: #9aa6ba; }
+.asset-perf-cell b { font-size: 15px; font-weight: 950; color: #b8c2d2; }
+.asset-perf-note { margin: 0; font-size: 11.5px; line-height: 1.5; color: #8491a6; }

+ 213 - 0
web/features/runs/ContentAssetShelf.tsx

@@ -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>
+  );
+}

+ 8 - 9
web/features/runs/RunDashboardPage.tsx

@@ -3,6 +3,7 @@
 import Link from "next/link";
 import { WalkJourney } from "@/features/runs/WalkJourney";
 import { StrategyConfigPanel } from "@/features/runs/StrategyConfigPanel";
+import { ContentAssetShelf } from "@/features/runs/ContentAssetShelf";
 
 import {
   Activity,
@@ -421,16 +422,14 @@ function StagePanel({
   }
   if (activeStage === "asset") {
     return (
-      <BusinessSection title="资产沉淀结果" icon={<Target size={17} />}>
+      <BusinessSection title="内容资产 · 成品货架" icon={<Target size={17} />}>
         <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "asset")} />
-        <div className="business-card-list">
-          <div className="business-record-card">
-            <strong>内容资产</strong>
-            <span>入池:{compactValue(data.dashboard.business_summary.kept_count)}</span>
-            <span>待复看:{compactValue(data.dashboard.business_summary.review_count)}</span>
-            <span>淘汰:{compactValue(data.dashboard.business_summary.rejected_count)}</span>
-          </div>
-        </div>
+        <ContentAssetShelf
+          contentItems={data.contentItems}
+          kept={compactValue(data.dashboard.business_summary.kept_count)}
+          review={compactValue(data.dashboard.business_summary.review_count)}
+          rejected={compactValue(data.dashboard.business_summary.rejected_count)}
+        />
       </BusinessSection>
     );
   }