|
|
@@ -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>
|