Browse Source

feat(frontend): show inline search results on query demo

SamLee 2 tuần trước cách đây
mục cha
commit
c29e3c8c2b

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 8
app/frontend/dist/assets/index-BH5EdYr0.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
app/frontend/dist/assets/index-C99ohPRA.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 8 - 0
app/frontend/dist/assets/index-D2QcI5kS.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
app/frontend/dist/assets/index-i7vXsfH2.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-BH5EdYr0.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-i7vXsfH2.css">
+    <script type="module" crossorigin src="/app/assets/index-D2QcI5kS.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-C99ohPRA.css">
   </head>
   <body>
     <div id="root"></div>

+ 164 - 30
app/frontend/src/pages/CreationDemo.jsx

@@ -5,6 +5,12 @@ const AXIS_LABEL = {
   形式: '形式(3-4层)',
 }
 
+const PLATFORM_LABEL = {
+  xiaohongshu: ['小红书', 'xhs'],
+  weixin: ['微信公众号', 'wx'],
+  douyin: ['抖音', 'dy'],
+}
+
 function params() {
   return new URLSearchParams(window.location.search)
 }
@@ -33,6 +39,27 @@ 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 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 uniqueAxisValues(items, axis) {
   const seen = new Set()
   const out = []
@@ -85,39 +112,136 @@ function singletonMaps(singleton) {
   return byQueryText
 }
 
-function PreviewQueryRow({ item, index, latest, singleton }) {
+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>
+  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>
+  )
+}
+
+function PreviewQueryRow({ item, index, latest, singleton, expanded, detailState, onToggle }) {
   const decoded = latest?.decoded_items?.[0]
-  const searchHref = latest && singleton?.run
-    ? `#/query/${encodeURIComponent(singleton.run.id)}/${encodeURIComponent(latest.query_id)}`
-    : ''
   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>
-          <a className="qdetail on" href={searchHref}>查看搜索结果</a>
-        </>
-      ) : (
-        <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>
+    <>
+      <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([
@@ -136,6 +260,10 @@ 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>
 
@@ -176,15 +304,21 @@ function QueryGenerationPreview() {
             </span>
           </div>
           <div className="axlist">
-            {(family.items || []).map((item, index) => (
-              <PreviewQueryRow
-                item={item}
-                index={index}
-                key={`${item.query}-${index}`}
-                latest={latestByQuery.get(item.query)}
-                singleton={singleton}
-              />
-            ))}
+            {(family.items || []).map((item, index) => {
+              const latest = latestByQuery.get(item.query)
+              return (
+                <PreviewQueryRow
+                  item={item}
+                  index={index}
+                  key={`${item.query}-${index}`}
+                  latest={latest}
+                  singleton={singleton}
+                  expanded={expandedQueryId === latest?.query_id}
+                  detailState={latest ? detailCache[latest.query_id] : null}
+                  onToggle={toggleResults}
+                />
+              )
+            })}
           </div>
         </div>
       </div>

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

@@ -74,12 +74,127 @@ h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 .qreason { flex: 1 0 100%; margin-left: 20px; color: var(--muted); font-size: 11.5px; }   /* 理由独占一行、占满宽 */
 .qdetail { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
   border-radius: 8px; padding: 2px 7px; font-size: 11.5px; color: var(--muted); background: #fff; }
+button.qdetail { font: inherit; cursor: pointer; }
 .qdetail.on { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
+.qdetail.active { color: #3f3fa8; border-color: #d9d9f4; background: #f4f4ff; font-weight: 700; }
 .qdetail.decode-on { color: var(--ok); border-color: #d8eadf; background: #e6f6ee; font-weight: 700; }
 .qdetail span { color: inherit; opacity: .8; }
 .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; }
+.inline-results {
+  margin: 0 10px 10px 31px;
+  padding: 10px;
+  border: 1px solid #e8e8ee;
+  border-radius: 10px;
+  background: #fbfbfd;
+}
+.inline-results.loading,
+.inline-results.error,
+.inline-results.empty-inline {
+  color: var(--muted);
+  font-size: 12px;
+}
+.inline-results.error { color: var(--bad); background: #fff7f7; }
+.inline-platform + .inline-platform { margin-top: 10px; }
+.inline-platform-hd {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  margin-bottom: 8px;
+  font-size: 12px;
+  font-weight: 700;
+}
+.inline-platform-hd.xhs { color: #ff2442; }
+.inline-platform-hd.wx { color: var(--ok); }
+.inline-platform-hd.dy { color: #111; }
+.inline-platform-hd .gn {
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--muted);
+  background: #f0f0f3;
+  border-radius: 10px;
+  padding: 1px 7px;
+}
+.inline-cards {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  gap: 8px;
+}
+.inline-card {
+  display: grid;
+  grid-template-columns: 74px minmax(0, 1fr);
+  gap: 10px;
+  align-items: start;
+  min-width: 0;
+  border: 1px solid var(--line);
+  border-radius: 9px;
+  background: #fff;
+  padding: 8px;
+}
+.inline-card.yes {
+  border-color: #cfeedd;
+  background: #fbfffd;
+}
+.inline-card.no {
+  border-color: #f0dddd;
+}
+.inline-thumb {
+  width: 74px;
+  height: 94px;
+  object-fit: cover;
+  object-position: top;
+  border-radius: 7px;
+  border: 1px solid var(--line);
+  background: #f0f0f3;
+}
+.empty-thumb {
+  display: grid;
+  place-items: center;
+  color: var(--muted);
+  font-size: 11px;
+}
+.inline-card-main { min-width: 0; }
+.inline-title {
+  font-size: 12.5px;
+  font-weight: 700;
+  line-height: 1.45;
+  color: var(--ink);
+  margin-bottom: 5px;
+}
+.inline-meta {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 5px;
+  color: var(--muted);
+  font-size: 11px;
+  margin-bottom: 5px;
+}
+.payload-chip {
+  color: var(--ok);
+  background: #e6f6ee;
+  border-radius: 9px;
+  padding: 1px 7px;
+  font-weight: 700;
+}
+.inline-reason {
+  color: #555;
+  font-size: 11.5px;
+  line-height: 1.5;
+  margin-bottom: 4px;
+}
+.inline-summary {
+  color: var(--muted);
+  font-size: 11.5px;
+  line-height: 1.55;
+}
+.inline-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 7px;
+}
 
 /* 顶部主导航 */
 .nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác