From fa13a9841ab0a8d56f66d2e61c976cc5f09977d3 Mon Sep 17 00:00:00 2001 From: didiwei Date: Wed, 11 Mar 2026 11:12:44 +0800 Subject: [PATCH] Add static Flask MVP dashboard with polished glass UI and read-only data APIs. This commit delivers a competition-ready three-page web shell using fixed MySQL data so demos stay stable without real-time crawler dependencies. Made-with: Cursor --- app.py | 2 + mvp_dashboard.py | 378 +++++++++++++++++++++++++++++++++ static/mvp/css/dashboard.css | 364 +++++++++++++++++++++++++++++++ static/mvp/js/common.js | 28 +++ static/mvp/js/heatmap.js | 92 ++++++++ static/mvp/js/hot_ranking.js | 117 ++++++++++ static/mvp/js/trend.js | 70 ++++++ templates/mvp/base.html | 35 +++ templates/mvp/heatmap.html | 29 +++ templates/mvp/hot_ranking.html | 56 +++++ templates/mvp/trend.html | 30 +++ 11 files changed, 1201 insertions(+) create mode 100644 mvp_dashboard.py create mode 100644 static/mvp/css/dashboard.css create mode 100644 static/mvp/js/common.js create mode 100644 static/mvp/js/heatmap.js create mode 100644 static/mvp/js/hot_ranking.js create mode 100644 static/mvp/js/trend.js create mode 100644 templates/mvp/base.html create mode 100644 templates/mvp/heatmap.html create mode 100644 templates/mvp/hot_ranking.html create mode 100644 templates/mvp/trend.html diff --git a/app.py b/app.py index 8ad9422f5..c57a5c3e5 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ import importlib from pathlib import Path from MindSpider.main import MindSpider +from mvp_dashboard import mvp_bp # 导入ReportEngine try: @@ -35,6 +36,7 @@ app = Flask(__name__) app.config['SECRET_KEY'] = 'Dedicated-to-creating-a-concise-and-versatile-public-opinion-analysis-platform' socketio = SocketIO(app, cors_allowed_origins="*") +app.register_blueprint(mvp_bp) # eventlet 在客户端主动断开时偶尔会抛出 ConnectionAbortedError,这里做一次防御性包裹, # 避免无意义的堆栈污染日志(仅在 eventlet 可用时启用)。 diff --git a/mvp_dashboard.py b/mvp_dashboard.py new file mode 100644 index 000000000..9e0935f77 --- /dev/null +++ b/mvp_dashboard.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +import pymysql +from flask import Blueprint, jsonify, render_template, request +from loguru import logger + +from config import settings + + +mvp_bp = Blueprint("mvp_dashboard", __name__, url_prefix="/mvp") + + +def _db_conn(): + return pymysql.connect( + host=settings.DB_HOST, + port=int(settings.DB_PORT), + user=settings.DB_USER, + password=settings.DB_PASSWORD, + database=settings.DB_NAME, + charset=settings.DB_CHARSET or "utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + ) + + +def _parse_int(value: Any) -> int: + if value is None: + return 0 + text = str(value).strip() + digits = "".join(ch for ch in text if ch.isdigit()) + return int(digits) if digits else 0 + + +def _default_date_range(days: int = 30) -> tuple[str, str]: + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days - 1) + return str(start_date), str(end_date) + + +def _safe_pagination(page: str | None, page_size: str | None) -> tuple[int, int]: + p = max(int(page or 1), 1) + s = min(max(int(page_size or 10), 1), 50) + return p, s + + +@mvp_bp.route("/") +def hot_page(): + return render_template("mvp/hot_ranking.html", page_key="hot") + + +@mvp_bp.route("/trend") +def trend_page(): + return render_template("mvp/trend.html", page_key="trend") + + +@mvp_bp.route("/heatmap") +def heatmap_page(): + return render_template("mvp/heatmap.html", page_key="heatmap") + + +@mvp_bp.route("/api/hot-events") +def hot_events(): + platform = (request.args.get("platform") or "weibo").strip().lower() + topic = (request.args.get("topic") or "").strip() + start_date = (request.args.get("start_date") or "").strip() + end_date = (request.args.get("end_date") or "").strip() + page, page_size = _safe_pagination(request.args.get("page"), request.args.get("page_size")) + offset = (page - 1) * page_size + + if not start_date or not end_date: + start_date, end_date = _default_date_range(30) + + if platform != "weibo": + return jsonify({"success": False, "message": "当前MVP仅支持 weibo 平台"}), 400 + + where = [ + "wn.create_date_time >= %s", + "wn.create_date_time <= %s", + ] + params: list[Any] = [f"{start_date} 00:00:00", f"{end_date} 23:59:59"] + if topic: + where.append("(dt.topic_name LIKE %s OR wn.source_keyword LIKE %s)") + params.extend([f"%{topic}%", f"%{topic}%"]) + + where_sql = " AND ".join(where) + try: + with _db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT + COUNT(*) AS total + FROM weibo_note wn + LEFT JOIN daily_topics dt ON dt.topic_id = wn.topic_id + WHERE {where_sql} + """, + params, + ) + total = int(cur.fetchone()["total"]) + + cur.execute( + f""" + SELECT + wn.note_id, + wn.nickname, + wn.content, + wn.create_date_time, + wn.source_keyword, + wn.liked_count, + wn.comments_count, + wn.shared_count, + wn.note_url, + COALESCE(dt.topic_name, '') AS topic_name + FROM weibo_note wn + LEFT JOIN daily_topics dt ON dt.topic_id = wn.topic_id + WHERE {where_sql} + ORDER BY wn.create_date_time DESC + LIMIT %s OFFSET %s + """, + params + [page_size, offset], + ) + rows = cur.fetchall() + except Exception as exc: + logger.exception(f"hot-events 查询失败: {exc}") + return jsonify({"success": False, "message": f"数据库查询失败: {exc}"}), 500 + + items = [] + for row in rows: + likes = _parse_int(row.get("liked_count")) + comments = _parse_int(row.get("comments_count")) + shares = _parse_int(row.get("shared_count")) + risk_index = min(100, int(likes * 0.08 + comments * 0.25 + shares * 0.3)) + items.append( + { + "note_id": row.get("note_id"), + "nickname": row.get("nickname") or "匿名用户", + "content": row.get("content") or "", + "create_time": row.get("create_date_time") or "", + "topic_name": row.get("topic_name") or row.get("source_keyword") or "未分类", + "liked_count": likes, + "comments_count": comments, + "shared_count": shares, + "risk_index": risk_index, + "note_url": row.get("note_url") or "", + "platform": "weibo", + } + ) + + return jsonify( + { + "success": True, + "data": items, + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": (total + page_size - 1) // page_size if total else 0, + }, + "filters": { + "platform": platform, + "topic": topic, + "start_date": start_date, + "end_date": end_date, + }, + } + ) + + +@mvp_bp.route("/api/ranking") +def ranking(): + topic = (request.args.get("topic") or "").strip() + start_date = (request.args.get("start_date") or "").strip() + end_date = (request.args.get("end_date") or "").strip() + top_n = min(max(int(request.args.get("top_n", "10")), 1), 20) + if not start_date or not end_date: + start_date, end_date = _default_date_range(30) + + where = ["wn.create_date_time >= %s", "wn.create_date_time <= %s"] + params: list[Any] = [f"{start_date} 00:00:00", f"{end_date} 23:59:59"] + if topic: + where.append("(dt.topic_name LIKE %s OR wn.source_keyword LIKE %s)") + params.extend([f"%{topic}%", f"%{topic}%"]) + where_sql = " AND ".join(where) + + try: + with _db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT + wn.note_id, + wn.nickname, + wn.content, + wn.create_date_time, + wn.liked_count, + wn.comments_count, + wn.shared_count, + COALESCE(dt.topic_name, '') AS topic_name + FROM weibo_note wn + LEFT JOIN daily_topics dt ON dt.topic_id = wn.topic_id + WHERE {where_sql} + ORDER BY wn.create_date_time DESC + LIMIT 300 + """, + params, + ) + rows = cur.fetchall() + except Exception as exc: + logger.exception(f"ranking 查询失败: {exc}") + return jsonify({"success": False, "message": f"数据库查询失败: {exc}"}), 500 + + scored = [] + for row in rows: + likes = _parse_int(row.get("liked_count")) + comments = _parse_int(row.get("comments_count")) + shares = _parse_int(row.get("shared_count")) + score = likes + comments * 2 + shares * 2 + scored.append( + { + "note_id": row.get("note_id"), + "nickname": row.get("nickname") or "匿名用户", + "content": row.get("content") or "", + "topic_name": row.get("topic_name") or "未分类", + "create_time": row.get("create_date_time") or "", + "heat_score": score, + "liked_count": likes, + "comments_count": comments, + "shared_count": shares, + } + ) + + scored.sort(key=lambda x: x["heat_score"], reverse=True) + return jsonify( + { + "success": True, + "data": scored[:top_n], + "filters": {"topic": topic, "start_date": start_date, "end_date": end_date, "top_n": top_n}, + } + ) + + +@mvp_bp.route("/api/trend-30d") +def trend_30d(): + topic = (request.args.get("topic") or "").strip() + start_date = (request.args.get("start_date") or "").strip() + end_date = (request.args.get("end_date") or "").strip() + if not start_date or not end_date: + start_date, end_date = _default_date_range(30) + + where = ["dt.extract_date >= %s", "dt.extract_date <= %s"] + params: list[Any] = [start_date, end_date] + if topic: + where.append("dt.topic_name LIKE %s") + params.append(f"%{topic}%") + where_sql = " AND ".join(where) + + try: + with _db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT + dt.extract_date AS report_date, + COUNT(*) AS topic_count, + AVG(COALESCE(dt.relevance_score, 0)) AS avg_relevance + FROM daily_topics dt + WHERE {where_sql} + GROUP BY dt.extract_date + ORDER BY dt.extract_date ASC + """, + params, + ) + rows = cur.fetchall() + except Exception as exc: + logger.exception(f"trend 查询失败: {exc}") + return jsonify({"success": False, "message": f"数据库查询失败: {exc}"}), 500 + + dates = [] + topic_counts = [] + risk_index = [] + for row in rows: + d = str(row["report_date"]) + cnt = int(row.get("topic_count") or 0) + rel = float(row.get("avg_relevance") or 0.0) + # 将 relevance 平滑映射到 0-100 风险指数 + risk = max(0, min(100, int(rel * 20 + cnt * 1.5))) + dates.append(d) + topic_counts.append(cnt) + risk_index.append(risk) + + return jsonify( + { + "success": True, + "data": {"dates": dates, "topic_counts": topic_counts, "risk_index": risk_index}, + "filters": {"topic": topic, "start_date": start_date, "end_date": end_date}, + } + ) + + +@mvp_bp.route("/api/heatmap-china") +def heatmap_china(): + start_date = (request.args.get("start_date") or "").strip() + end_date = (request.args.get("end_date") or "").strip() + if not start_date or not end_date: + start_date, end_date = _default_date_range(30) + + # 固定映射为中国省级名称,便于前端直接喂给ECharts地图 + province_alias = { + "北京": "北京市", + "上海": "上海市", + "天津": "天津市", + "重庆": "重庆市", + "内蒙古": "内蒙古自治区", + "广西": "广西壮族自治区", + "西藏": "西藏自治区", + "宁夏": "宁夏回族自治区", + "新疆": "新疆维吾尔自治区", + "香港": "香港特别行政区", + "澳门": "澳门特别行政区", + } + + try: + with _db_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + IFNULL(ip_location, '') AS ip_location, + liked_count, + comments_count, + shared_count + FROM weibo_note + WHERE create_date_time >= %s AND create_date_time <= %s + LIMIT 3000 + """, + [f"{start_date} 00:00:00", f"{end_date} 23:59:59"], + ) + rows = cur.fetchall() + except Exception as exc: + logger.exception(f"heatmap 查询失败: {exc}") + return jsonify({"success": False, "message": f"数据库查询失败: {exc}"}), 500 + + region_scores: dict[str, int] = {} + region_events: dict[str, int] = {} + for row in rows: + location = (row.get("ip_location") or "").replace("IP属地:", "").strip() + if not location: + continue + region = location[:2] + if location.startswith(("内蒙古", "黑龙江")): + region = location[:3] + if location.startswith(("新疆", "广西", "宁夏", "西藏")): + region = location[:2] + + region_name = province_alias.get(region, f"{region}省" if len(region) == 2 else region) + likes = _parse_int(row.get("liked_count")) + comments = _parse_int(row.get("comments_count")) + shares = _parse_int(row.get("shared_count")) + score = likes + comments * 2 + shares * 2 + + region_scores[region_name] = region_scores.get(region_name, 0) + score + region_events[region_name] = region_events.get(region_name, 0) + 1 + + max_score = max(region_scores.values()) if region_scores else 1 + heat_data = [] + for region_name, score in region_scores.items(): + normalized = int((score / max_score) * 100) + heat_data.append({"name": region_name, "value": normalized, "event_count": region_events[region_name]}) + + return jsonify( + { + "success": True, + "data": heat_data, + "filters": {"start_date": start_date, "end_date": end_date}, + } + ) diff --git a/static/mvp/css/dashboard.css b/static/mvp/css/dashboard.css new file mode 100644 index 000000000..3626544c0 --- /dev/null +++ b/static/mvp/css/dashboard.css @@ -0,0 +1,364 @@ +:root { + --bg-primary: #0a0f1f; + --bg-secondary: #11192f; + --text-main: #e8eefc; + --text-muted: #9eb1d6; + --accent: #74a6ff; + --accent-2: #67e8f9; + --success: #52d89c; + --danger: #ff6b8a; + --glass-bg: rgba(17, 25, 47, 0.58); + --glass-border: rgba(255, 255, 255, 0.16); + --radius-lg: 18px; + --radius-md: 12px; + --shadow-lg: 0 20px 45px rgba(0, 0, 0, 0.38); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--text-main); + background: radial-gradient(circle at 15% 20%, #1f2f56 0%, var(--bg-primary) 40%), + radial-gradient(circle at 90% 10%, #1a4b63 0%, transparent 40%), + var(--bg-primary); + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + min-height: 100vh; + overflow-x: hidden; +} + +.bg-layer { + position: fixed; + border-radius: 999px; + filter: blur(2px); + pointer-events: none; + z-index: -1; + animation: floatOrb 12s ease-in-out infinite; +} + +.bg-orb-1 { + width: 380px; + height: 380px; + left: -80px; + top: 40px; + background: rgba(116, 166, 255, 0.2); +} + +.bg-orb-2 { + width: 300px; + height: 300px; + right: 8%; + top: 6%; + background: rgba(103, 232, 249, 0.18); + animation-delay: 2s; +} + +.bg-orb-3 { + width: 260px; + height: 260px; + right: 12%; + bottom: 5%; + background: rgba(82, 216, 156, 0.15); + animation-delay: 4s; +} + +.glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.topbar { + margin: 18px 24px 0; + padding: 18px 24px; + border-radius: var(--radius-lg); + display: flex; + justify-content: space-between; + align-items: center; +} + +.brand { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.badge { + color: var(--accent-2); + border: 1px solid rgba(103, 232, 249, 0.35); + border-radius: 999px; + padding: 8px 14px; + font-size: 12px; +} + +.layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 20px; + padding: 20px 24px 28px; +} + +.sidebar { + border-radius: var(--radius-lg); + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 10px; + height: fit-content; +} + +.nav-item { + text-decoration: none; + color: var(--text-muted); + border: 1px solid transparent; + border-radius: 10px; + padding: 12px; + transition: all 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.nav-item:hover { + color: var(--text-main); + border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-2px); +} + +.nav-item.active { + color: white; + border-color: rgba(116, 166, 255, 0.5); + background: linear-gradient(135deg, rgba(116, 166, 255, 0.25), rgba(103, 232, 249, 0.14)); +} + +.content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.panel { + border-radius: var(--radius-lg); + padding: 18px; +} + +.panel-title { + font-size: 17px; + margin-bottom: 12px; + color: #f6f8ff; + font-weight: 600; +} + +.panel-grid { + display: grid; + gap: 20px; +} + +.hot-grid { + grid-template-columns: 1fr 1fr; +} + +.trend-grid { + grid-template-columns: 1fr 1fr; +} + +.heat-grid { + grid-template-columns: 1.3fr 0.7fr; +} + +.filter-row { + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.inline-filters { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +input, +button { + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border-radius: var(--radius-md); + padding: 10px 12px; + transition: all 180ms ease-out; +} + +input:focus { + outline: none; + border-color: rgba(116, 166, 255, 0.7); + box-shadow: 0 0 0 3px rgba(116, 166, 255, 0.2); +} + +.btn-primary { + cursor: pointer; + background: linear-gradient(135deg, rgba(116, 166, 255, 0.35), rgba(103, 232, 249, 0.22)); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 10px 18px rgba(59, 130, 246, 0.25); +} + +.btn-primary:active { + transform: translateY(0); +} + +.chart { + width: 100%; + height: 420px; +} + +.map-chart { + height: 520px; +} + +.table-wrap { + overflow-x: auto; +} + +.event-table { + width: 100%; + border-collapse: collapse; +} + +.event-table th, +.event-table td { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 10px 8px; + text-align: left; + font-size: 13px; +} + +.event-table th { + color: var(--accent-2); + font-weight: 600; +} + +.event-table td { + color: var(--text-main); +} + +.content-ellipsis { + max-width: 400px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.risk-badge { + border-radius: 999px; + padding: 4px 10px; + font-weight: 700; + display: inline-block; +} + +.risk-high { + color: #ffdce6; + background: rgba(255, 107, 138, 0.25); +} + +.risk-mid { + color: #fff3d1; + background: rgba(255, 194, 64, 0.22); +} + +.risk-low { + color: #d5ffec; + background: rgba(82, 216, 156, 0.2); +} + +.pager { + margin-top: 14px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + +.ranking-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.rank-item { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 10px; + background: rgba(255, 255, 255, 0.03); + transition: transform 200ms ease-out, border-color 200ms ease-out; +} + +.rank-item:hover { + transform: translateY(-3px); + border-color: rgba(116, 166, 255, 0.5); +} + +.rank-score { + color: var(--accent); + font-weight: 700; + margin-left: 8px; +} + +.fade-up { + opacity: 0; + transform: translateY(16px); + animation: fadeUp 300ms ease-out forwards; +} + +.skeleton { + height: 26px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.07)); + background-size: 200% 100%; + animation: shimmer 1.2s linear infinite; +} + +@keyframes fadeUp { + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +@keyframes floatOrb { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-16px); + } +} + +@media (max-width: 1100px) { + .layout { + grid-template-columns: 1fr; + } + + .sidebar { + flex-direction: row; + } + + .hot-grid, + .trend-grid, + .heat-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/mvp/js/common.js b/static/mvp/js/common.js new file mode 100644 index 000000000..65c0c5ae6 --- /dev/null +++ b/static/mvp/js/common.js @@ -0,0 +1,28 @@ +function formatDateInputDefaults(startInput, endInput, days = 30) { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - (days - 1)); + + const toDateString = (d) => d.toISOString().slice(0, 10); + if (startInput && !startInput.value) startInput.value = toDateString(startDate); + if (endInput && !endInput.value) endInput.value = toDateString(endDate); +} + +function showSkeleton(container, rows = 4) { + if (!container) return; + container.innerHTML = ""; + for (let i = 0; i < rows; i += 1) { + const div = document.createElement("div"); + div.className = "skeleton"; + container.appendChild(div); + } +} + +async function apiGet(url) { + const res = await fetch(url); + const data = await res.json(); + if (!res.ok || !data.success) { + throw new Error(data.message || "请求失败"); + } + return data; +} diff --git a/static/mvp/js/heatmap.js b/static/mvp/js/heatmap.js new file mode 100644 index 000000000..5da4bcd07 --- /dev/null +++ b/static/mvp/js/heatmap.js @@ -0,0 +1,92 @@ +const startDateInput = document.getElementById("startDate"); +const endDateInput = document.getElementById("endDate"); +const loadHeatmapBtn = document.getElementById("loadHeatmapBtn"); +const regionList = document.getElementById("regionList"); +const mapChart = echarts.init(document.getElementById("chinaMap")); + +let chinaMapLoaded = false; + +async function ensureChinaMap() { + if (chinaMapLoaded) return; + const mapJson = await fetch("https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json").then((r) => r.json()); + echarts.registerMap("china", mapJson); + chinaMapLoaded = true; +} + +function renderRegionRanking(list) { + if (!list.length) { + regionList.innerHTML = "
暂无地区数据
"; + return; + } + const sorted = [...list].sort((a, b) => b.value - a.value).slice(0, 12); + regionList.innerHTML = sorted + .map( + (r, idx) => ` +
+
#${idx + 1} ${r.name}${r.value}
+
事件数:${r.event_count || 0}
+
+ ` + ) + .join(""); +} + +async function loadHeatmap() { + showSkeleton(regionList, 6); + try { + await ensureChinaMap(); + const data = await apiGet( + `/mvp/api/heatmap-china?start_date=${startDateInput.value}&end_date=${endDateInput.value}` + ); + + const mapData = data.data || []; + renderRegionRanking(mapData); + + mapChart.setOption( + { + animationDuration: 320, + animationEasing: "cubicOut", + tooltip: { + trigger: "item", + formatter: (params) => `${params.name}
RI: ${params.value || 0}`, + }, + visualMap: { + min: 0, + max: 100, + text: ["高风险", "低风险"], + calculable: true, + inRange: { + color: ["#b3f4d8", "#fed784", "#ff6b8a"], + }, + textStyle: { color: "#dbe7ff" }, + }, + series: [ + { + name: "风险热力", + type: "map", + map: "china", + roam: true, + emphasis: { + itemStyle: { areaColor: "#7ea9ff" }, + label: { color: "#fff" }, + }, + itemStyle: { + borderColor: "rgba(255,255,255,0.3)", + borderWidth: 1, + }, + data: mapData, + }, + ], + }, + true + ); + } catch (err) { + regionList.innerHTML = `
加载失败:${err.message}
`; + } +} + +window.addEventListener("resize", () => mapChart.resize()); +loadHeatmapBtn.addEventListener("click", loadHeatmap); + +formatDateInputDefaults(startDateInput, endDateInput, 30); +loadHeatmap(); diff --git a/static/mvp/js/hot_ranking.js b/static/mvp/js/hot_ranking.js new file mode 100644 index 000000000..990a3bdbc --- /dev/null +++ b/static/mvp/js/hot_ranking.js @@ -0,0 +1,117 @@ +const topicInput = document.getElementById("topicInput"); +const startDateInput = document.getElementById("startDate"); +const endDateInput = document.getElementById("endDate"); +const searchBtn = document.getElementById("searchBtn"); +const eventsBody = document.getElementById("eventsBody"); +const rankingList = document.getElementById("rankingList"); +const prevPage = document.getElementById("prevPage"); +const nextPage = document.getElementById("nextPage"); +const pageInfo = document.getElementById("pageInfo"); + +let currentPage = 1; +let totalPages = 1; + +function riskClass(ri) { + if (ri >= 80) return "risk-high"; + if (ri >= 50) return "risk-mid"; + return "risk-low"; +} + +function currentFilters() { + return { + topic: encodeURIComponent(topicInput.value.trim()), + startDate: startDateInput.value, + endDate: endDateInput.value, + }; +} + +function renderEvents(items) { + if (!items.length) { + eventsBody.innerHTML = '暂无数据'; + return; + } + eventsBody.innerHTML = items + .map((item) => { + const content = (item.content || "").replace(/\s+/g, " ").trim(); + return ` + + ${item.create_time || "-"} + ${item.topic_name || "-"} + ${content || "-"} + ${item.liked_count}/${item.comments_count}/${item.shared_count} + ${item.risk_index} + `; + }) + .join(""); +} + +function renderRanking(items) { + if (!items.length) { + rankingList.innerHTML = "
暂无排行数据
"; + return; + } + rankingList.innerHTML = items + .map( + (item, idx) => ` +
+
#${idx + 1} ${item.topic_name || "未分类"} ${item.heat_score}
+
${item.nickname || "匿名用户"} · ${item.create_time || "-"}
+
+ ` + ) + .join(""); +} + +async function loadEvents() { + const f = currentFilters(); + try { + eventsBody.innerHTML = '
'; + const data = await apiGet( + `/mvp/api/hot-events?page=${currentPage}&page_size=10&topic=${f.topic}&start_date=${f.startDate}&end_date=${f.endDate}&platform=weibo` + ); + renderEvents(data.data || []); + totalPages = data.pagination.total_pages || 1; + pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页`; + } catch (err) { + eventsBody.innerHTML = `加载失败:${err.message}`; + } +} + +async function loadRanking() { + const f = currentFilters(); + showSkeleton(rankingList, 5); + try { + const data = await apiGet( + `/mvp/api/ranking?top_n=10&topic=${f.topic}&start_date=${f.startDate}&end_date=${f.endDate}` + ); + renderRanking(data.data || []); + } catch (err) { + rankingList.innerHTML = `
加载失败:${err.message}
`; + } +} + +async function loadAll() { + await Promise.all([loadEvents(), loadRanking()]); +} + +searchBtn.addEventListener("click", () => { + currentPage = 1; + loadAll(); +}); + +prevPage.addEventListener("click", () => { + if (currentPage > 1) { + currentPage -= 1; + loadEvents(); + } +}); + +nextPage.addEventListener("click", () => { + if (currentPage < totalPages) { + currentPage += 1; + loadEvents(); + } +}); + +formatDateInputDefaults(startDateInput, endDateInput, 30); +loadAll(); diff --git a/static/mvp/js/trend.js b/static/mvp/js/trend.js new file mode 100644 index 000000000..e0f33342e --- /dev/null +++ b/static/mvp/js/trend.js @@ -0,0 +1,70 @@ +const topicInput = document.getElementById("topicInput"); +const startDateInput = document.getElementById("startDate"); +const endDateInput = document.getElementById("endDate"); +const loadTrendBtn = document.getElementById("loadTrendBtn"); + +const riskChart = echarts.init(document.getElementById("riskChart")); +const countChart = echarts.init(document.getElementById("countChart")); + +function requestUrl() { + const topic = encodeURIComponent(topicInput.value.trim()); + return `/mvp/api/trend-30d?topic=${topic}&start_date=${startDateInput.value}&end_date=${endDateInput.value}`; +} + +function setChart(chart, title, dates, values, color) { + chart.setOption( + { + animationDuration: 300, + animationEasing: "cubicOut", + tooltip: { trigger: "axis" }, + xAxis: { + type: "category", + data: dates, + axisLabel: { color: "#c3d0f2" }, + }, + yAxis: { + type: "value", + axisLabel: { color: "#c3d0f2" }, + splitLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } }, + }, + series: [ + { + name: title, + type: "line", + smooth: true, + showSymbol: false, + lineStyle: { width: 3, color }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: `${color}80` }, + { offset: 1, color: `${color}10` }, + ]), + }, + data: values, + }, + ], + }, + true + ); +} + +async function loadTrend() { + try { + const data = await apiGet(requestUrl()); + const dates = data.data.dates || []; + setChart(riskChart, "RI", dates, data.data.risk_index || [], "#74a6ff"); + setChart(countChart, "话题数", dates, data.data.topic_counts || [], "#67e8f9"); + } catch (err) { + riskChart.setOption({ title: { text: `加载失败: ${err.message}`, left: "center", textStyle: { color: "#fff" } } }); + countChart.setOption({ title: { text: `加载失败: ${err.message}`, left: "center", textStyle: { color: "#fff" } } }); + } +} + +window.addEventListener("resize", () => { + riskChart.resize(); + countChart.resize(); +}); + +loadTrendBtn.addEventListener("click", loadTrend); +formatDateInputDefaults(startDateInput, endDateInput, 30); +loadTrend(); diff --git a/templates/mvp/base.html b/templates/mvp/base.html new file mode 100644 index 000000000..1e080dc80 --- /dev/null +++ b/templates/mvp/base.html @@ -0,0 +1,35 @@ + + + + + + {% block title %}BettaFish 可视化看板{% endblock %} + + + + +
+
+
+ +
+
BettaFish 舆情监测 MVP
+
Flask + Templates
+
+ +
+ + +
+ {% block content %}{% endblock %} +
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/mvp/heatmap.html b/templates/mvp/heatmap.html new file mode 100644 index 000000000..ee39868ac --- /dev/null +++ b/templates/mvp/heatmap.html @@ -0,0 +1,29 @@ +{% extends "mvp/base.html" %} + +{% block title %}全国热力图 - BettaFish{% endblock %} + +{% block content %} +
+
热力图筛选
+
+ + + +
+
+ +
+
+
全国舆情风险分布
+
+
+
+
省份风险排行榜
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/mvp/hot_ranking.html b/templates/mvp/hot_ranking.html new file mode 100644 index 000000000..0a7fc2eb5 --- /dev/null +++ b/templates/mvp/hot_ranking.html @@ -0,0 +1,56 @@ +{% extends "mvp/base.html" %} + +{% block title %}热点与排行 - BettaFish{% endblock %} + +{% block content %} +
+
+
筛选器
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
热度 Top10
+
+
+
+ +
+
热点事件列表
+
+ + + + + + + + + + + +
时间话题内容互动量RI
+
+
+ + 第 1 页 + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/mvp/trend.html b/templates/mvp/trend.html new file mode 100644 index 000000000..930f4698d --- /dev/null +++ b/templates/mvp/trend.html @@ -0,0 +1,30 @@ +{% extends "mvp/base.html" %} + +{% block title %}30天趋势 - BettaFish{% endblock %} + +{% block content %} +
+
趋势筛选
+
+ + + + +
+
+ +
+
+
近30天 RI 指数变化
+
+
+
+
话题数量变化
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}