|
@@ -5,6 +5,12 @@ const AXIS_LABEL = {
|
|
|
形式: '形式(3-4层)',
|
|
形式: '形式(3-4层)',
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const PLATFORM_LABEL = {
|
|
|
|
|
+ xiaohongshu: ['小红书', 'xhs'],
|
|
|
|
|
+ weixin: ['微信公众号', 'wx'],
|
|
|
|
|
+ douyin: ['抖音', 'dy'],
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function params() {
|
|
function params() {
|
|
|
return new URLSearchParams(window.location.search)
|
|
return new URLSearchParams(window.location.search)
|
|
|
}
|
|
}
|
|
@@ -33,6 +39,27 @@ function platformCounts(row) {
|
|
|
return ['xiaohongshu', 'weixin', 'douyin'].map((key) => platforms[key]?.status || '未跑').join(' / ')
|
|
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) {
|
|
function uniqueAxisValues(items, axis) {
|
|
|
const seen = new Set()
|
|
const seen = new Set()
|
|
|
const out = []
|
|
const out = []
|
|
@@ -85,39 +112,136 @@ function singletonMaps(singleton) {
|
|
|
return byQueryText
|
|
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 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)}` : ''
|
|
const decodeHref = decoded ? `#/decode-item/${encodeURIComponent(decoded.item_id)}` : ''
|
|
|
return (
|
|
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() {
|
|
function QueryGenerationPreview() {
|
|
|
const [data, setData] = useState(null)
|
|
const [data, setData] = useState(null)
|
|
|
const [singleton, setSingleton] = useState(null)
|
|
const [singleton, setSingleton] = useState(null)
|
|
|
const [activeKey, setActiveKey] = useState('f1')
|
|
const [activeKey, setActiveKey] = useState('f1')
|
|
|
|
|
+ const [expandedQueryId, setExpandedQueryId] = useState('')
|
|
|
|
|
+ const [detailCache, setDetailCache] = useState({})
|
|
|
const [err, setErr] = 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(() => {
|
|
useEffect(() => {
|
|
|
setErr('')
|
|
setErr('')
|
|
|
Promise.all([
|
|
Promise.all([
|
|
@@ -136,6 +260,10 @@ function QueryGenerationPreview() {
|
|
|
.catch((e) => setErr(e.message || '读取失败'))
|
|
.catch((e) => setErr(e.message || '读取失败'))
|
|
|
}, [])
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setExpandedQueryId('')
|
|
|
|
|
+ }, [activeKey])
|
|
|
|
|
+
|
|
|
if (err) return <div className="empty">{err}</div>
|
|
if (err) return <div className="empty">{err}</div>
|
|
|
if (!data) return <div className="empty">加载中...</div>
|
|
if (!data) return <div className="empty">加载中...</div>
|
|
|
|
|
|
|
@@ -176,15 +304,21 @@ function QueryGenerationPreview() {
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="axlist">
|
|
<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>
|
|
</div>
|
|
|
</div>
|
|
</div>
|