Преглед изворни кода

web 按运营需求调整可视化

- 去掉「多模态提取」环节展示
- 原文:突出「打开原帖」链接;图片点击放大(lightbox)
- 筛选:补充运营可懂的筛选标准(4 条)
- 第3节「知识片段」改为「知识拆分」,按 What/Why/How 展示 + 原文证据
- 新增第4节「知识解构」:阶段(灵感/选题/脚本)+ 作用域(实质/形式/感受/作用/意图)+ ingest payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lisihan пре 1 месец
родитељ
комит
96d9f2b8d4
1 измењених фајлова са 82 додато и 50 уклоњено
  1. 82 50
      web/index.html

+ 82 - 50
web/index.html

@@ -8,7 +8,7 @@
 <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
 <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
 <style>
-  :root{--bg:#0f1115;--panel:#171a21;--panel2:#1e222b;--line:#2a2f3a;--fg:#e6e8ec;--mut:#8b93a1;--ac:#5b9dff;--ok:#36c08a;--no:#e0556b;--warn:#e0a23a;}
+  :root{--bg:#0f1115;--panel:#171a21;--panel2:#1e222b;--line:#2a2f3a;--fg:#e6e8ec;--mut:#8b93a1;--ac:#5b9dff;--ok:#36c08a;--no:#e0556b;--warn:#e0a23a;--what:#5b9dff;--why:#c084fc;--how:#36c08a;}
   *{box-sizing:border-box}
   body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif}
   header{padding:14px 20px;border-bottom:1px solid var(--line);font-weight:600;display:flex;gap:10px;align-items:center}
@@ -26,22 +26,37 @@
   .badge.no{background:rgba(224,85,107,.15);color:var(--no);border-color:transparent}
   .badge.ac{background:rgba(91,157,255,.15);color:var(--ac);border-color:transparent}
   .badge.warn{background:rgba(224,162,58,.15);color:var(--warn);border-color:transparent}
+  .badge.what{background:rgba(91,157,255,.15);color:var(--what);border-color:transparent}
+  .badge.why{background:rgba(192,132,252,.15);color:var(--why);border-color:transparent}
+  .badge.how{background:rgba(54,192,138,.15);color:var(--how);border-color:transparent}
   .stage{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:16px;overflow:hidden}
   .stage>h3{margin:0;padding:10px 16px;background:var(--panel2);font-size:13px;display:flex;gap:8px;align-items:center}
   .stage>h3 .n{width:20px;height:20px;border-radius:50%;background:var(--ac);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px}
   .stage .body{padding:14px 16px}
   .kv{color:var(--mut);font-size:12px;margin:0 0 4px}
   .imgs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
-  .imgs img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--line)}
+  .imgs img{width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in;transition:transform .1s}
+  .imgs img:hover{transform:scale(1.05);border-color:var(--ac)}
   .card{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:10px}
   .card h4{margin:0 0 8px;font-size:14px}
   .chips{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0}
   pre{white-space:pre-wrap;word-break:break-word;background:#0c0e12;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:12px;color:#c8cdd6;margin:6px 0 0;max-height:260px;overflow:auto}
   details summary{cursor:pointer;color:var(--mut);font-size:12px;margin-top:8px}
-  .whh{margin:8px 0}
-  .whh b{color:var(--ac);font-size:12px}
+  .whh{margin:8px 0;padding-left:10px;border-left:2px solid var(--line)}
+  .whh.w{border-color:var(--what)} .whh.y{border-color:var(--why)} .whh.h{border-color:var(--how)}
+  .whh b{font-size:12px}
+  .whh.w b{color:var(--what)} .whh.y b{color:var(--why)} .whh.h b{color:var(--how)}
+  .link{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:rgba(91,157,255,.12);border:1px solid var(--ac);border-radius:8px;color:var(--ac);text-decoration:none;font-size:13px;margin-bottom:10px}
+  .crit{background:#0c0e12;border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin-bottom:12px}
+  .crit .t{color:var(--mut);font-size:12px;margin-bottom:6px}
+  .crit ul{margin:0;padding-left:18px} .crit li{margin:3px 0;font-size:13px}
+  .scope{display:flex;gap:8px;align-items:baseline;margin:4px 0}
+  .scope .st{color:var(--ac);font-size:12px;min-width:42px}
+  .lb{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:50;cursor:zoom-out}
+  .lb img{max-width:92vw;max-height:92vh;border-radius:8px}
   a{color:var(--ac)}
   .empty{color:var(--mut);padding:40px;text-align:center}
+  .sub2{color:var(--mut);font-size:12px;margin:-2px 0 8px}
 </style>
 </head>
 <body>
@@ -49,85 +64,100 @@
 <script type="text/babel">
 const {useState,useEffect} = React;
 const j = (u)=>fetch(u).then(r=>r.json());
+const SCOPE_CN = {substance:"实质",form:"形式",feeling:"感受",effect:"作用",intent:"意图"};
+const TYPE_KIND = {what:"what",why:"why",how:"how"};
 
 function Badge({kind,children}){return <span className={"badge "+(kind||"")}>{children}</span>;}
 
 function PostList({posts,sel,onSel}){
   return <div className="side">
     {posts.map(p=>{
-      const stageKind = p.stage==="done"?"ok":p.stage==="rejected"?"no":p.stage==="failed"?"no":"warn";
+      const k = p.stage==="done"?"ok":p.stage==="rejected"?"no":p.stage==="failed"?"no":"warn";
       return <div key={p.id} className={"pitem"+(sel===p.id?" sel":"")} onClick={()=>onSel(p.id)}>
         <div className="id">{p.id}</div>
-        <div className="row">
-          <Badge kind={stageKind}>{p.stage}</Badge>
-          <Badge kind="ac">{p.item_count} 片段</Badge>
-        </div>
+        <div className="row"><Badge kind={k}>{p.stage}</Badge><Badge kind="ac">{p.item_count} 条知识</Badge></div>
       </div>;
     })}
   </div>;
 }
 
-function Stage({n,title,children}){
-  return <div className="stage"><h3><span className="n">{n}</span>{title}</h3><div className="body">{children}</div></div>;
+function Stage({n,title,sub,children}){
+  return <div className="stage"><h3><span className="n">{n}</span>{title}</h3>
+    <div className="body">{sub&&<div className="sub2">{sub}</div>}{children}</div></div>;
 }
 
-function ItemCard({it}){
+function SplitCard({item}){
+  return <div className="card">
+    <h4>{item.title||"(无标题)"}</h4>
+    <div className="chips">{(item.knowledge_types||[]).map(t=><Badge key={t} kind={TYPE_KIND[t]}>{t.toUpperCase()}</Badge>)}</div>
+    {item.what&&<div className="whh w"><b>What · 是什么</b><div>{item.what}</div></div>}
+    {item.why&&<div className="whh y"><b>Why · 为什么</b><div>{item.why}</div></div>}
+    {item.how&&<div className="whh h"><b>How · 怎么做</b><div>{item.how}</div></div>}
+    {(item.evidence&&item.evidence.length>0)&&
+      <details><summary>原文证据({item.evidence.length})</summary><pre>{item.evidence.join("\n")}</pre></details>}
+  </div>;
+}
+
+function DeconCard({it}){
   const item=it.item||{}, deco=it.deconstruction||{}, payload=it.ingest_payload||{};
-  const ist=it.ingest_status;
   return <div className="card">
     <h4>{item.title||"(无标题)"}</h4>
-    <div className="chips">
-      {(item.knowledge_types||[]).map(t=><Badge key={t} kind="ac">{t}</Badge>)}
-      {(deco.stages||[]).map(s=><Badge key={s}>{s}</Badge>)}
-      <Badge kind={ist==="ingested"?"ok":ist==="failed"?"no":"warn"}>{ist}</Badge>
-    </div>
-    {item.what&&<div className="whh"><b>What</b><div>{item.what}</div></div>}
-    {item.why&&<div className="whh"><b>Why</b><div>{item.why}</div></div>}
-    {item.how&&<div className="whh"><b>How</b><div>{item.how}</div></div>}
-    <div className="chips">
-      {(deco.scopes||[]).map((s,i)=><Badge key={i}>{s.scope_type}: {s.value}</Badge>)}
-    </div>
-    <details><summary>ingest payload</summary>
-      <pre>{JSON.stringify(payload,null,2)}</pre>
-    </details>
+    <div className="kv" style={{marginTop:8}}>阶段(灵感 / 选题 / 脚本)</div>
+    <div className="chips">{(deco.stages||[]).length? deco.stages.map(s=><Badge key={s} kind="ac">{s}</Badge>) : <span className="kv">—</span>}</div>
+    <div className="kv" style={{marginTop:8}}>作用域(实质 / 形式 / 感受 / 作用 / 意图)</div>
+    {(deco.scopes||[]).length? (deco.scopes||[]).map((s,i)=>
+      <div className="scope" key={i}><span className="st">{SCOPE_CN[s.scope_type]||s.scope_type}</span><span>{s.value}</span></div>
+    ) : <div className="kv">—</div>}
+    <details><summary>组装后的入库数据(ingest payload)</summary><pre>{JSON.stringify(payload,null,2)}</pre></details>
   </div>;
 }
 
-function Pipeline({postId}){
+function Pipeline({postId,onZoom}){
   const [post,setPost]=useState(null);
   const [items,setItems]=useState([]);
-  useEffect(()=>{ if(!postId)return;
-    setPost(null);setItems([]);
+  useEffect(()=>{ if(!postId)return; setPost(null);setItems([]);
     j("/api/posts/"+postId).then(setPost);
     j("/api/posts/"+postId+"/items").then(setItems);
   },[postId]);
   if(!postId) return <div className="empty">← 左边选一个帖子</div>;
   if(!post) return <div className="empty">加载中…</div>;
   const inner=(post.raw&&post.raw.data&&post.raw.data.data)||{};
-  const ex=post.extracted||{}, scr=post.screening||{};
-  const imgs=(inner.image_url_list||[]).map(x=>x.image_url||x).slice(0,9);
+  const scr=post.screening||{};
+  const imgs=(inner.image_url_list||[]).map(x=>x.image_url||x);
   return <div>
-    <Stage n="1" title="原文(拉取)">
-      <p className="kv">作者 {inner.channel_account_name} · 类型 {inner.content_type} · <a href={post.url} target="_blank">原链接</a></p>
+    <Stage n="1" title="原文">
+      <a className="link" href={post.url} target="_blank" rel="noreferrer">🔗 打开原帖</a>
+      <p className="kv">作者 {inner.channel_account_name||"—"} · 类型 {inner.content_type||"—"}</p>
       <div><b>{inner.title||"(无标题)"}</b></div>
-      <pre>{inner.body_text||"(正文为空)"}</pre>
-      <div className="imgs">{imgs.map((u,i)=><img key={i} src={u} onError={e=>e.target.style.display='none'}/>)}</div>
+      <pre>{inner.body_text||"(正文为空,知识在图片里)"}</pre>
+      <div className="imgs">{imgs.map((u,i)=><img key={i} src={u} title="点击放大"
+        onClick={()=>onZoom(u)} onError={e=>e.target.style.display='none'}/>)}</div>
     </Stage>
-    <Stage n="2" title="多模态提取(Gemini)">
-      <div className="chips"><Badge kind={ex.is_empty?"no":"ok"}>is_empty: {String(ex.is_empty)}</Badge></div>
-      {ex.from_image&&<div className="whh"><b>图片提取</b><div>{ex.from_image}</div></div>}
-      {ex.from_video&&<div className="whh"><b>视频提取</b><div>{ex.from_video}</div></div>}
-      {ex.text&&<pre>{ex.text}</pre>}
-    </Stage>
-    <Stage n="3" title="筛选">
+
+    <Stage n="2" title="筛选" sub="判断这篇帖子值不值得提取成创作知识">
+      <div className="crit">
+        <div className="t">筛选标准(4 条都满足才通过)</div>
+        <ul>
+          <li>内容不是空的</li>
+          <li>讲的是"怎么做内容"的方法或经验,不是一条作品本身(比如一段文案、一张图)</li>
+          <li>至少包含一种:是什么(What)/ 为什么(Why)/ 怎么做(How)</li>
+          <li>至少能拆出一条能用的创作知识</li>
+        </ul>
+      </div>
       <div className="chips">
-        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"passed":"rejected"}</Badge>
-        <Badge kind="ac">score {scr.score}</Badge>
+        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"✓ 通过":"✕ 淘汰"}</Badge>
+        <Badge kind="ac">评分 {scr.score}/10</Badge>
       </div>
-      <div>{scr.reason}</div>
+      <div className="kv" style={{marginTop:6}}>判断理由</div>
+      <div>{scr.reason||"—"}</div>
     </Stage>
-    <Stage n="4" title={"知识片段("+items.length+")"}>
-      {items.length? items.map((it,i)=><ItemCard key={i} it={it}/>) : <div className="kv">无(被筛选淘汰或未拆出)</div>}
+
+    <Stage n="3" title={"知识拆分("+items.length+" 条)"} sub="把帖子拆成 What / Why / How 知识片段">
+      {items.length? items.map((it,i)=><SplitCard key={i} item={it.item||{}}/>) : <div className="kv">无(被淘汰或未拆出)</div>}
+    </Stage>
+
+    <Stage n="4" title="知识解构" sub="给每条知识标注 阶段 与 作用域">
+      {items.length? items.map((it,i)=><DeconCard key={i} it={it}/>) : <div className="kv">无</div>}
     </Stage>
   </div>;
 }
@@ -135,13 +165,15 @@ function Pipeline({postId}){
 function App(){
   const [posts,setPosts]=useState([]);
   const [sel,setSel]=useState(null);
+  const [zoom,setZoom]=useState(null);
   useEffect(()=>{ j("/api/posts").then(ps=>{setPosts(ps); if(ps[0])setSel(ps[0].id);}); },[]);
   return <div>
-    <header>创作知识 <span className="sub">· 帖子知识流水线可视化({posts.length} 帖)</span></header>
+    <header>创作知识 <span className="sub">· 帖子知识流水线({posts.length} 帖)</span></header>
     <div className="wrap">
       <PostList posts={posts} sel={sel} onSel={setSel}/>
-      <div className="main"><Pipeline postId={sel}/></div>
+      <div className="main"><Pipeline postId={sel} onZoom={setZoom}/></div>
     </div>
+    {zoom&&<div className="lb" onClick={()=>setZoom(null)}><img src={zoom}/></div>}
   </div>;
 }
 ReactDOM.createRoot(document.getElementById("root")).render(<App/>);