diff --git a/elastic/es_connect.py b/elastic/es_connect.py index d3d5bf0..ea8b974 100644 --- a/elastic/es_connect.py +++ b/elastic/es_connect.py @@ -307,7 +307,8 @@ def search_data(query): "_id": hit.meta.id, "writer_id": hit.writer_id, "data": hit.data, - "image": hit.image + "image": hit.image, + "time": getattr(hit, "time", None), }) return results @@ -328,7 +329,8 @@ def search_all(): "_id": hit.meta.id, "writer_id": hit.writer_id, "data": hit.data, - "image": hit.image + "image": hit.image, + "time": getattr(hit, "time", None), }) return results @@ -435,7 +437,8 @@ def search_by_any_field(keyword): "_id": hit.meta.id, "writer_id": hit.writer_id, "data": hit.data, - "image": hit.image + "image": hit.image, + "time": getattr(hit, "time", None), }) return results diff --git a/elastic/templates/elastic/manage.html b/elastic/templates/elastic/manage.html index 9c9495f..1440ca2 100644 --- a/elastic/templates/elastic/manage.html +++ b/elastic/templates/elastic/manage.html @@ -81,6 +81,8 @@ + + @@ -93,12 +95,31 @@ + {% if is_admin or has_manage_key %} +
+
统计报表
+
+ + + + + +
+ +
+ {% endif %} + + @@ -153,6 +174,11 @@ function getCookie(name) { // DOM元素引用 const searchQueryInput = document.getElementById('searchQuery'); const keyFilterSelect = document.getElementById('keyFilter'); +const typeFilterSelect = document.getElementById('typeFilter'); +const reportFromInput = document.getElementById('reportFrom'); +const reportToInput = document.getElementById('reportTo'); +const reportIntervalSelect = document.getElementById('reportInterval'); +const reportBox = document.getElementById('reportBox'); const searchResultDiv = document.getElementById('searchResult'); const searchStatus = document.getElementById('searchStatus'); const searchCount = document.getElementById('searchCount'); @@ -173,6 +199,9 @@ const zoomOutBtn = document.getElementById('zoomOutBtn'); const resetZoomBtn = document.getElementById('resetZoomBtn'); const zoomValue = document.getElementById('zoomValue'); +const IS_ADMIN = {{ is_admin|yesno:"true,false" }}; +const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }}; + // 全局变量 let currentId = ''; let currentWriter = ''; @@ -182,6 +211,7 @@ let currentSearchQuery = ''; // 记录当前搜索查询 let isFuzzySearch = false; // 记录当前是否为模糊搜索 let isDeleting = false; // 标记是否正在删除 let currentKeyFilter = ''; +let currentTypeFilter = ''; // 图片缩放相关变量 let currentScale = 1; @@ -205,6 +235,10 @@ async function performSearch(type) { currentKeyFilter = ''; if (keyFilterSelect) keyFilterSelect.value = ''; } + if (currentTypeFilter) { + currentTypeFilter = ''; + if (typeFilterSelect) typeFilterSelect.value = ''; + } currentSearchQuery = query; isFuzzySearch = type === 'fuzzy'; @@ -270,11 +304,11 @@ async function loadAllData() { showSearchLoading(); try { - if (currentKeyFilter) { - const response = await fetch(`/elastic/filter-by-key/?key=${encodeURIComponent(currentKeyFilter)}`); + if (currentKeyFilter || currentTypeFilter) { + const response = await fetch(`/elastic/filter/?key=${encodeURIComponent(currentKeyFilter)}&type=${encodeURIComponent(currentTypeFilter)}`); const data = await response.json(); if (data.status === 'success') { - displayAllData(data.data || [], currentKeyFilter); + displayAllData(data.data || [], currentKeyFilter, currentTypeFilter); } else { showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error'); } @@ -306,7 +340,10 @@ async function loadAllData() { function displayAllData(data, key) { searchResultDiv.style.display = 'block'; searchResultDiv.className = 'search-result'; - searchStatus.textContent = key ? `按Key筛查:${key}` : '显示全部数据'; + const labels = []; + if (key) labels.push(`Key:${key}`); + if (currentTypeFilter) labels.push(`类型:${currentTypeFilter}`); + searchStatus.textContent = labels.length ? `筛查:${labels.join(',')}` : '显示全部数据'; searchCount.textContent = `共 ${data.length} 条记录`; renderTable(data); @@ -318,7 +355,7 @@ function clearSearch() { searchResultDiv.style.display = 'none'; currentSearchQuery = ''; - if (currentKeyFilter) { + if (currentKeyFilter || currentTypeFilter) { loadAllData(); return; } @@ -359,13 +396,119 @@ function clearKeyFilter() { loadAllData(); } +async function initTypeFilter() { + if (!typeFilterSelect) return; + typeFilterSelect.innerHTML = ''; + try { + const resp = await fetch('/elastic/types-for-filter/', { credentials: 'same-origin' }); + const data = await resp.json(); + if (data.status !== 'success') return; + const types = data.data || []; + types.forEach(t => { + const opt = document.createElement('option'); + opt.value = String(t || ''); + opt.textContent = String(t || ''); + typeFilterSelect.appendChild(opt); + }); + } catch (e) { + } + typeFilterSelect.addEventListener('change', () => { + currentTypeFilter = (typeFilterSelect.value || '').trim(); + loadAllData(); + }); +} + +function clearTypeFilter() { + currentTypeFilter = ''; + if (typeFilterSelect) typeFilterSelect.value = ''; + loadAllData(); +} + +function buildReportParams() { + const params = new URLSearchParams(); + if (currentKeyFilter) params.set('key', currentKeyFilter); + if (currentTypeFilter) params.set('type', currentTypeFilter); + const iv = (reportIntervalSelect && reportIntervalSelect.value) ? reportIntervalSelect.value : 'day'; + params.set('interval', iv); + const fromVal = reportFromInput ? (reportFromInput.value || '').trim() : ''; + const toVal = reportToInput ? (reportToInput.value || '').trim() : ''; + if (fromVal) params.set('from', fromVal); + if (toVal) params.set('to', toVal); + return params; +} + +async function generateReport() { + if (!reportBox) return; + reportBox.style.display = 'block'; + reportBox.className = 'search-result'; + reportBox.innerHTML = '
正在生成报表...
'; + try { + const params = buildReportParams(); + const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' }); + const data = await resp.json(); + if (data.status !== 'success') { + reportBox.className = 'search-result error'; + reportBox.innerHTML = `
生成失败:${data.message || '未知错误'}
`; + return; + } + const r = data.data || {}; + const total = r.total || 0; + const byType = r.by_type || []; + const byTime = r.by_time || []; + const rng = r.range || {}; + const flt = r.filters || {}; + const lines = []; + const filterParts = []; + if (flt.key) filterParts.push(`Key:${flt.key}`); + if (flt.type) filterParts.push(`类型:${flt.type}`); + if (flt.interval) filterParts.push(`粒度:${flt.interval}`); + lines.push(`
总数:${total}
`); + if (rng.from || rng.to) { + lines.push(`
时间范围:${(rng.from || '')} ~ ${(rng.to || '')}
`); + } + if (filterParts.length) { + lines.push(`
筛查:${filterParts.join(',')}
`); + } + + const typeRows = byType.map(it => ``).join(''); + const timeRows = byTime.map(it => ``).join(''); + + lines.push(`
+
+
按成果类型
+
图片 数据时间 录入人 操作
${it.type || ''}${it.count || 0}
${it.bucket || ''}${it.count || 0}
+ + ${typeRows || ''} +
类型数量
暂无
+ +
+
按时间
+ + + ${timeRows || ''} +
时间数量
暂无
+
+ `); + + reportBox.innerHTML = lines.join(''); + } catch (e) { + reportBox.className = 'search-result error'; + reportBox.innerHTML = `
生成失败:${e.message || '未知错误'}
`; + } +} + +function downloadReportCsv() { + const params = buildReportParams(); + window.location.href = `/elastic/report/csv/?${params.toString()}`; +} + // 渲染表格 function renderTable(data) { tableBody.innerHTML = ''; if (!data || data.length === 0) { const row = document.createElement('tr'); - row.innerHTML = '暂无数据'; + row.innerHTML = '暂无数据'; tableBody.appendChild(row); return; } @@ -422,6 +565,7 @@ function renderTable(data) { ${displayData} + ${formatDateTime(item.time)} ${item.writer_name || item.writer_id || ''} @@ -432,6 +576,18 @@ function renderTable(data) { }); } +function formatDateTime(t) { + if (!t) return ''; + try { + const d = new Date(t); + if (String(d) === 'Invalid Date') return String(t); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } catch (e) { + return String(t); + } +} + function buildImageCell(item) { const urls = Array.isArray(item.image_urls) ? item.image_urls : (item.image_url ? [item.image_url] : []); if (!urls.length) return '无图片'; @@ -667,6 +823,18 @@ async function doDelete(id){ // 页面加载时自动加载所有数据 document.addEventListener('DOMContentLoaded', function() { initKeyFilter(); + initTypeFilter(); + if (reportFromInput && reportToInput && reportIntervalSelect) { + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const toLocal = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (!reportToInput.value) reportToInput.value = toLocal(now); + if (!reportFromInput.value) { + const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + reportFromInput.value = toLocal(from); + } + if (!reportIntervalSelect.value) reportIntervalSelect.value = 'day'; + } loadAllData(); }); diff --git a/elastic/urls.py b/elastic/urls.py index b07ae6a..43ea181 100644 --- a/elastic/urls.py +++ b/elastic/urls.py @@ -19,6 +19,10 @@ urlpatterns = [ path('all-data/', views.get_all_data, name='get_all_data'), path('filter-by-key/', views.filter_by_key, name='filter_by_key'), path('keys-for-filter/', views.keys_for_filter_view, name='keys_for_filter'), + path('types-for-filter/', views.types_for_filter_view, name='types_for_filter'), + path('filter/', views.filter_view, name='filter'), + path('report/', views.report_view, name='report'), + path('report/csv/', views.report_csv_view, name='report_csv'), # 用户管理 path('users/', views.get_users, name='get_users'), diff --git a/elastic/views.py b/elastic/views.py index 5affa22..121bb0d 100644 --- a/elastic/views.py +++ b/elastic/views.py @@ -6,10 +6,12 @@ import re import uuid import base64 import json +import csv +import io import tempfile import concurrent.futures from django.conf import settings -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.shortcuts import render from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import ensure_csrf_cookie @@ -953,6 +955,7 @@ def manage_page(request): me = get_user_by_id(session_user_id) or {} is_admin = int(request.session.get("permission", 1)) == 0 + has_manage_key = bool(me.get("manage_key") or []) if is_admin: raw_results = search_all() else: @@ -980,6 +983,7 @@ def manage_page(request): "elastic/manage.html", { "is_admin": is_admin, + "has_manage_key": has_manage_key, "user_id": session_user_id, "username": me.get("username"), }, @@ -1428,6 +1432,293 @@ def keys_for_filter_view(request): add(out, seen, v) return JsonResponse({"status": "success", "data": out}) + +def _extract_type_from_data(value): + s = value + if s is None: + return "" + if not isinstance(s, str): + try: + s = json.dumps(s, ensure_ascii=False) + except Exception: + s = str(s) + s = str(s) + try: + obj = json.loads(s) + if isinstance(obj, dict): + t = obj.get("数据类型") + if isinstance(t, str) and t.strip(): + return t.strip() + except Exception: + pass + try: + m = re.search(r'"数据类型"\s*:\s*"([^"]+)"', s) + if m: + return str(m.group(1)).strip() + except Exception: + pass + return "" + + +@require_http_methods(["GET"]) +def types_for_filter_view(request): + uid = request.session.get("user_id") + if uid is None: + return JsonResponse({"status": "error", "message": "未登录"}, status=401) + try: + types = [str(t).strip() for t in (get_type_list() or []) if str(t).strip()] + except Exception: + types = [] + seen = set() + out = [] + for t in types: + if t in seen: + continue + seen.add(t) + out.append(t) + return JsonResponse({"status": "success", "data": out}) + + +@require_http_methods(["GET"]) +def filter_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"status": "error", "message": "未登录"}, status=401) + + key = (request.GET.get("key") or "").strip() + typ = (request.GET.get("type") or "").strip() + + results = search_all() + results = _filter_results_for_user(request, results) + + filtered = list(results or []) + + if key: + selected = str(key).strip() + try: + users = get_all_users() or [] + except Exception: + users = [] + writer_keys_by_id = {} + for u in users: + try: + u_id = str(u.get("user_id", "")).strip() + except Exception: + u_id = "" + if not u_id: + continue + try: + u_keys = {str(k).strip() for k in (u.get("key") or []) if str(k).strip()} + except Exception: + u_keys = set() + writer_keys_by_id[u_id] = u_keys + + tmp = [] + for r in filtered: + writer_id = str(r.get("writer_id", "")).strip() + writer_keys = writer_keys_by_id.get(writer_id) + if writer_keys and selected in writer_keys: + tmp.append(r) + continue + if selected and selected in str(r.get("data", "")): + tmp.append(r) + filtered = tmp + + if typ: + tsel = str(typ).strip() + filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel] + + data = _attach_writer_names(_attach_image_urls(request, filtered)) + return JsonResponse({"status": "success", "data": data}) + + +def _parse_dt(value): + if not value: + return None + if hasattr(value, "isoformat"): + try: + dt = value + if getattr(dt, "tzinfo", None) is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + pass + s = str(value).strip() + if not s: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(s) + if getattr(dt, "tzinfo", None) is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None + + +def _floor_dt(dt, interval: str): + if not dt: + return None + iv = str(interval or "day").strip().lower() + if iv == "month": + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if iv == "week": + base = dt.replace(hour=0, minute=0, second=0, microsecond=0) + return base - timedelta(days=base.weekday()) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _dt_label(dt, interval: str): + if not dt: + return "" + iv = str(interval or "day").strip().lower() + if iv == "month": + return dt.strftime("%Y-%m") + return dt.strftime("%Y-%m-%d") + + +@require_http_methods(["GET"]) +def report_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"status": "error", "message": "未登录"}, status=401) + is_admin = int(request.session.get("permission", 1)) == 0 + me = get_user_by_id(session_user_id) or {} + has_manage_key = bool(me.get("manage_key") or []) + if (not is_admin) and (not has_manage_key): + return JsonResponse({"status": "error", "message": "无权限"}, status=403) + + gte = (request.GET.get("from") or "").strip() + lte = (request.GET.get("to") or "").strip() + interval = (request.GET.get("interval") or "day").strip() + key = (request.GET.get("key") or "").strip() + typ = (request.GET.get("type") or "").strip() + + gte_dt = _parse_dt(gte) if gte else None + lte_dt = _parse_dt(lte) if lte else None + + results = search_all() + results = _filter_results_for_user(request, results) + filtered = list(results or []) + + if key: + selected = str(key).strip() + try: + users = get_all_users() or [] + except Exception: + users = [] + writer_keys_by_id = {} + for u in users: + try: + u_id = str(u.get("user_id", "")).strip() + except Exception: + u_id = "" + if not u_id: + continue + try: + u_keys = {str(k).strip() for k in (u.get("key") or []) if str(k).strip()} + except Exception: + u_keys = set() + writer_keys_by_id[u_id] = u_keys + + tmp = [] + for r in filtered: + writer_id = str(r.get("writer_id", "")).strip() + writer_keys = writer_keys_by_id.get(writer_id) + if writer_keys and selected in writer_keys: + tmp.append(r) + continue + if selected and selected in str(r.get("data", "")): + tmp.append(r) + filtered = tmp + + if typ: + tsel = str(typ).strip() + filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel] + + ranged = [] + for r in filtered: + t = _parse_dt(r.get("time")) + if (gte_dt or lte_dt) and (t is None): + continue + if gte_dt and t and t < gte_dt: + continue + if lte_dt and t and t > lte_dt: + continue + rr = dict(r) + rr["_time_dt"] = t + ranged.append(rr) + + by_type = {} + by_bucket = {} + for r in ranged: + tval = _extract_type_from_data(r.get("data")) + if tval: + by_type[tval] = by_type.get(tval, 0) + 1 + bucket_dt = _floor_dt(r.get("_time_dt"), interval) + if bucket_dt: + label = _dt_label(bucket_dt, interval) + if label: + by_bucket[label] = by_bucket.get(label, 0) + 1 + + by_type_arr = [{"type": k, "count": int(v)} for k, v in sorted(by_type.items(), key=lambda x: (-x[1], x[0]))] + by_time_arr = [{"bucket": k, "count": int(v)} for k, v in sorted(by_bucket.items(), key=lambda x: x[0])] + + return JsonResponse( + { + "status": "success", + "data": { + "generated_at": datetime.now(timezone.utc).isoformat(), + "range": {"from": gte or "", "to": lte or ""}, + "filters": {"key": key or "", "type": typ or "", "interval": interval or "day"}, + "total": len(ranged), + "by_type": by_type_arr, + "by_time": by_time_arr, + }, + } + ) + + +@require_http_methods(["GET"]) +def report_csv_view(request): + resp = report_view(request) + if getattr(resp, "status_code", 200) != 200: + return resp + try: + payload = json.loads(resp.content.decode("utf-8")) + except Exception: + return JsonResponse({"status": "error", "message": "生成失败"}, status=500) + data = (payload or {}).get("data") or {} + + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["统计报表"]) + w.writerow(["生成时间", data.get("generated_at", "")]) + rng = data.get("range") or {} + w.writerow(["时间范围", f"{rng.get('from','')} ~ {rng.get('to','')}"]) + flt = data.get("filters") or {} + w.writerow(["筛查Key", flt.get("key", "")]) + w.writerow(["筛查类型", flt.get("type", "")]) + w.writerow(["时间粒度", flt.get("interval", "")]) + w.writerow([]) + w.writerow(["总数", data.get("total", 0)]) + w.writerow([]) + w.writerow(["按成果类型统计"]) + w.writerow(["类型", "数量"]) + for it in list(data.get("by_type") or []): + w.writerow([it.get("type", ""), it.get("count", 0)]) + w.writerow([]) + w.writerow(["按时间统计"]) + w.writerow(["时间", "数量"]) + for it in list(data.get("by_time") or []): + w.writerow([it.get("bucket", ""), it.get("count", 0)]) + + content = buf.getvalue() + out = HttpResponse(content, content_type="text/csv; charset=utf-8") + out["Content-Disposition"] = 'attachment; filename="report.csv"' + return out + @require_http_methods(["POST"]) @csrf_protect def revoke_registration_code_view(request):