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

feat(frontend): show formal search and decode details

SamLee пре 2 недеља
родитељ
комит
26939e03ff

Разлика између датотеке није приказан због своје велике величине
+ 8 - 0
app/frontend/dist/assets/index-Bx7KAD4Y.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
app/frontend/dist/assets/index-C99ohPRA.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 8
app/frontend/dist/assets/index-D2QcI5kS.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
app/frontend/dist/assets/index-QFmrwHTU.css


+ 2 - 2
app/frontend/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识 · Query 正交 Demo</title>
-    <script type="module" crossorigin src="/app/assets/index-D2QcI5kS.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-C99ohPRA.css">
+    <script type="module" crossorigin src="/app/assets/index-Bx7KAD4Y.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-QFmrwHTU.css">
   </head>
   <body>
     <div id="root"></div>

+ 25 - 145
app/frontend/src/pages/CreationDemo.jsx

@@ -39,25 +39,15 @@ function platformCounts(row) {
   return ['xiaohongshu', 'weixin', 'douyin'].map((key) => platforms[key]?.status || '未跑').join(' / ')
 }
 
-function shortText(text, n = 130) {
-  if (!text) return ''
-  return text.length > n ? text.slice(0, n) + '...' : text
+function decodedLabel(row) {
+  if (!row) return ''
+  return `${row.decoded_count || 0}/${row.candidate_count || 0} 已拆`
 }
 
-function mediaUrl(asset) {
-  return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
-}
-
-function firstImage(item) {
-  const asset = (item?.media_assets || []).find((row) => ['image', 'cover', 'frame'].includes(row.media_type))
-  return mediaUrl(asset)
-}
-
-function clsLabel(classification) {
-  if (!classification || classification.is_creation_knowledge === null || classification.is_creation_knowledge === undefined) {
-    return ['none', '未判断']
-  }
-  return classification.is_creation_knowledge ? ['yes', '创作知识'] : ['no', '非创作知识']
+function knowledgeLabel(row) {
+  if (!row) return ''
+  const hits = Math.max(row.creation_hit_count || 0, row.decoded_count || 0)
+  return `${hits}/${row.candidate_count || 0} 知识贴`
 }
 
 function uniqueAxisValues(items, axis) {
@@ -112,140 +102,38 @@ function singletonMaps(singleton) {
   return byQueryText
 }
 
-function decodedItemMap(singleton) {
-  const out = new Map()
-  for (const row of singleton?.decoded_items || []) {
-    out.set(row.item_id, row)
-  }
-  return out
-}
-
-function InlineSearchResults({ state, singleton }) {
-  const decodedByItem = decodedItemMap(singleton)
-  if (state?.loading) return <div className="inline-results loading">搜索结果读取中...</div>
-  if (state?.error) return <div className="inline-results error">{state.error}</div>
-  const data = state?.data
-  if (!data) return null
-  const platforms = Object.entries(data.platforms || {}).filter(([, platform]) => platform?.items?.length)
-  if (platforms.length === 0) return <div className="inline-results empty-inline">这一条 query 暂无可展示素材</div>
+function PreviewQueryRow({ item, index, latest }) {
   return (
-    <div className="inline-results">
-      {platforms.map(([platformKey, platform]) => {
-        const [label, tone] = PLATFORM_LABEL[platformKey] || [platformKey, '']
-        return (
-          <div className="inline-platform" key={platformKey}>
-            <div className={`inline-platform-hd ${tone}`}>
-              <span>{label}</span>
-              <span className={`status ${platform.status || 'done'}`}>{statusText(platform.status || 'done')}</span>
-              <span className="gn">{platform.items.length} 条</span>
-            </div>
-            <div className="inline-cards">
-              {platform.items.map((result) => {
-                const [clsTone, clsText] = clsLabel(result.classification)
-                const decoded = decodedByItem.get(result.id)
-                const image = firstImage(result)
-                return (
-                  <article className={`inline-card ${clsTone}`} key={result.id}>
-                    {image ? <img className="inline-thumb" src={image} alt="" loading="lazy" /> : <div className="inline-thumb empty-thumb">无图</div>}
-                    <div className="inline-card-main">
-                      <div className="inline-title">{result.title || '无标题'}</div>
-                      <div className="inline-meta">
-                        {result.author_name && <span>{result.author_name}</span>}
-                        <span className={`cls ${clsTone}`}>{clsText}</span>
-                        {decoded?.payload_count ? <span className="payload-chip">{decoded.payload_count} payload</span> : null}
-                      </div>
-                      {result.classification?.reason && <div className="inline-reason">{result.classification.reason}</div>}
-                      {result.raw_summary && <div className="inline-summary">{shortText(result.raw_summary)}</div>}
-                      <div className="inline-actions">
-                        {result.classification?.is_creation_knowledge === true && (
-                          <a className="qdetail decode-on" href={`#/decode-item/${encodeURIComponent(result.id)}`}>
-                            查看知识颗{decoded?.payload_count ? <span>{decoded.payload_count} payload</span> : null}
-                          </a>
-                        )}
-                        {result.canonical_url && <a className="qdetail" href={result.canonical_url} target="_blank" rel="noreferrer">原帖</a>}
-                      </div>
-                    </div>
-                  </article>
-                )
-              })}
-            </div>
-          </div>
-        )
-      })}
+    <div className={`qrow ${item.keep === false ? 'drop' : 'keep'}`} key={`${item.query}-${index}`} title={item.reason || ''}>
+      <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
+      {typeof item.valid === 'number' && (
+        <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
+      )}
+      <span className="qtext">{item.query}</span>
+      {latest ? (
+        <>
+          <span className="qcreation done">{decodedLabel(latest)}</span>
+          <span className="qcreation knowledge">{knowledgeLabel(latest)}</span>
+          <a className="qdetail on" href={`#/query/latest/${encodeURIComponent(latest.query_id)}`}>查看详情</a>
+        </>
+      ) : (
+        <span className="qdetail">未搜索</span>
+      )}
+      {item.reason && <span className="qreason">{item.reason}</span>}
     </div>
   )
 }
 
-function PreviewQueryRow({ item, index, latest, singleton, expanded, detailState, onToggle }) {
-  const decoded = latest?.decoded_items?.[0]
-  const decodeHref = decoded ? `#/decode-item/${encodeURIComponent(decoded.item_id)}` : ''
-  return (
-    <>
-      <div className={`qrow ${item.keep === false ? 'drop' : 'keep'}`} key={`${item.query}-${index}`} title={item.reason || ''}>
-        <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
-        {typeof item.valid === 'number' && (
-          <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
-        )}
-        <span className="qtext">{item.query}</span>
-        {latest ? (
-          <>
-            <span className="qcreation done">{latest.creation_hit_count}/{latest.candidate_count} 命中</span>
-            <button className={`qdetail ${expanded ? 'active' : 'on'}`} type="button" onClick={() => onToggle(latest)}>
-              {expanded ? '收起结果' : '展开结果'}
-            </button>
-          </>
-        ) : (
-          <span className="qdetail">未搜索</span>
-        )}
-        {decodeHref && <a className="qdetail decode-on" href={decodeHref}>查看拆解结果<span>{decoded.payload_count} payload</span></a>}
-        {item.reason && <span className="qreason">{item.reason}</span>}
-      </div>
-      {expanded && <InlineSearchResults state={detailState} singleton={singleton} />}
-    </>
-  )
-}
-
-function updateDetailCache(cache, queryId, patch) {
-  return {
-    ...cache,
-    [queryId]: {
-      ...(cache[queryId] || {}),
-      ...patch,
-    },
-  }
-}
-
 function QueryGenerationPreview() {
   const [data, setData] = useState(null)
   const [singleton, setSingleton] = useState(null)
   const [activeKey, setActiveKey] = useState('f1')
-  const [expandedQueryId, setExpandedQueryId] = useState('')
-  const [detailCache, setDetailCache] = useState({})
   const [err, setErr] = useState('')
 
-  const toggleResults = (latest) => {
-    if (!latest?.query_id || !singleton?.run?.id) return
-    if (expandedQueryId === latest.query_id) {
-      setExpandedQueryId('')
-      return
-    }
-    setExpandedQueryId(latest.query_id)
-    if (detailCache[latest.query_id]?.data || detailCache[latest.query_id]?.loading) return
-    setDetailCache((cache) => updateDetailCache(cache, latest.query_id, { loading: true, error: '' }))
-    fetch(`/api/acquisition/runs/${encodeURIComponent(singleton.run.id)}/queries/${encodeURIComponent(latest.query_id)}`)
-      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('搜索结果读取失败'))))
-      .then((payload) => {
-        setDetailCache((cache) => updateDetailCache(cache, latest.query_id, { loading: false, data: payload, error: '' }))
-      })
-      .catch((e) => {
-        setDetailCache((cache) => updateDetailCache(cache, latest.query_id, { loading: false, error: e.message || '读取失败' }))
-      })
-  }
-
   useEffect(() => {
     setErr('')
     Promise.all([
-      fetch('/api/query-generation/preview?per=30&dry=true')
+      fetch('/api/query-generation/preview?per=0&batch_n=0&dry=true')
         .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query 生成预览读取失败')))),
       fetch('/api/query-generation/latest-singleton')
         .then((r) => (r.ok ? r.json() : null))
@@ -260,10 +148,6 @@ function QueryGenerationPreview() {
       .catch((e) => setErr(e.message || '读取失败'))
   }, [])
 
-  useEffect(() => {
-    setExpandedQueryId('')
-  }, [activeKey])
-
   if (err) return <div className="empty">{err}</div>
   if (!data) return <div className="empty">加载中...</div>
 
@@ -312,10 +196,6 @@ function QueryGenerationPreview() {
                   index={index}
                   key={`${item.query}-${index}`}
                   latest={latest}
-                  singleton={singleton}
-                  expanded={expandedQueryId === latest?.query_id}
-                  detailState={latest ? detailCache[latest.query_id] : null}
-                  onToggle={toggleResults}
                 />
               )
             })}

+ 130 - 17
app/frontend/src/pages/CreationQueryDetail.jsx

@@ -1,4 +1,5 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
+import DecodeItemDetail from './DecodeItemDetail.jsx'
 
 const PLATFORMS = [
   ['xiaohongshu', '小红书', 'xhs'],
@@ -27,13 +28,46 @@ function itemVideo(item) {
   return mediaUrl(asset)
 }
 
-function clsLabel(cls) {
+function contentModeLabel(item) {
+  return {
+    image_post: '图文帖',
+    video_post: '视频帖',
+    article: '文章图文',
+    unsupported: '不支持',
+  }[item.content_mode] || item.content_type || '未识别'
+}
+
+function skipReasonText(item) {
+  const reason = item.metadata?.skip_reason || item.classification?.error_message || item.error_message || ''
+  return {
+    unsupported_content_mode: '暂不支持这种帖子类型,已跳过',
+    video_url_missing: '视频帖没有可处理的视频地址,已跳过',
+  }[reason] || reason
+}
+
+function clsLabel(cls, decoded) {
+  if (cls?.status === 'skipped') return ['none', '已跳过']
+  if (decoded) return ['yes', '已拆知识']
   if (!cls || cls.is_creation_knowledge === null || cls.is_creation_knowledge === undefined) {
-    return ['none', '未判断']
+    return ['none', '未粗筛']
   }
   return cls.is_creation_knowledge ? ['yes', '创作知识'] : ['no', '非创作知识']
 }
 
+function judgementText(item, decoded) {
+  if (item.metadata?.acquisition_match_status === 'existing') {
+    return `本次搜索重复命中,已复用历史解析,生成 ${decoded?.payload_count || 0} payload`
+  }
+  const skipText = skipReasonText(item)
+  if (skipText) return skipText
+  if (decoded) {
+    return `Decode 已通过,生成 ${decoded.payload_count || 0} payload`
+  }
+  if (item.classification?.reason) return item.classification.reason
+  if (item.classification?.is_creation_knowledge === false) return '粗筛判断为非创作知识'
+  return '本次未做粗筛判断'
+}
+
 function PlatformStatus({ platform }) {
   const status = platform?.status || 'not_started'
   const text = {
@@ -72,8 +106,11 @@ function Media({ item, onImageClick }) {
   ) : null
 }
 
-function ResultCard({ item, tone, onImageClick }) {
-  const [clsTone, clsText] = clsLabel(item.classification)
+function ResultCard({ item, tone, decoded, onImageClick, onDecodeClick }) {
+  const [clsTone, clsText] = clsLabel(item.classification, decoded)
+  const isExisting = item.metadata?.acquisition_match_status === 'existing'
+  const matchedCandidate = item.metadata?.matched_candidate || {}
+  const hasFullBody = item.body_text && item.body_text !== item.raw_summary
   return (
     <div className={`lane ${tone}`}>
       <Media item={item} onImageClick={onImageClick} />
@@ -81,10 +118,18 @@ function ResultCard({ item, tone, onImageClick }) {
         <div className="t">{item.title || '无标题'}</div>
         <span className={`cls ${clsTone}`}>{clsText}</span>
       </div>
+      <div className="mode-row">
+        <span className={`mode-chip ${item.content_mode || 'unknown'}`}>{contentModeLabel(item)}</span>
+        {item.content_type && <span className="rawtype">原始:{item.content_type}</span>}
+      </div>
+      {isExisting && <div className="reuse-tag">重复命中 · 复用历史解析</div>}
       {item.author_name && <div className="meta">{item.author_name}</div>}
-      {item.classification?.reason && <div className="meta">{item.classification.reason}</div>}
-      {item.classification?.is_creation_knowledge === true && (
-        <a className="src detail-link" href={`#/decode-item/${encodeURIComponent(item.id)}`}>查看知识颗</a>
+      <div className={`judge ${clsTone}`}>判断:{judgementText(item, decoded)}</div>
+      {decoded && (
+        <button className="src detail-link detail-button" type="button" onClick={() => onDecodeClick(item.id)}>
+          查看详情
+          <span>{decoded.payload_count || 0} payload</span>
+        </button>
       )}
       {item.raw_summary && (
         <details className="fold">
@@ -92,15 +137,27 @@ function ResultCard({ item, tone, onImageClick }) {
           <div className="bt">{shortText(item.raw_summary, 520)}</div>
         </details>
       )}
+      {hasFullBody && (
+        <details className="fold">
+          <summary>平台原文</summary>
+          <div className="bt">{item.body_text}</div>
+        </details>
+      )}
+      {isExisting && matchedCandidate.url && (
+        <a className="src reuse-src" href={matchedCandidate.url} target="_blank" rel="noreferrer">
+          本次搜索命中的原贴
+        </a>
+      )}
       {item.canonical_url && <a className="src" href={item.canonical_url} target="_blank" rel="noreferrer">打开原链接</a>}
     </div>
   )
 }
 
-function PlatformGroup({ id, label, tone, data, onImageClick }) {
+function PlatformGroup({ id, label, tone, data, decodedByItem, onImageClick, onDecodeClick }) {
   const items = data?.items || []
-  const creationItems = items.filter((item) => item.classification?.is_creation_knowledge === true)
-  const otherItems = items.filter((item) => item.classification?.is_creation_knowledge !== true)
+  const decodedFor = (item) => decodedByItem.get(item.id) || item.decode_summary
+  const creationItems = items.filter((item) => item.classification?.is_creation_knowledge === true || decodedFor(item))
+  const otherItems = items.filter((item) => item.classification?.is_creation_knowledge !== true && !decodedFor(item))
   const renderRow = (rowItems, title, dim = false) => (
     <div className="result-band">
       <div className={`subhd ${dim ? 'dim' : ''}`}>
@@ -111,7 +168,16 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
         <div className="row-empty">{dim ? '暂无非创作知识或判断失败' : '暂无创作知识'}</div>
       ) : (
         <div className="grid result-row">
-          {rowItems.map((item) => <ResultCard key={item.id} item={item} tone={tone} onImageClick={onImageClick} />)}
+          {rowItems.map((item) => (
+            <ResultCard
+              key={item.id}
+              item={item}
+              tone={tone}
+              decoded={decodedFor(item)}
+              onImageClick={onImageClick}
+              onDecodeClick={onDecodeClick}
+            />
+          ))}
         </div>
       )}
     </div>
@@ -123,6 +189,7 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
         <PlatformStatus platform={data} />
         <span className="gn">{items.length} 条</span>
       </div>
+      {data?.error_message && <div className="perr">{data.error_message}</div>}
       {items.length === 0 ? (
         <div className="gnone">暂无可展示内容</div>
       ) : (
@@ -137,8 +204,10 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
 
 export default function CreationQueryDetail({ runId, queryId }) {
   const [data, setData] = useState(null)
+  const [overview, setOverview] = useState(null)
   const [err, setErr] = useState('')
   const [lightbox, setLightbox] = useState(null)
+  const [decodeItemId, setDecodeItemId] = useState('')
 
   const openImage = (images, index, title) => {
     setLightbox({ images, index, title: title || '帖子图片' })
@@ -154,29 +223,73 @@ export default function CreationQueryDetail({ runId, queryId }) {
 
   useEffect(() => {
     setData(null)
+    setOverview(null)
     setErr('')
     if (!runId || !queryId) {
       setErr('缺少 run_id 或 query_id')
       return
     }
-    fetch(`/api/acquisition/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(queryId)}`)
-      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('接口读取失败'))))
-      .then(setData)
+    const detailUrl = runId === 'latest'
+      ? `/api/query-generation/latest/queries/${encodeURIComponent(queryId)}`
+      : `/api/acquisition/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(queryId)}`
+    Promise.all([
+      fetch(detailUrl)
+        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('接口读取失败')))),
+      fetch('/api/query-generation/latest-singleton')
+        .then((r) => (r.ok ? r.json() : null))
+        .catch(() => null),
+    ])
+      .then(([detail, latest]) => {
+        setData(detail)
+        setOverview(latest)
+      })
       .catch((e) => setErr(e.message || '读取失败'))
   }, [runId, queryId])
 
+  const decodedByItem = useMemo(() => {
+    const map = new Map()
+    for (const row of overview?.decoded_items || []) {
+      if (row.query_id === queryId) map.set(row.item_id, row)
+    }
+    return map
+  }, [overview, queryId])
+
+  const queryStats = useMemo(() => {
+    return (overview?.queries || []).find((row) => row.query_id === queryId)
+  }, [overview, queryId])
+
   return (
     <div>
       <div className="detail-top">
         <a className="back" href="#/">返回 Query 列表</a>
-        {runId && <span className="meta">run_id: {runId}</span>}
+        {queryStats && (
+          <span className="meta">
+            {queryStats.decoded_count || 0}/{queryStats.candidate_count || 0} 已拆 · {Math.max(queryStats.creation_hit_count || 0, queryStats.decoded_count || 0)}/{queryStats.candidate_count || 0} 知识贴
+          </span>
+        )}
       </div>
       <h1 className="dq">{data?.query?.query_text || queryId}</h1>
       {err && <div className="empty">{err}</div>}
       {!data && !err && <div className="empty">加载中...</div>}
       {data && PLATFORMS.map(([id, label, tone]) => (
-        <PlatformGroup key={id} id={id} label={label} tone={tone} data={data.platforms?.[id]} onImageClick={openImage} />
+        <PlatformGroup
+          key={id}
+          id={id}
+          label={label}
+          tone={tone}
+          data={data.platforms?.[id]}
+          decodedByItem={decodedByItem}
+          onImageClick={openImage}
+          onDecodeClick={setDecodeItemId}
+        />
       ))}
+      {decodeItemId && (
+        <div className="decode-modal-mask" onClick={() => setDecodeItemId('')}>
+          <div className="decode-modal-panel" onClick={(event) => event.stopPropagation()}>
+            <DecodeItemDetail itemId={decodeItemId} embedded onClose={() => setDecodeItemId('')} />
+          </div>
+        </div>
+      )}
       {lightbox && (
         <div className="lightbox" onClick={() => setLightbox(null)}>
           <button className="lbx" type="button" onClick={() => setLightbox(null)}>×</button>

+ 11 - 5
app/frontend/src/pages/DecodeItemDetail.jsx

@@ -239,7 +239,7 @@ function PayloadModal({ payload, onClose }) {
   )
 }
 
-export default function DecodeItemDetail({ itemId }) {
+export default function DecodeItemDetail({ itemId, embedded = false, onClose }) {
   const [data, setData] = useState(null)
   const [err, setErr] = useState('')
   const [jsonPayload, setJsonPayload] = useState(null)
@@ -275,20 +275,26 @@ export default function DecodeItemDetail({ itemId }) {
     return map
   }, [data])
 
-  if (err) return <div className="legacy-decode-page"><div className="empty">{err}</div></div>
-  if (!data) return <div className="legacy-decode-page"><div className="empty">加载中...</div></div>
+  const pageClass = `legacy-decode-page${embedded ? ' embedded-decode-page' : ''}`
+
+  if (err) return <div className={pageClass}><div className="empty">{err}</div></div>
+  if (!data) return <div className={pageClass}><div className="empty">加载中...</div></div>
 
   const particles = data.knowledge_particles || []
   const readText = data.decode_result?.read_result?.text || ''
   const pass = data.decode_result?.gate_result?.passed
 
   return (
-    <div className="legacy-decode-page">
+    <div className={pageClass}>
       <div className="app">
         <header className="top">
           <h1>创作知识的解构</h1>
           <div className="picker">
-            <a className="pill" href="#/">返回 Query Demo</a>
+            {embedded ? (
+              <button className="pill" type="button" onClick={onClose}>关闭弹窗</button>
+            ) : (
+              <a className="pill" href="#/">返回 Query Demo</a>
+            )}
             <span className="pill on">{data.item?.title || '真实单例'}<span className="n">{particles.length}颗</span></span>
             <span className="lab">状态:{data.decode_result?.status || 'unknown'} · {pass ? '通过创作闸' : '未通过创作闸'}</span>
           </div>

+ 90 - 0
app/frontend/src/styles.css

@@ -82,6 +82,7 @@ button.qdetail { font: inherit; cursor: pointer; }
 .qcreation { flex: 0 0 auto; font-size: 11.5px; font-weight: 700; border-radius: 9px; padding: 1px 7px; white-space: nowrap; }
 .qcreation.done { color: var(--ok); background: #e6f6ee; }
 .qcreation.pending { color: #9a6a00; background: #fff6df; }
+.qcreation.knowledge { color: #5b47bd; background: #f0edff; }
 .inline-results {
   margin: 0 10px 10px 31px;
   padding: 10px;
@@ -295,6 +296,39 @@ td.q { font-weight: 600; }
 .cls.yes { background: #e6f7ee; color: var(--ok); }
 .cls.no { background: #fbeaea; color: var(--bad); }
 .cls.none { background: #f0f0f3; color: var(--muted); font-weight: 600; }
+.reuse-tag {
+  align-self: flex-start;
+  border: 1px solid #d7cffb;
+  border-radius: 999px;
+  color: #6557d8;
+  font-size: 11.5px;
+  font-weight: 700;
+  padding: 3px 8px;
+  background: #f4f1ff;
+}
+.mode-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 5px 0 2px;
+}
+.mode-chip {
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 700;
+  padding: 2px 7px;
+  color: #4b5563;
+  background: #eef0f4;
+}
+.mode-chip.image_post { color: #6557d8; background: #f1efff; }
+.mode-chip.video_post { color: #111827; background: #eceff3; }
+.mode-chip.article { color: var(--ok); background: #e6f6ee; }
+.mode-chip.unsupported { color: var(--bad); background: #fbeaea; }
+.rawtype {
+  color: var(--muted);
+  font-size: 11px;
+}
 /* 小红书/公众号完整图文:卡片内只放缩略条,点击进大图阅读 */
 .thumbstrip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: thin; }
 .thumbbtn, .coverbtn { border: 0; padding: 0; background: transparent; cursor: zoom-in; flex: 0 0 auto; position: relative; }
@@ -328,6 +362,52 @@ td.q { font-weight: 600; }
   overflow: auto; white-space: pre-wrap; }
 .lane .meta { color: var(--muted); font-size: 11.5px; }
 .lane .fail { color: var(--bad); font-size: 12.5px; padding: 18px 4px; text-align: center; }
+.judge {
+  margin-top: 7px;
+  color: #555;
+  font-size: 11.8px;
+  line-height: 1.5;
+}
+.judge.yes { color: #0f7a50; }
+.judge.no { color: var(--bad); }
+.judge.none { color: var(--muted); }
+.detail-button {
+  border: 1px solid #d8eadf;
+  background: #e6f6ee;
+  color: var(--ok);
+  border-radius: 8px;
+  padding: 4px 10px;
+  cursor: pointer;
+  font: inherit;
+  font-weight: 700;
+}
+.detail-button span {
+  margin-left: 6px;
+  opacity: .78;
+  font-weight: 600;
+}
+.detail-button:hover {
+  background: #d9f1e6;
+}
+
+.decode-modal-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 80;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 18px;
+  background: rgba(15, 16, 28, .52);
+}
+.decode-modal-panel {
+  width: min(1180px, calc(100vw - 36px));
+  height: min(88vh, 920px);
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 22px 70px rgba(0, 0, 0, .34);
+  background: #f5f5f7;
+}
 
 /* 分页 */
 .pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; }
@@ -369,6 +449,7 @@ td.q { font-weight: 600; }
 .status.partial, .status.running { background: #fff6df; color: #9a6a00; }
 .status.failed { background: #fbeaea; color: var(--bad); }
 .src { display: inline-block; margin-top: 8px; font-size: 12px; }
+.reuse-src { color: #6557d8; font-weight: 700; }
 .detail-link { margin-left: 8px; font-weight: 700; }
 /* 平台区内:创作知识 / 非创作知识 小标题(各自起一行) */
 .subhd { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700;
@@ -639,6 +720,14 @@ td.q { font-weight: 600; }
   color: #111827;
   font: 13px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
 }
+.legacy-decode-page.embedded-decode-page {
+  height: 100%;
+  min-height: 0;
+  border-radius: 12px;
+}
+.legacy-decode-page.embedded-decode-page .app {
+  height: 100%;
+}
 .legacy-decode-page * { box-sizing: border-box; }
 .legacy-decode-page a { color: #2563eb; text-decoration: none; }
 .legacy-decode-page a:hover { text-decoration: underline; }
@@ -683,6 +772,7 @@ td.q { font-weight: 600; }
   background: #fff;
   color: #6b7280;
   cursor: pointer;
+  font-family: inherit;
 }
 .legacy-decode-page .pill:hover {
   background: #f3f4f6;

+ 24 - 0
app/routes/acquisition.py

@@ -38,8 +38,29 @@ def _normalize_query_detail(detail: dict[str, Any]) -> dict[str, Any]:
     for row in detail.get("classifications") or []:
         classification_by_item.setdefault(row["item_id"], row)
 
+    decode_by_item: dict[Any, dict[str, Any]] = {}
+    for row in detail.get("decode_summaries") or []:
+        decode_by_item.setdefault(row["item_id"], row)
+
     items: list[dict[str, Any]] = []
     platforms: dict[str, dict[str, Any]] = {}
+    for raw_job in detail.get("jobs") or []:
+        job = _model_dump(raw_job)
+        platform = job.get("platform")
+        if not platform:
+            continue
+        platforms.setdefault(
+            platform,
+            {
+                "platform": platform,
+                "status": job.get("status") or "pending",
+                "attempt_count": job.get("attempt_count"),
+                "display_limit": job.get("display_limit"),
+                "search_limit": job.get("search_limit"),
+                "error_message": job.get("error_message"),
+                "items": [],
+            },
+        )
     for row in detail.get("items") or []:
         item = _model_dump(row)
         item["media_assets"] = [
@@ -52,12 +73,15 @@ def _normalize_query_detail(detail: dict[str, Any]) -> dict[str, Any]:
             if cls
             else None
         )
+        item["decode_summary"] = _model_dump(decode_by_item[item["id"]]) if item["id"] in decode_by_item else None
         payload = CandidateItemSchema.model_validate(item).model_dump(mode="json")
         items.append(payload)
         group = platforms.setdefault(
             payload["platform"],
             {"platform": payload["platform"], "status": "done", "items": []},
         )
+        if group.get("status") in {None, "pending"}:
+            group["status"] = "done"
         group["items"].append(payload)
     return {
         "query": QuerySchema.model_validate(detail["query"]).model_dump(mode="json"),

+ 24 - 1
app/routes/query_generation.py

@@ -2,11 +2,13 @@
 from __future__ import annotations
 
 from typing import Any
+from uuid import UUID
 
 from fastapi import APIRouter, Depends, Query
 
 from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
 from app.dependencies import _env_file, get_acquisition_repository
+from app.routes.acquisition import _normalize_query_detail
 from core.config import Settings
 
 router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
@@ -29,7 +31,8 @@ def _summary(generated: dict[str, Any]) -> dict[str, Any]:
 
 @router.get("/preview")
 def query_generation_preview(
-    per: int = Query(default=30, ge=1, le=100),
+    per: int = Query(default=0, ge=0, le=10000),
+    batch_n: int = Query(default=0, ge=0, le=1000),
     dry: bool = Query(default=True),
 ) -> dict[str, Any]:
     """Preview the currently active formal query families without writing DB rows."""
@@ -40,6 +43,7 @@ def query_generation_preview(
         tree_path=TREES,
         options=QueryBuildOptions(
             per=per,
+            batch_n=batch_n,
             dry=dry,
             active_family_keys=("f1", "f2"),
         ),
@@ -56,3 +60,22 @@ def latest_singleton_overview(repo: Any = Depends(get_acquisition_repository)) -
     if getter is None:
         return {"batch": None, "run": None, "queries": [], "decoded_items": []}
     return getter()
+
+
+@router.get("/latest/queries/{query_id}")
+def latest_query_detail(
+    query_id: UUID,
+    repo: Any = Depends(get_acquisition_repository),
+) -> dict[str, Any]:
+    """Return search material for one query in the latest real query board batch."""
+
+    overview_getter = getattr(repo, "get_latest_singleton_overview", None)
+    detail_getter = getattr(repo, "get_query_detail_for_batch", None)
+    if overview_getter is None or detail_getter is None:
+        return {"query": None, "jobs": [], "items": [], "platforms": {}}
+    overview = overview_getter()
+    batch = overview.get("batch")
+    batch_id = batch.get("id") if isinstance(batch, dict) else getattr(batch, "id", None)
+    if batch_id is None:
+        return {"query": None, "jobs": [], "items": [], "platforms": {}}
+    return _normalize_query_detail(detail_getter(batch_id=batch_id, query_id=query_id))

Неке датотеке нису приказане због велике количине промена