| 图片 |
数据 |
+ 时间 |
录入人 |
操作 |
@@ -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 => `| ${it.type || ''} | ${it.count || 0} |
`).join('');
+ const timeRows = byTime.map(it => `| ${it.bucket || ''} | ${it.count || 0} |
`).join('');
+
+ lines.push(`
+
+
按成果类型
+
+ | 类型 | 数量 |
+ ${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):
|