feat: 热词热力图改为Treemap矩形堆砌布局,面积代表提及次数;赛道分析页面布局调整
This commit is contained in:
+87
-27
@@ -45,9 +45,11 @@ def now_iso() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def db() -> sqlite3.Connection:
|
def db() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout = 5000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ def dashboard():
|
|||||||
"type": r["type"] or "经营干货",
|
"type": r["type"] or "经营干货",
|
||||||
"score": r["score"] or 0,
|
"score": r["score"] or 0,
|
||||||
"summary": r["ai_summary"] or "",
|
"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
|
for r in top_intel
|
||||||
],
|
],
|
||||||
@@ -251,7 +253,7 @@ def intel_list():
|
|||||||
"summary": r["ai_summary"] or r["summary"] or "",
|
"summary": r["ai_summary"] or r["summary"] or "",
|
||||||
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
|
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
|
||||||
"score": r["score"] or 0,
|
"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 "中",
|
"timeliness": r["timeliness"] or "中",
|
||||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||||
"url": r["canonical_url"],
|
"url": r["canonical_url"],
|
||||||
@@ -393,26 +395,41 @@ def brand_detail(brand_id: int):
|
|||||||
@app.get("/api/analysis")
|
@app.get("/api/analysis")
|
||||||
def analysis():
|
def analysis():
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
# 赛道热度趋势:按月统计各赛道的平均评分
|
# 赛道热度趋势:按月统计各赛道的文章数和平均评分,热度=文章数×平均评分/10
|
||||||
heat_rows = conn.execute(
|
heat_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT strftime('%m', a.published_at) AS month,
|
SELECT strftime('%Y-%m', a.published_at) AS month,
|
||||||
a.category,
|
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
|
FROM articles a
|
||||||
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
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
|
GROUP BY month, a.category
|
||||||
ORDER BY month
|
ORDER BY month
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).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
|
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
|
GROUP BY a.category
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -432,29 +449,55 @@ def analysis():
|
|||||||
"""
|
"""
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# 热词
|
# 热词热力图:近7天每天各标签出现次数
|
||||||
tag_rows = conn.execute(
|
tag_heatmap = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT value AS tag, COUNT(*) AS cnt
|
SELECT value AS tag,
|
||||||
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
|
strftime('%Y-%m-%d', a.published_at) AS day,
|
||||||
GROUP BY value
|
COUNT(*) AS cnt
|
||||||
ORDER BY cnt DESC
|
FROM ai_analyses x
|
||||||
LIMIT 8
|
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()
|
).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 = {}
|
heat_trend = {}
|
||||||
for r in heat_rows:
|
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:
|
if month_key not in heat_trend:
|
||||||
heat_trend[month_key] = {"month": month_key}
|
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 = [
|
brand_stores_data = [
|
||||||
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
|
{"category": r["category"], "stores": r["total_stores"] or 0, "brands": r["brand_cnt"]}
|
||||||
for r in category_counts
|
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)},
|
{"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({
|
return jsonify({
|
||||||
"categoryHeatTrend": list(heat_trend.values()),
|
"categoryHeatTrend": list(heat_trend.values()),
|
||||||
"openCloseByCategory": open_close,
|
"brandStoresByCategory": brand_stores_data,
|
||||||
|
"sentimentByCategory": sentiment_data,
|
||||||
"priceBandData": price_bands,
|
"priceBandData": price_bands,
|
||||||
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
|
"hotKeywords": hot_keywords,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -36,6 +36,7 @@ load_dotenv(BASE_DIR / ".env")
|
|||||||
|
|
||||||
DEFAULT_LIST_URL = "https://m.canyin88.com/zixun/"
|
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$")
|
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}))?)?")
|
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())
|
absolute = urljoin(base_url, raw_url.strip())
|
||||||
parts = urlsplit(absolute)
|
parts = urlsplit(absolute)
|
||||||
host = parts.netloc.lower().removeprefix("m.").removeprefix("www.")
|
host = parts.netloc.lower().removeprefix("m.").removeprefix("www.")
|
||||||
if host != "canyin88.com" or not ARTICLE_PATH_RE.search(parts.path):
|
if host == "canyin88.com" and ARTICLE_PATH_RE.search(parts.path):
|
||||||
return None
|
|
||||||
return urlunsplit(("https", "www.canyin88.com", 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:
|
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.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
||||||
page.wait_for_timeout(800)
|
page.wait_for_timeout(800)
|
||||||
document_title = clean_text(page.title())
|
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
|
title = meta(page, 'meta[property="og:title"]') or document_title
|
||||||
if not title or title == "相关推荐":
|
if not title or title == "相关推荐":
|
||||||
title = first_text(page, [".title", "h1"])
|
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]))
|
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()
|
timestamp = now_iso()
|
||||||
inserted = 0
|
inserted = 0
|
||||||
for url in urls:
|
for url in urls:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO articles
|
INSERT OR IGNORE INTO articles
|
||||||
(canonical_url, source_type, discovered_at, updated_at)
|
(canonical_url, source_type, source_name, discovered_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(url, source_type, timestamp, timestamp),
|
(url, source_type, source_name, timestamp, timestamp),
|
||||||
)
|
)
|
||||||
inserted += cursor.rowcount
|
inserted += cursor.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -439,7 +442,7 @@ def run_crawl(args: argparse.Namespace) -> int:
|
|||||||
print(f"--- 采集公众号: {source_name} ({list_url}) ---", flush=True)
|
print(f"--- 采集公众号: {source_name} ({list_url}) ---", flush=True)
|
||||||
urls = discover_urls(page, list_url, max_scrolls, list_pause_ms)
|
urls = discover_urls(page, list_url, max_scrolls, list_pause_ms)
|
||||||
discovered_count += len(urls)
|
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)
|
queue = pending_urls(conn, urls, args.max_retries)
|
||||||
cutoff = datetime.now() - timedelta(days=lookback_days)
|
cutoff = datetime.now() - timedelta(days=lookback_days)
|
||||||
for index, url in enumerate(queue, start=1):
|
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,
|
"status": status, "source_type": args.source_type,
|
||||||
"discovered": discovered_count, "inserted": inserted_count,
|
"discovered": discovered_count, "inserted": inserted_count,
|
||||||
"updated": updated_count, "failed": failed_count,
|
"updated": updated_count, "failed": failed_count,
|
||||||
}, ensure_ascii=False, flush=True))
|
}, ensure_ascii=False))
|
||||||
return 0 if status in {"success", "partial"} else 1
|
return 0 if status in {"success", "partial"} else 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -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:
|
def analyze_article(title: str, content: str) -> dict:
|
||||||
"""调用通义千问对文章进行结构化分析。"""
|
"""调用通义千问对文章进行结构化分析。"""
|
||||||
api_key = get_api_key()
|
api_key = get_api_key()
|
||||||
@@ -71,7 +88,7 @@ JSON 字段必须为:
|
|||||||
- numbers(关键数据数组)
|
- numbers(关键数据数组)
|
||||||
|
|
||||||
文章标题:{title}
|
文章标题:{title}
|
||||||
正文:{content[:24000]}"""
|
正文:{sanitize_content(content[:24000])}"""
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -276,6 +293,11 @@ def run_batch_analyze(force: bool, pause: float, retries: int) -> int:
|
|||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = 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:
|
if attempt <= retries:
|
||||||
wait = attempt * 2
|
wait = attempt * 2
|
||||||
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
|
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
|
||||||
|
|||||||
+3
-2
@@ -94,9 +94,10 @@ export interface Brand {
|
|||||||
|
|
||||||
export interface AnalysisData {
|
export interface AnalysisData {
|
||||||
categoryHeatTrend: Record<string, any>[]
|
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 }[]
|
priceBandData: { band: string; pct: number }[]
|
||||||
hotKeywords: { word: string; change: number }[]
|
hotKeywords: { days: string[]; tags: string[]; matrix: Record<string, Record<string, number>> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceConfig {
|
export interface SourceConfig {
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={page === 'qa' ? 'flex h-full flex-col p-4 md:p-6' : 'p-4 md:p-6'}>
|
<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 === 'intel' && <IntelFeed />}
|
||||||
{page === 'qa' && <QAChat />}
|
{page === 'qa' && <QAChat />}
|
||||||
{page === 'brands' && <BrandLibrary />}
|
{page === 'brands' && <BrandLibrary />}
|
||||||
|
|||||||
@@ -3,10 +3,58 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, Legend,
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, Legend,
|
||||||
ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell,
|
ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell, Treemap,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { api, type AnalysisData } from '@/lib/api'
|
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> = {
|
const lineColors: Record<string, string> = {
|
||||||
茶饮咖啡: '#f97316',
|
茶饮咖啡: '#f97316',
|
||||||
快餐: '#3b82f6',
|
快餐: '#3b82f6',
|
||||||
@@ -29,9 +77,10 @@ export default function CategoryAnalysis() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const heatTrend = data?.categoryHeatTrend ?? []
|
const heatTrend = data?.categoryHeatTrend ?? []
|
||||||
const openClose = data?.openCloseByCategory ?? []
|
const brandStores = data?.brandStoresByCategory ?? []
|
||||||
|
const sentiment = data?.sentimentByCategory ?? []
|
||||||
const priceBands = data?.priceBandData ?? []
|
const priceBands = data?.priceBandData ?? []
|
||||||
const hotKw = data?.hotKeywords ?? []
|
const hotKw = data?.hotKeywords ?? { days: [], tags: [], matrix: {} }
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +103,7 @@ export default function CategoryAnalysis() {
|
|||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">赛道热度指数(近 12 个月)</CardTitle>
|
<CardTitle className="text-base">赛道热度指数(近 12 个月)</CardTitle>
|
||||||
<p className="text-xs text-slate-400">由情报提及量、开关店数据、消费关注度加权合成</p>
|
<p className="text-xs text-slate-400">由情报提及量与 AI 评分加权合成</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{heatTrend.length === 0 ? (
|
{heatTrend.length === 0 ? (
|
||||||
@@ -79,26 +128,52 @@ export default function CategoryAnalysis() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
{/* 开关店对比 */}
|
{/* 品牌门店规模 */}
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">上半年新开 / 关闭门店对比</CardTitle>
|
<CardTitle className="text-base">各赛道品牌门店规模</CardTitle>
|
||||||
<p className="text-xs text-slate-400">绿色为正增长缺口,红色为净收缩信号</p>
|
<p className="text-xs text-slate-400">按赛道聚合的品牌总门店数</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{openClose.length === 0 ? (
|
{brandStores.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-sm text-slate-400">暂无开关店数据</div>
|
<div className="flex h-64 items-center justify-center text-sm text-slate-400">暂无门店数据</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||||
<XAxis dataKey="category" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
<XAxis dataKey="category" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||||
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={44} />
|
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={44} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Bar dataKey="新开" fill="#10b981" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="正面" stackId="a" fill="#10b981" radius={[0, 0, 0, 0]} />
|
||||||
<Bar dataKey="关闭" fill="#f87171" radius={[3, 3, 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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,28 +222,48 @@ export default function CategoryAnalysis() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 热词趋势 */}
|
{/* 热词热力图 — 矩形堆砌,大小代表提及次数 */}
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">行业热词关注度周环比</CardTitle>
|
<CardTitle className="text-base">行业热词近7天热力图</CardTitle>
|
||||||
|
<p className="text-xs text-slate-400">矩形面积代表提及次数,颜色深浅表示热度高低</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{hotKw.length === 0 ? (
|
{!hotKw?.tags || hotKw.tags.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-slate-400">暂无热词数据</div>
|
<div className="py-8 text-center text-sm text-slate-400">暂无热词数据</div>
|
||||||
) : (
|
) : (() => {
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
const treemapData = hotKw.tags
|
||||||
{hotKw.map((k) => (
|
.map((tag) => ({
|
||||||
<div key={k.word} className="rounded-lg border border-slate-100 p-3">
|
name: tag,
|
||||||
<div className="text-sm font-medium text-slate-800">{k.word}</div>
|
size: hotKw.days.reduce((sum, d) => sum + (hotKw.matrix[tag]?.[d] || 0), 0),
|
||||||
<div className="mt-1 text-xs font-semibold text-emerald-500">▲ +{k.change}%</div>
|
}))
|
||||||
|
.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>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
})()}
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { api, type DashboardData, type OverviewStats } from '@/lib/api'
|
import { api, type DashboardData, type OverviewStats } from '@/lib/api'
|
||||||
|
|
||||||
export interface PageKeyHintType {
|
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) => [
|
const statCards = (ovStats: OverviewStats) => [
|
||||||
@@ -109,7 +109,8 @@ export default function Dashboard({ onNavigate }: PageKeyHintType) {
|
|||||||
{topIntel.map((item, idx) => (
|
{topIntel.map((item, idx) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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">
|
<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}
|
{idx + 1}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { ChevronDown, ChevronUp, Search, Sparkles, ListChecks, Loader2, FileText, AlertCircle, TrendingUp, PlayCircle, X } from 'lucide-react'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -26,6 +27,7 @@ function scoreColor(score: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function IntelFeed() {
|
export default function IntelFeed() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [type, setType] = useState<string>('全部')
|
const [type, setType] = useState<string>('全部')
|
||||||
const [category, setCategory] = useState('全部赛道')
|
const [category, setCategory] = useState('全部赛道')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -39,16 +41,33 @@ export default function IntelFeed() {
|
|||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const perPage = 20
|
const perPage = 20
|
||||||
|
const pendingArticleId = searchParams.get('article')
|
||||||
|
const pendingQuery = searchParams.get('q') || ''
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setExpanded(null)
|
setExpanded(null)
|
||||||
setDetail(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) })
|
.then((res) => { setItems(res.items); setTotalPages(res.pages); setTotal(res.total) })
|
||||||
.catch(() => { setItems([]); setTotalPages(1); setTotal(0) })
|
.catch(() => { setItems([]); setTotalPages(1); setTotal(0) })
|
||||||
.finally(() => setLoading(false))
|
.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) => {
|
const toggleExpand = (id: string) => {
|
||||||
if (expanded === id) {
|
if (expanded === id) {
|
||||||
@@ -279,9 +298,11 @@ export default function IntelFeed() {
|
|||||||
{(detail?.analysis?.brands || item.entities).length > 0 && (
|
{(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">
|
<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>
|
<span className="text-xs text-slate-400">关联品牌:</span>
|
||||||
{(detail?.analysis?.brands || item.entities).map((e) => (
|
{(detail?.analysis?.brands || item.entities).map((e, i) => {
|
||||||
<Badge key={e} variant="secondary" className="text-[10px]">{e}</Badge>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user