Compare commits
4 Commits
98056b2515
...
ca9da9f7aa
| Author | SHA1 | Date | |
|---|---|---|---|
| ca9da9f7aa | |||
| 62ee8399e8 | |||
| 40317b47ec | |||
| 31c0371da3 |
@@ -17,10 +17,9 @@ class ElasticConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 延迟导入,避免循环导入或过早加载
|
# 延迟导入,避免循环导入或过早加载
|
||||||
from .es_connect import create_index_with_mapping, get_type_list
|
from .es_connect import create_index_with_mapping, start_daily_analytics_scheduler
|
||||||
try:
|
try:
|
||||||
create_index_with_mapping()
|
create_index_with_mapping()
|
||||||
types = get_type_list()
|
start_daily_analytics_scheduler()
|
||||||
print(f"🔎 启动时 type_list: {types}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ ES 初始化失败: {e}")
|
print(f"❌ ES 初始化失败: {e}")
|
||||||
@@ -15,6 +15,8 @@ GLOBAL_INDEX.settings(number_of_shards=1, number_of_replicas=0)
|
|||||||
class AchievementDocument(Document):
|
class AchievementDocument(Document):
|
||||||
"""获奖数据文档映射"""
|
"""获奖数据文档映射"""
|
||||||
writer_id = fields.TextField(fields={'keyword': {'type': 'keyword'}})
|
writer_id = fields.TextField(fields={'keyword': {'type': 'keyword'}})
|
||||||
|
time = fields.DateField()
|
||||||
|
|
||||||
data = fields.TextField(
|
data = fields.TextField(
|
||||||
analyzer='ik_max_word',
|
analyzer='ik_max_word',
|
||||||
search_analyzer='ik_smart',
|
search_analyzer='ik_smart',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .documents import AchievementDocument, UserDocument, GlobalDocument
|
|||||||
from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
|
from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import threading
|
||||||
|
|
||||||
# 使用Django的ES连接配置
|
# 使用Django的ES连接配置
|
||||||
connections.create_connection(hosts=['localhost:9200'])
|
connections.create_connection(hosts=['localhost:9200'])
|
||||||
@@ -132,11 +134,11 @@ def insert_data(data):
|
|||||||
bool: 插入成功返回True,失败返回False
|
bool: 插入成功返回True,失败返回False
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 使用Django-elasticsearch-dsl的方式插入数据
|
|
||||||
achievement = AchievementDocument(
|
achievement = AchievementDocument(
|
||||||
writer_id=data.get('writer_id', ''),
|
writer_id=data.get('writer_id', ''),
|
||||||
data=data.get('data', ''),
|
data=data.get('data', ''),
|
||||||
image=data.get('image', '')
|
image=data.get('image', ''),
|
||||||
|
time=datetime.now(timezone.utc).isoformat()
|
||||||
)
|
)
|
||||||
achievement.save()
|
achievement.save()
|
||||||
print(f"文档写入成功,内容: {data}")
|
print(f"文档写入成功,内容: {data}")
|
||||||
@@ -303,6 +305,68 @@ def search_by_any_field(keyword):
|
|||||||
print(f"模糊搜索失败: {str(e)}")
|
print(f"模糊搜索失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
ANALYTICS_CACHE = {"data": None, "ts": 0}
|
||||||
|
|
||||||
|
def _compute_hist(range_gte: str, interval: str, fmt: str):
|
||||||
|
from elasticsearch_dsl import Search
|
||||||
|
s = AchievementDocument.search()
|
||||||
|
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
|
||||||
|
s = s.extra(size=0)
|
||||||
|
s.aggs.bucket('b', 'date_histogram', field='time', calendar_interval=interval, format=fmt, min_doc_count=0)
|
||||||
|
resp = s.execute()
|
||||||
|
buckets = getattr(resp.aggs, 'b').buckets
|
||||||
|
return [{"label": b.key_as_string, "count": b.doc_count} for b in buckets]
|
||||||
|
|
||||||
|
def _compute_type_counts(range_gte: str, types: list):
|
||||||
|
counts = []
|
||||||
|
for t in types:
|
||||||
|
s = AchievementDocument.search()
|
||||||
|
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
|
||||||
|
s = s.query('match_phrase', data=str(t))
|
||||||
|
total = s.count()
|
||||||
|
counts.append({"type": str(t), "count": int(total)})
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def compute_analytics():
|
||||||
|
types = get_type_list()
|
||||||
|
days = _compute_hist('now-10d/d', 'day', 'yyyy-MM-dd')
|
||||||
|
weeks = _compute_hist('now-10w/w', 'week', 'yyyy-ww')
|
||||||
|
months = _compute_hist('now-10M/M', 'month', 'yyyy-MM')
|
||||||
|
pie_1m = _compute_type_counts('now-1M/M', types)
|
||||||
|
pie_12m = _compute_type_counts('now-12M/M', types)
|
||||||
|
return {
|
||||||
|
"last_10_days": days[-10:],
|
||||||
|
"last_10_weeks": weeks[-10:],
|
||||||
|
"last_10_months": months[-10:],
|
||||||
|
"type_pie_1m": pie_1m,
|
||||||
|
"type_pie_12m": pie_12m,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_analytics_overview(force: bool = False):
|
||||||
|
now_ts = time.time()
|
||||||
|
if force or ANALYTICS_CACHE["data"] is None or (now_ts - ANALYTICS_CACHE["ts"]) > 3600:
|
||||||
|
ANALYTICS_CACHE["data"] = compute_analytics()
|
||||||
|
ANALYTICS_CACHE["ts"] = now_ts
|
||||||
|
return ANALYTICS_CACHE["data"]
|
||||||
|
|
||||||
|
def _seconds_until_hour(h: int):
|
||||||
|
now = datetime.now()
|
||||||
|
tgt = now.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||||
|
if tgt <= now:
|
||||||
|
tgt = tgt + timedelta(days=1)
|
||||||
|
return max(0, int((tgt - now).total_seconds()))
|
||||||
|
|
||||||
|
def start_daily_analytics_scheduler():
|
||||||
|
def _run_and_reschedule():
|
||||||
|
try:
|
||||||
|
get_analytics_overview(force=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"分析任务失败: {e}")
|
||||||
|
finally:
|
||||||
|
threading.Timer(24 * 3600, _run_and_reschedule).start()
|
||||||
|
delay = _seconds_until_hour(3)
|
||||||
|
threading.Timer(delay, _run_and_reschedule).start()
|
||||||
|
|
||||||
def write_user_data(user_data):
|
def write_user_data(user_data):
|
||||||
"""
|
"""
|
||||||
写入用户数据到 ES
|
写入用户数据到 ES
|
||||||
@@ -395,6 +459,23 @@ def get_all_users():
|
|||||||
print(f"获取所有用户失败: {str(e)}")
|
print(f"获取所有用户失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
try:
|
||||||
|
search = UserDocument.search()
|
||||||
|
search = search.query("term", user_id=int(user_id))
|
||||||
|
response = search.execute()
|
||||||
|
if response.hits:
|
||||||
|
hit = response.hits[0]
|
||||||
|
return {
|
||||||
|
"user_id": hit.user_id,
|
||||||
|
"username": hit.username,
|
||||||
|
"permission": hit.permission,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取用户数据失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def delete_user_by_username(username):
|
def delete_user_by_username(username):
|
||||||
"""
|
"""
|
||||||
根据用户名删除用户
|
根据用户名删除用户
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
|||||||
path('search/', views.search, name='search'),
|
path('search/', views.search, name='search'),
|
||||||
path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'),
|
path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'),
|
||||||
path('all-data/', views.get_all_data, name='get_all_data'),
|
path('all-data/', views.get_all_data, name='get_all_data'),
|
||||||
|
path('analytics/overview/', views.analytics_overview, name='analytics_overview'),
|
||||||
|
|
||||||
# 用户管理
|
# 用户管理
|
||||||
path('users/', views.get_users, name='get_users'),
|
path('users/', views.get_users, name='get_users'),
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ def get_all_data(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def analytics_overview(request):
|
||||||
|
try:
|
||||||
|
force = request.GET.get("force") == "1"
|
||||||
|
data = get_analytics_overview(force=force)
|
||||||
|
return JsonResponse({"status": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|||||||
@@ -78,6 +78,60 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3730a3;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.legend .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -98,9 +152,41 @@
|
|||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<h1>欢迎来到系统</h1>
|
<div class="card">
|
||||||
<p>这里是一大堆内容……</p>
|
<div class="header">
|
||||||
<p style="height: 200vh;">滚动试试看,左边菜单不会消失哦!✨</p>
|
<h2>数据概览</h2>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<span class="badge">用户:{{ user_id }}</span>
|
||||||
|
{% if is_admin %}
|
||||||
|
<button id="triggerAnalyze" class="btn btn-primary">手动开始分析</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div>
|
||||||
|
<div class="legend"><span class="dot" style="background:#4f46e5;"></span><span class="muted">最近十天录入</span></div>
|
||||||
|
<canvas id="chartDays" height="140"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="legend"><span class="dot" style="background:#16a34a;"></span><span class="muted">最近十周录入</span></div>
|
||||||
|
<canvas id="chartWeeks" height="140"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="legend"><span class="dot" style="background:#ea580c;"></span><span class="muted">最近十个月录入</span></div>
|
||||||
|
<canvas id="chartMonths" height="140"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" style="margin-top:16px;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h2>近1个月成果类型</h2></div>
|
||||||
|
<canvas id="pie1m" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h2>近12个月成果类型</h2></div>
|
||||||
|
<canvas id="pie12m" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登出脚本(保持不变) -->
|
<!-- 登出脚本(保持不变) -->
|
||||||
@@ -136,5 +222,90 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
async function loadAnalytics() {
|
||||||
|
const resp = await fetch('/elastic/analytics/overview/');
|
||||||
|
const d = await resp.json();
|
||||||
|
if (!resp.ok || d.status !== 'success') return;
|
||||||
|
const data = d.data || {};
|
||||||
|
renderLine('chartDays', data.last_10_days || [], '#4f46e5');
|
||||||
|
renderLine('chartWeeks', data.last_10_weeks || [], '#16a34a');
|
||||||
|
renderLine('chartMonths', data.last_10_months || [], '#ea580c');
|
||||||
|
renderPie('pie1m', data.type_pie_1m || []);
|
||||||
|
renderPie('pie12m', data.type_pie_12m || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('triggerAnalyze');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '分析中…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/elastic/analytics/overview/?force=1');
|
||||||
|
const d = await resp.json();
|
||||||
|
if (!resp.ok || d.status !== 'success') throw new Error('分析失败');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
btn.textContent = '重试';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexWithAlpha(hex, alphaHex) {
|
||||||
|
if (!hex || !hex.startsWith('#')) return hex;
|
||||||
|
if (hex.length === 7) return hex + alphaHex;
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
function renderLine(id, items, color) {
|
||||||
|
const ctx = document.getElementById(id);
|
||||||
|
const labels = items.map(x => x.label);
|
||||||
|
const values = items.map(x => x.count);
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: hexWithAlpha(color, '26'),
|
||||||
|
tension: 0.25,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
animation: { duration: 800, easing: 'easeOutQuart' },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { grid: { color: 'rgba(31,35,40,0.06)' }, beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderPie(id, items) {
|
||||||
|
const ctx = document.getElementById(id);
|
||||||
|
const labels = items.map(x => x.type);
|
||||||
|
const values = items.map(x => x.count);
|
||||||
|
const colors = ['#2563eb','#22c55e','#f59e0b','#ef4444','#a855f7','#06b6d4','#84cc16','#ec4899','#475569','#d946ef'];
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{ data: values, backgroundColor: colors.slice(0, labels.length) }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||||
|
plugins: { legend: { position: 'bottom' } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAnalytics();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from elastic.es_connect import get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -11,7 +12,14 @@ def home(request):
|
|||||||
|
|
||||||
# Show user_id (prefer query param if present, but don't trust it)
|
# Show user_id (prefer query param if present, but don't trust it)
|
||||||
user_id_qs = request.GET.get("user_id")
|
user_id_qs = request.GET.get("user_id")
|
||||||
|
uid = user_id_qs or session_user_id
|
||||||
|
perm = request.session.get("permission")
|
||||||
|
if perm is None and uid is not None:
|
||||||
|
u = get_user_by_id(uid)
|
||||||
|
perm = (u or {}).get("permission", 1)
|
||||||
|
request.session["permission"] = perm
|
||||||
context = {
|
context = {
|
||||||
"user_id": user_id_qs or session_user_id,
|
"user_id": uid,
|
||||||
|
"is_admin": (perm == 0),
|
||||||
}
|
}
|
||||||
return render(request, "main/home.html", context)
|
return render(request, "main/home.html", context)
|
||||||
Reference in New Issue
Block a user