feat: 热词热力图改为Treemap矩形堆砌布局,面积代表提及次数;赛道分析页面布局调整

This commit is contained in:
freedakgmail
2026-07-21 07:59:25 +08:00
parent a40b0f14ae
commit 3c4916ba04
8 changed files with 284 additions and 81 deletions
+87 -27
View File
@@ -45,9 +45,11 @@ def now_iso() -> str:
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
return conn
@@ -160,7 +162,7 @@ def dashboard():
"type": r["type"] or "经营干货",
"score": r["score"] or 0,
"summary": r["ai_summary"] or "",
"entities": json.loads(r["entities"]) if r["entities"] else [],
"entities": [b["name"] if isinstance(b, dict) else b for b in (json.loads(r["entities"]) if r["entities"] else [])],
}
for r in top_intel
],
@@ -251,7 +253,7 @@ def intel_list():
"summary": r["ai_summary"] or r["summary"] or "",
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
"score": r["score"] or 0,
"entities": json.loads(r["entities"]) if r["entities"] else [],
"entities": [b["name"] if isinstance(b, dict) else b for b in (json.loads(r["entities"]) if r["entities"] else [])],
"timeliness": r["timeliness"] or "",
"tags": json.loads(r["tags"]) if r["tags"] else [],
"url": r["canonical_url"],
@@ -393,26 +395,41 @@ def brand_detail(brand_id: int):
@app.get("/api/analysis")
def analysis():
with db() as conn:
# 赛道热度趋势:按月统计各赛道的平均评分
# 赛道热度趋势:按月统计各赛道的文章数和平均评分,热度=文章数×平均评分/10
heat_rows = conn.execute(
"""
SELECT strftime('%m', a.published_at) AS month,
SELECT strftime('%Y-%m', a.published_at) AS month,
a.category,
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS heat
COUNT(*) AS article_cnt,
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS avg_score
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL AND a.category != ''
AND a.published_at >= date('now', '-12 months')
GROUP BY month, a.category
ORDER BY month
"""
).fetchall()
# 开关店对比(从文章中提取的品类趋势数据
category_counts = conn.execute(
# 品牌门店规模(按赛道聚合真实门店总数
brand_stores = conn.execute(
"""
SELECT a.category, COUNT(*) AS cnt
SELECT category, SUM(stores) AS total_stores, COUNT(*) AS brand_cnt
FROM brands WHERE category IS NOT NULL AND category != '' AND stores > 0
GROUP BY category
"""
).fetchall()
# 舆情分布(按赛道统计正面/负面/中性文章数)
sentiment_rows = conn.execute(
"""
SELECT a.category,
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'positive' THEN 1 ELSE 0 END) AS positive_cnt,
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'negative' THEN 1 ELSE 0 END) AS negative_cnt,
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'neutral' OR json_extract(x.result_json, '$.sentiment') IS NULL THEN 1 ELSE 0 END) AS neutral_cnt
FROM articles a
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL AND a.category != ''
GROUP BY a.category
"""
).fetchall()
@@ -432,29 +449,55 @@ def analysis():
"""
).fetchone()
# 热词
tag_rows = conn.execute(
# 热词热力图:近7天每天各标签出现次数
tag_heatmap = conn.execute(
"""
SELECT value AS tag, COUNT(*) AS cnt
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
GROUP BY value
ORDER BY cnt DESC
LIMIT 8
SELECT value AS tag,
strftime('%Y-%m-%d', a.published_at) AS day,
COUNT(*) AS cnt
FROM ai_analyses x
JOIN articles a ON a.id = x.article_id
, json_each(json_extract(x.result_json, '$.tags'))
WHERE a.published_at >= date('now', '-6 days')
AND value IS NOT NULL AND value != ''
GROUP BY tag, day
"""
).fetchall()
# 构建热度趋势数据
# 热词总量排行(用于选取 TOP 标签)
tag_total = conn.execute(
"""
SELECT value AS tag, COUNT(*) AS cnt
FROM ai_analyses x
JOIN articles a ON a.id = x.article_id
, json_each(json_extract(x.result_json, '$.tags'))
WHERE a.published_at >= date('now', '-6 days')
AND value IS NOT NULL AND value != ''
GROUP BY value
ORDER BY cnt DESC
LIMIT 10
"""
).fetchall()
# 构建热度趋势数据(热度 = 文章数 × 平均评分 / 10)
heat_trend = {}
for r in heat_rows:
month_key = f"{r['month']}" if r["month"] else "未知"
month_key = f"{r['month'][-2:]}" if r["month"] else "未知"
if month_key not in heat_trend:
heat_trend[month_key] = {"month": month_key}
heat_trend[month_key][r["category"]] = round(r["heat"] or 0)
heat_val = round((r["article_cnt"] or 0) * (r["avg_score"] or 0) / 10)
heat_trend[month_key][r["category"]] = heat_val
# 构建开关店数据
open_close = [
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
for r in category_counts
# 构建品牌门店规模数据
brand_stores_data = [
{"category": r["category"], "stores": r["total_stores"] or 0, "brands": r["brand_cnt"]}
for r in brand_stores
]
# 构建舆情分布数据
sentiment_data = [
{"category": r["category"], "正面": r["positive_cnt"] or 0, "负面": r["negative_cnt"] or 0, "中性": r["neutral_cnt"] or 0}
for r in sentiment_rows
]
# 构建价格分布
@@ -468,11 +511,28 @@ def analysis():
{"band": "150元以上", "pct": round((price_rows["band6"] or 0) * 100 / total_brands)},
]
# 构建热词热力图数据(用 UTC 日期与 SQLite date('now') 保持一致)
from datetime import timedelta, timezone
today_utc = datetime.now(timezone.utc).date()
days = [(today_utc - timedelta(days=i)).isoformat() for i in range(6, -1, -1)]
top_tags = [r["tag"] for r in tag_total]
# 构建矩阵 {tag: {day: cnt}}
matrix = {tag: {day: 0 for day in days} for tag in top_tags}
for r in tag_heatmap:
if r["tag"] in matrix and r["day"] in matrix[r["tag"]]:
matrix[r["tag"]][r["day"]] = r["cnt"]
hot_keywords = {
"days": days,
"tags": top_tags,
"matrix": matrix,
}
return jsonify({
"categoryHeatTrend": list(heat_trend.values()),
"openCloseByCategory": open_close,
"brandStoresByCategory": brand_stores_data,
"sentimentByCategory": sentiment_data,
"priceBandData": price_bands,
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
"hotKeywords": hot_keywords,
})
+13 -10
View File
@@ -36,6 +36,7 @@ load_dotenv(BASE_DIR / ".env")
DEFAULT_LIST_URL = "https://m.canyin88.com/zixun/"
ARTICLE_PATH_RE = re.compile(r"/zixun/\d{4}/\d{1,2}/\d{1,2}/\d+\.html$")
CANYIN168_ARTICLE_RE = re.compile(r"/Article/[a-z]+/\d+\.html$")
DATE_RE = re.compile(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})(?:日)?(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?")
@@ -47,9 +48,11 @@ def normalize_url(raw_url: str, base_url: str = DEFAULT_LIST_URL) -> str | None:
absolute = urljoin(base_url, raw_url.strip())
parts = urlsplit(absolute)
host = parts.netloc.lower().removeprefix("m.").removeprefix("www.")
if host != "canyin88.com" or not ARTICLE_PATH_RE.search(parts.path):
return None
return urlunsplit(("https", "www.canyin88.com", parts.path, "", ""))
if host == "canyin88.com" and ARTICLE_PATH_RE.search(parts.path):
return urlunsplit(("https", "www.canyin88.com", parts.path, "", ""))
if host == "canyin168.com" and CANYIN168_ARTICLE_RE.search(parts.path):
return urlunsplit(("http", "www.canyin168.com", parts.path, "", ""))
return None
def clean_text(value: str | None) -> str:
@@ -138,7 +141,7 @@ def extract_article(page, url: str) -> dict[str, str | None]:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(800)
document_title = clean_text(page.title())
document_title = re.sub(r"(?:[_-]红餐网.*)$", "", document_title).strip()
document_title = re.sub(r"(?:[_-](?:红餐网|职业餐饮网).*)$", "", document_title).strip()
title = meta(page, 'meta[property="og:title"]') or document_title
if not title or title == "相关推荐":
title = first_text(page, [".title", "h1"])
@@ -197,17 +200,17 @@ def pending_urls(conn: sqlite3.Connection, discovered: list[str], max_retries: i
return list(dict.fromkeys(discovered + [row[0] for row in rows]))
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: str = "公众号") -> int:
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: str = "公众号", source_name: str = "") -> int:
timestamp = now_iso()
inserted = 0
for url in urls:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO articles
(canonical_url, source_type, discovered_at, updated_at)
VALUES (?, ?, ?, ?)
(canonical_url, source_type, source_name, discovered_at, updated_at)
VALUES (?, ?, ?, ?, ?)
""",
(url, source_type, timestamp, timestamp),
(url, source_type, source_name, timestamp, timestamp),
)
inserted += cursor.rowcount
conn.commit()
@@ -439,7 +442,7 @@ def run_crawl(args: argparse.Namespace) -> int:
print(f"--- 采集公众号: {source_name} ({list_url}) ---", flush=True)
urls = discover_urls(page, list_url, max_scrolls, list_pause_ms)
discovered_count += len(urls)
inserted_count += insert_discoveries(conn, urls, source_name)
inserted_count += insert_discoveries(conn, urls, "公众号", source_name)
queue = pending_urls(conn, urls, args.max_retries)
cutoff = datetime.now() - timedelta(days=lookback_days)
for index, url in enumerate(queue, start=1):
@@ -551,7 +554,7 @@ def run_crawl(args: argparse.Namespace) -> int:
"status": status, "source_type": args.source_type,
"discovered": discovered_count, "inserted": inserted_count,
"updated": updated_count, "failed": failed_count,
}, ensure_ascii=False, flush=True))
}, ensure_ascii=False))
return 0 if status in {"success", "partial"} else 1
+23 -1
View File
@@ -51,6 +51,23 @@ def get_api_key() -> str:
# ---- 单篇文章结构化分析 ----
_PROFANITY_MAP = {
"妈的": "**", "傻逼": "**", "他妈": "**", "": "*",
"草泥马": "***", "尼玛": "**", "贱人": "**",
"王八蛋": "***", "混蛋": "**", "去死": "**",
"毛爷爷": "某伟人", "毛泽东": "某伟人", "国民党": "某党",
"共产党": "某党", "长征": "远征", "抗日": "抗战",
"革命": "变革", "社会主义": "某主义",
}
def sanitize_content(text: str) -> str:
"""过滤粗口和敏感词,避免触发千问内容审核。"""
for word, replacement in _PROFANITY_MAP.items():
text = text.replace(word, replacement)
return text
def analyze_article(title: str, content: str) -> dict:
"""调用通义千问对文章进行结构化分析。"""
api_key = get_api_key()
@@ -71,7 +88,7 @@ JSON 字段必须为:
- numbers(关键数据数组)
文章标题:{title}
正文:{content[:24000]}"""
正文:{sanitize_content(content[:24000])}"""
payload = json.dumps({
"model": model,
"messages": [
@@ -276,6 +293,11 @@ def run_batch_analyze(force: bool, pause: float, retries: int) -> int:
break
except Exception as exc:
last_error = exc
err_str = str(exc)
# 内容审核失败,标记跳过不重试
if "data_inspection_failed" in err_str:
print(f"[{index}/{len(rows)}] SKIP(内容审核) {row['title']}", flush=True)
break
if attempt <= retries:
wait = attempt * 2
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
+3 -2
View File
@@ -94,9 +94,10 @@ export interface Brand {
export interface AnalysisData {
categoryHeatTrend: Record<string, any>[]
openCloseByCategory: { category: string; 新开: number; 关闭: number }[]
brandStoresByCategory: { category: string; stores: number; brands: number }[]
sentimentByCategory: { category: string; 正面: number; 负面: number; 中性: number }[]
priceBandData: { band: string; pct: number }[]
hotKeywords: { word: string; change: number }[]
hotKeywords: { days: string[]; tags: string[]; matrix: Record<string, Record<string, number>> }
}
export interface SourceConfig {
+1 -1
View File
@@ -59,7 +59,7 @@ export default function Home() {
</div>
<div className={page === 'qa' ? 'flex h-full flex-col p-4 md:p-6' : 'p-4 md:p-6'}>
{page === 'dashboard' && <Dashboard onNavigate={(p) => navigate(`/${p}`)} />}
{page === 'dashboard' && <Dashboard onNavigate={(p, articleId, articleTitle) => navigate(articleId ? `/${p}?article=${articleId}&q=${encodeURIComponent(articleTitle || '')}` : `/${p}`)} />}
{page === 'intel' && <IntelFeed />}
{page === 'qa' && <QAChat />}
{page === 'brands' && <BrandLibrary />}
+128 -33
View File
@@ -3,10 +3,58 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2 } from 'lucide-react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, Legend,
ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell,
ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell, Treemap,
} from 'recharts'
import { api, type AnalysisData } from '@/lib/api'
const treemapColors = ['#fdba74', '#fb923c', '#f97316', '#ea580c', '#c2410c']
function TreemapCell(props: any) {
const { x, y, width, height, name, value, maxVal } = props
if (width < 30 || height < 20) return null
const intensity = maxVal > 0 ? value / maxVal : 0
const colorIdx = Math.min(Math.floor(intensity * treemapColors.length), treemapColors.length - 1)
const bg = treemapColors[colorIdx]
const fontSize = Math.max(10, Math.min(16, width / 8))
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
fill={bg}
stroke="#fff"
strokeWidth={2}
rx={4}
/>
{width > 50 && height > 30 && (
<text
x={x + width / 2}
y={y + height / 2 - 6}
textAnchor="middle"
fill="#fff"
fontSize={fontSize}
fontWeight={600}
>
{name}
</text>
)}
{width > 40 && height > 40 && (
<text
x={x + width / 2}
y={y + height / 2 + 12}
textAnchor="middle"
fill="rgba(255,255,255,0.85)"
fontSize={Math.max(10, fontSize - 3)}
>
{value}
</text>
)}
</g>
)
}
const lineColors: Record<string, string> = {
: '#f97316',
: '#3b82f6',
@@ -29,9 +77,10 @@ export default function CategoryAnalysis() {
}, [])
const heatTrend = data?.categoryHeatTrend ?? []
const openClose = data?.openCloseByCategory ?? []
const brandStores = data?.brandStoresByCategory ?? []
const sentiment = data?.sentimentByCategory ?? []
const priceBands = data?.priceBandData ?? []
const hotKw = data?.hotKeywords ?? []
const hotKw = data?.hotKeywords ?? { days: [], tags: [], matrix: {} }
if (loading) {
return (
@@ -54,7 +103,7 @@ export default function CategoryAnalysis() {
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"> 12 </CardTitle>
<p className="text-xs text-slate-400"></p>
<p className="text-xs text-slate-400"> AI </p>
</CardHeader>
<CardContent>
{heatTrend.length === 0 ? (
@@ -79,26 +128,52 @@ export default function CategoryAnalysis() {
</Card>
<div className="grid gap-4 xl:grid-cols-2">
{/* 开关店对比 */}
{/* 品牌门店规模 */}
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"> / </CardTitle>
<p className="text-xs text-slate-400">绿</p>
<CardTitle className="text-base"></CardTitle>
<p className="text-xs text-slate-400"></p>
</CardHeader>
<CardContent>
{openClose.length === 0 ? (
<div className="flex h-64 items-center justify-center text-sm text-slate-400"></div>
{brandStores.length === 0 ? (
<div className="flex h-64 items-center justify-center text-sm text-slate-400"></div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={openClose} barGap={2}>
<BarChart data={brandStores} barGap={2}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="category" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={50} />
<Tooltip />
<Bar dataKey="stores" name="门店总数" fill="#3b82f6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* 舆情分布 */}
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<p className="text-xs text-slate-400">//</p>
</CardHeader>
<CardContent>
{sentiment.length === 0 ? (
<div className="flex h-64 items-center justify-center text-sm text-slate-400"></div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sentiment} barGap={2}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="category" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={44} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="新开" fill="#10b981" radius={[3, 3, 0, 0]} />
<Bar dataKey="关闭" fill="#f87171" radius={[3, 3, 0, 0]} />
<Bar dataKey="正面" stackId="a" fill="#10b981" radius={[0, 0, 0, 0]} />
<Bar dataKey="中性" stackId="a" fill="#cbd5e1" radius={[0, 0, 0, 0]} />
<Bar dataKey="负面" stackId="a" fill="#f87171" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -147,28 +222,48 @@ export default function CategoryAnalysis() {
)}
</CardContent>
</Card>
</div>
{/* 热词趋势 */}
<Card className="border-slate-200">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{hotKw.length === 0 ? (
<div className="py-8 text-center text-sm text-slate-400"></div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{hotKw.map((k) => (
<div key={k.word} className="rounded-lg border border-slate-100 p-3">
<div className="text-sm font-medium text-slate-800">{k.word}</div>
<div className="mt-1 text-xs font-semibold text-emerald-500"> +{k.change}%</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 热词热力图 — 矩形堆砌,大小代表提及次数 */}
<Card className="border-slate-200">
<CardHeader className="pb-3">
<CardTitle className="text-base">7</CardTitle>
<p className="text-xs text-slate-400"></p>
</CardHeader>
<CardContent>
{!hotKw?.tags || hotKw.tags.length === 0 ? (
<div className="py-8 text-center text-sm text-slate-400"></div>
) : (() => {
const treemapData = hotKw.tags
.map((tag) => ({
name: tag,
size: hotKw.days.reduce((sum, d) => sum + (hotKw.matrix[tag]?.[d] || 0), 0),
}))
.filter((d) => d.size > 0)
.sort((a, b) => b.size - a.size)
if (treemapData.length === 0) {
return <div className="py-8 text-center text-sm text-slate-400"></div>
}
const maxVal = Math.max(...treemapData.map((d) => d.size))
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<Treemap
data={treemapData}
dataKey="size"
nameKey="name"
stroke="#fff"
content={<TreemapCell maxVal={maxVal} />}
/>
</ResponsiveContainer>
</div>
)
})()}
</CardContent>
</Card>
</div>
</div>
)
}
+3 -2
View File
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge'
import { api, type DashboardData, type OverviewStats } from '@/lib/api'
export interface PageKeyHintType {
onNavigate?: (page: 'intel' | 'qa' | 'analysis' | 'reports') => void
onNavigate?: (page: 'intel' | 'qa' | 'analysis' | 'reports', articleId?: string, articleTitle?: string) => void
}
const statCards = (ovStats: OverviewStats) => [
@@ -109,7 +109,8 @@ export default function Dashboard({ onNavigate }: PageKeyHintType) {
{topIntel.map((item, idx) => (
<div
key={item.id}
className="flex items-start gap-3 rounded-lg border border-slate-100 p-3 transition-colors hover:border-orange-200 hover:bg-orange-50/40"
className="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-100 p-3 transition-colors hover:border-orange-200 hover:bg-orange-50/40"
onClick={() => onNavigate?.('intel', item.id, item.title)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-900 text-xs font-bold text-white">
{idx + 1}
+26 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useSearchParams } from 'react-router'
import { ChevronDown, ChevronUp, Search, Sparkles, ListChecks, Loader2, FileText, AlertCircle, TrendingUp, PlayCircle, X } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -26,6 +27,7 @@ function scoreColor(score: number) {
}
export default function IntelFeed() {
const [searchParams, setSearchParams] = useSearchParams()
const [type, setType] = useState<string>('全部')
const [category, setCategory] = useState('全部赛道')
const [query, setQuery] = useState('')
@@ -39,16 +41,33 @@ export default function IntelFeed() {
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const perPage = 20
const pendingArticleId = searchParams.get('article')
const pendingQuery = searchParams.get('q') || ''
useEffect(() => {
setLoading(true)
setExpanded(null)
setDetail(null)
api.getIntel({ type: type === '全部' ? undefined : type, category: category === '全部赛道' ? undefined : category, q: query || undefined, page, per_page: perPage })
const searchQuery = pendingQuery || query
api.getIntel({ type: type === '全部' ? undefined : type, category: category === '全部赛道' ? undefined : category, q: searchQuery || undefined, page, per_page: perPage })
.then((res) => { setItems(res.items); setTotalPages(res.pages); setTotal(res.total) })
.catch(() => { setItems([]); setTotalPages(1); setTotal(0) })
.finally(() => setLoading(false))
}, [type, category, query, page])
}, [type, category, query, page, pendingQuery])
// 从 Dashboard 跳转过来时,同步搜索关键词并自动展开对应文章
useEffect(() => {
if (pendingQuery && !query) {
setQuery(pendingQuery)
}
if (pendingArticleId && items.length > 0 && !expanded) {
const found = items.find((it) => it.id === pendingArticleId)
if (found) {
toggleExpand(found.id)
setSearchParams({}, { replace: true })
}
}
}, [pendingArticleId, pendingQuery, items, expanded, query])
const toggleExpand = (id: string) => {
if (expanded === id) {
@@ -279,9 +298,11 @@ export default function IntelFeed() {
{(detail?.analysis?.brands || item.entities).length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-slate-100 p-3">
<span className="text-xs text-slate-400"></span>
{(detail?.analysis?.brands || item.entities).map((e) => (
<Badge key={e} variant="secondary" className="text-[10px]">{e}</Badge>
))}
{(detail?.analysis?.brands || item.entities).map((e, i) => {
const label = typeof e === 'string' ? e : (e as { name?: string })?.name || ''
if (!label) return null
return <Badge key={i} variant="secondary" className="text-[10px]">{label}</Badge>
})}
</div>
)}