Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f8a64fdb | |||
| 01a3b2dfdb | |||
| 0dd7879389 | |||
| 19f805c818 | |||
| d84d0218cd | |||
| e92964ce71 | |||
| 1a3aee39e0 | |||
| 7fa7b42b1a | |||
| 26452161f8 | |||
| 07d3a4420c | |||
| 2c3c2d6acf | |||
| afc663844b | |||
| 9e3fe7150b | |||
| c9611fa622 | |||
| fe7f08ed1c |
@@ -39,8 +39,8 @@
|
||||
.msg.success { color: #166534; }
|
||||
|
||||
/* 图片放大模态框 */
|
||||
.image-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 2000; }
|
||||
.image-modal-content { max-width: 90%; max-height: 90%; border-radius: 8px; }
|
||||
.image-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 2000; overflow: hidden; }
|
||||
.image-modal-content { max-width: 90%; max-height: 90%; border-radius: 8px; transform-origin: center center; cursor: grab; user-select: none; }
|
||||
.image-modal-close { position: absolute; top: 20px; right: 30px; color: white; font-size: 40px; font-weight: bold; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="user-id-sidebar">
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
<h3>你好,<span id="sidebarUsername">{{ username|default:"访客" }}</span></h3>
|
||||
</div>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}">返回主页</a>
|
||||
@@ -58,6 +58,58 @@
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
{% if subpage %}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>{{ subpage_title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<a href="{% url 'accounts:profile' %}" style="color:#2d8cf0; text-decoration:none;">返回个人中心</a>
|
||||
</div>
|
||||
{% if subpage == "username" %}
|
||||
<form id="nameForm">
|
||||
<div class="form-group">
|
||||
<label for="newUsername">新用户名</label>
|
||||
<input type="text" id="newUsername" placeholder="请输入新用户名" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<div id="nameMsg" class="msg"></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if subpage == "password" %}
|
||||
<form id="pwdForm">
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
<input type="password" id="newPassword" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认密码</label>
|
||||
<input type="password" id="confirmPassword" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<div id="pwdMsg" class="msg"></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if subpage == "registration-code" %}
|
||||
<form id="rcForm">
|
||||
<div class="form-group">
|
||||
<label for="newRegCode">新注册码</label>
|
||||
<input type="text" id="newRegCode" placeholder="输入新注册码后替换原有 key" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>预览</label>
|
||||
<div id="rcPreview" style="background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; padding:10px 12px; font-size:13px; color:#334155;">
|
||||
<div style="color:#64748b;">输入注册码后自动显示 key 预览</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">替换</button>
|
||||
<div id="rcMsg" class="msg"></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
@@ -65,7 +117,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<p><span class="label">用户名:</span> {{ profile_user.username }}</p>
|
||||
<p><span class="label">用户名:</span> <span id="profileUsername">{{ profile_user.username }}</span></p>
|
||||
<p><span class="label">用户ID:</span> {{ profile_user.user_id }}</p>
|
||||
<p><span class="label">注册码:</span> {{ profile_user.registration_code|default:"无" }}</p>
|
||||
<p><span class="label">所属:</span> {{ profile_user.key|join:"、"|default:"未填写" }}</p>
|
||||
@@ -74,6 +126,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>账号设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<a class="btn" href="{% url 'accounts:profile_username' %}">修改用户名</a>
|
||||
<a class="btn" href="{% url 'accounts:profile_password' %}">修改密码</a>
|
||||
<a class="btn" href="{% url 'accounts:profile_registration_code' %}">替换注册码</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">我的提交</div>
|
||||
{% if achievements %}
|
||||
<div class="image-grid">
|
||||
@@ -96,49 +161,6 @@
|
||||
<a href="{% url 'elastic:upload_page' %}" style="color: #2d8cf0; text-decoration: none;">去上传第一张图片吧!</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="profile-card rc-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>替换注册码</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="rcForm">
|
||||
<div class="form-group">
|
||||
<label for="newRegCode">新注册码</label>
|
||||
<input type="text" id="newRegCode" placeholder="输入新注册码后替换原有 key" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>预览</label>
|
||||
<div id="rcPreview" style="background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; padding:10px 12px; font-size:13px; color:#334155;">
|
||||
<div style="color:#64748b;">输入注册码后自动显示 key 预览</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">替换</button>
|
||||
<div id="rcMsg" class="msg"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if permission_name != "管理员" and not profile_user.manage_key %}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>修改密码</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="pwdForm">
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
<input type="password" id="newPassword" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认密码</label>
|
||||
<input type="password" id="confirmPassword" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<div id="pwdMsg" class="msg"></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -166,20 +188,114 @@
|
||||
});
|
||||
|
||||
// 图片放大功能
|
||||
let modalScale = 1;
|
||||
let modalTranslateX = 0;
|
||||
let modalTranslateY = 0;
|
||||
let modalDragging = false;
|
||||
let modalDragStartX = 0;
|
||||
let modalDragStartY = 0;
|
||||
let modalDragOriginX = 0;
|
||||
let modalDragOriginY = 0;
|
||||
|
||||
function applyModalTransform() {
|
||||
const modalImg = document.getElementById('modalImg');
|
||||
modalImg.style.transform = `translate(${modalTranslateX}px, ${modalTranslateY}px) scale(${modalScale})`;
|
||||
}
|
||||
|
||||
function resetModalTransform() {
|
||||
modalScale = 1;
|
||||
modalTranslateX = 0;
|
||||
modalTranslateY = 0;
|
||||
applyModalTransform();
|
||||
}
|
||||
|
||||
function clampScale(next) {
|
||||
if (next < 0.2) return 0.2;
|
||||
if (next > 5) return 5;
|
||||
return next;
|
||||
}
|
||||
|
||||
function openModal(src) {
|
||||
const modal = document.getElementById('imageModal');
|
||||
const modalImg = document.getElementById('modalImg');
|
||||
modal.style.display = "flex";
|
||||
modalImg.src = src;
|
||||
resetModalTransform();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('imageModal').style.display = "none";
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('imageModal');
|
||||
if (event.target == modal) closeModal();
|
||||
const modalEl = document.getElementById('imageModal');
|
||||
const modalImgEl = document.getElementById('modalImg');
|
||||
if (modalEl && modalImgEl) {
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) closeModal();
|
||||
});
|
||||
|
||||
modalImgEl.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
modalDragging = true;
|
||||
modalDragStartX = e.clientX;
|
||||
modalDragStartY = e.clientY;
|
||||
modalDragOriginX = modalTranslateX;
|
||||
modalDragOriginY = modalTranslateY;
|
||||
modalImgEl.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!modalDragging) return;
|
||||
const dx = e.clientX - modalDragStartX;
|
||||
const dy = e.clientY - modalDragStartY;
|
||||
modalTranslateX = modalDragOriginX + dx;
|
||||
modalTranslateY = modalDragOriginY + dy;
|
||||
applyModalTransform();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (!modalDragging) return;
|
||||
modalDragging = false;
|
||||
modalImgEl.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
modalEl.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const rect = modalImgEl.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
const nextScale = clampScale(modalScale * (e.deltaY < 0 ? 1.1 : 0.9));
|
||||
const ratio = nextScale / modalScale;
|
||||
modalTranslateX = (modalTranslateX - cx) * ratio + cx;
|
||||
modalTranslateY = (modalTranslateY - cy) * ratio + cy;
|
||||
modalScale = nextScale;
|
||||
applyModalTransform();
|
||||
}, { passive: false });
|
||||
|
||||
modalImgEl.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
modalDragging = true;
|
||||
modalDragStartX = t.clientX;
|
||||
modalDragStartY = t.clientY;
|
||||
modalDragOriginX = modalTranslateX;
|
||||
modalDragOriginY = modalTranslateY;
|
||||
}, { passive: true });
|
||||
|
||||
modalImgEl.addEventListener('touchmove', (e) => {
|
||||
if (!modalDragging || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - modalDragStartX;
|
||||
const dy = t.clientY - modalDragStartY;
|
||||
modalTranslateX = modalDragOriginX + dx;
|
||||
modalTranslateY = modalDragOriginY + dy;
|
||||
applyModalTransform();
|
||||
}, { passive: true });
|
||||
|
||||
modalImgEl.addEventListener('touchend', () => {
|
||||
modalDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
const pwdForm = document.getElementById('pwdForm');
|
||||
@@ -230,6 +346,62 @@
|
||||
});
|
||||
}
|
||||
|
||||
const nameForm = document.getElementById('nameForm');
|
||||
if (nameForm) {
|
||||
nameForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('nameMsg');
|
||||
msg.textContent = '';
|
||||
msg.className = 'msg';
|
||||
const input = document.getElementById('newUsername');
|
||||
const newName = (input.value || '').trim();
|
||||
const currentNameEl = document.getElementById('profileUsername');
|
||||
const currentName = (currentNameEl && currentNameEl.textContent ? currentNameEl.textContent : '').trim();
|
||||
if (!newName) {
|
||||
msg.textContent = '请输入用户名';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
if (newName.length > 50) {
|
||||
msg.textContent = '用户名过长';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
if (currentName && newName === currentName) {
|
||||
msg.textContent = '用户名未变化';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
const resp = await fetch('/accounts/profile/username/update/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken || ''
|
||||
},
|
||||
body: JSON.stringify({ username: newName })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.ok) {
|
||||
msg.textContent = '修改成功';
|
||||
msg.className = 'msg success';
|
||||
if (currentNameEl) currentNameEl.textContent = data.username || newName;
|
||||
const sidebarName = document.getElementById('sidebarUsername');
|
||||
if (sidebarName) sidebarName.textContent = data.username || newName;
|
||||
input.value = '';
|
||||
} else {
|
||||
msg.textContent = (data && data.message) ? data.message : '操作失败';
|
||||
msg.className = 'msg error';
|
||||
}
|
||||
} catch (err) {
|
||||
msg.textContent = '操作失败';
|
||||
msg.className = 'msg error';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rcForm = document.getElementById('rcForm');
|
||||
if (rcForm) {
|
||||
let rcPreviewTimer = null;
|
||||
|
||||
@@ -13,6 +13,10 @@ urlpatterns = [
|
||||
path("register/submit/", views.register_submit, name="register_submit"),
|
||||
path("email/send-code/", views.send_email_code, name="send_email_code"),
|
||||
path("profile/", views.profile_page, name="profile"),
|
||||
path("profile/username/", views.profile_username_page, name="profile_username"),
|
||||
path("profile/password/", views.profile_password_page, name="profile_password"),
|
||||
path("profile/registration-code/", views.profile_registration_code_page, name="profile_registration_code"),
|
||||
path("profile/username/update/", views.update_profile_username_view, name="update_profile_username"),
|
||||
path("profile/registration-code/replace/", views.replace_registration_code_view, name="replace_registration_code"),
|
||||
path("profile/registration-code/preview/", views.registration_code_preview_view, name="registration_code_preview"),
|
||||
path("registration-code/request/submit/", views.submit_registration_code_request_view, name="submit_registration_code_request"),
|
||||
|
||||
@@ -71,33 +71,62 @@ def set_session_key(request):
|
||||
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_page(request):
|
||||
def _build_profile_context(request):
|
||||
session_user_id = request.session.get("user_id")
|
||||
if session_user_id is None:
|
||||
return redirect("/accounts/login/")
|
||||
|
||||
# 获取用户信息
|
||||
return None
|
||||
user = get_user_by_id(session_user_id)
|
||||
if not user:
|
||||
return redirect("/accounts/login/")
|
||||
|
||||
# 获取个人提交的成就(图片)
|
||||
return None
|
||||
from elastic.es_connect import search_all
|
||||
from elastic.views import _attach_image_urls
|
||||
|
||||
raw_results = [r for r in search_all() if str(r.get("writer_id", "")) == str(session_user_id)]
|
||||
achievements = _attach_image_urls(request, raw_results)
|
||||
|
||||
permission_name = "管理员" if int(user.get("permission", 1)) == 0 else "普通用户"
|
||||
|
||||
context = {
|
||||
return {
|
||||
"username": request.session.get("username"),
|
||||
"profile_user": user,
|
||||
"permission_name": permission_name,
|
||||
"achievements": achievements,
|
||||
}
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = ""
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_username_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "username"
|
||||
context["subpage_title"] = "修改用户名"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_password_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "password"
|
||||
context["subpage_title"] = "修改密码"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_registration_code_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "registration-code"
|
||||
context["subpage_title"] = "替换注册码"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@@ -304,6 +333,34 @@ def replace_registration_code_view(request):
|
||||
return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def update_profile_username_view(request):
|
||||
session_user_id = request.session.get("user_id")
|
||||
if session_user_id is None:
|
||||
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"ok": False, "message": "JSON无效"}, status=400)
|
||||
new_username = (payload.get("username") or "").strip()
|
||||
if not new_username:
|
||||
return JsonResponse({"ok": False, "message": "请输入用户名"}, status=400)
|
||||
if len(new_username) > 50:
|
||||
return JsonResponse({"ok": False, "message": "用户名过长"}, status=400)
|
||||
me = get_user_by_id(session_user_id) or {}
|
||||
if str(me.get("username", "")).strip() == new_username:
|
||||
request.session["username"] = new_username
|
||||
return JsonResponse({"ok": True, "username": new_username})
|
||||
existing = es_get_user_by_username(new_username)
|
||||
if existing and str(existing.get("user_id")) != str(session_user_id):
|
||||
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
|
||||
ok = update_user_by_id(session_user_id, username=new_username)
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": "修改失败"}, status=500)
|
||||
request.session["username"] = new_username
|
||||
return JsonResponse({"ok": True, "username": new_username})
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def registration_code_preview_view(request):
|
||||
session_user_id = request.session.get("user_id")
|
||||
|
||||
@@ -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
|
||||
@@ -828,12 +831,8 @@ def get_user_by_username(username):
|
||||
def get_all_users():
|
||||
"""获取所有用户"""
|
||||
try:
|
||||
search = UserDocument.search()
|
||||
search = search.query("match_all")
|
||||
response = search.execute()
|
||||
|
||||
users = []
|
||||
for hit in response:
|
||||
for hit in UserDocument.search().query("match_all").scan():
|
||||
users.append({
|
||||
"user_id": hit.user_id,
|
||||
"username": hit.username,
|
||||
@@ -845,7 +844,6 @@ def get_all_users():
|
||||
"key": list(getattr(hit, 'key', []) or []),
|
||||
"manage_key": list(getattr(hit, 'manage_key', []) or []),
|
||||
})
|
||||
|
||||
return users
|
||||
except Exception as e:
|
||||
print(f"获取所有用户失败: {str(e)}")
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
<input type="text" id="searchQuery" class="search-input" placeholder="请输入搜索关键词...">
|
||||
<select id="keyFilter" class="search-input"></select>
|
||||
<button class="btn" onclick="clearKeyFilter()">清空Key筛查</button>
|
||||
<select id="typeFilter" class="search-input"></select>
|
||||
<button class="btn" onclick="clearTypeFilter()">清空类型筛查</button>
|
||||
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
|
||||
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
|
||||
<button class="btn" onclick="loadAllData()">显示全部</button>
|
||||
@@ -93,12 +95,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin or has_manage_key %}
|
||||
<div class="search-container" style="margin-top: 12px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">统计报表</div>
|
||||
<div class="search-controls" style="flex-wrap: wrap;">
|
||||
<input type="datetime-local" id="reportFrom" class="search-input" placeholder="开始时间">
|
||||
<input type="datetime-local" id="reportTo" class="search-input" placeholder="结束时间">
|
||||
<select id="reportInterval" class="search-input">
|
||||
<option value="day">按天</option>
|
||||
<option value="week">按周</option>
|
||||
<option value="month">按月</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="generateReport()">生成报表</button>
|
||||
<button class="btn" onclick="downloadReportCsv()">下载CSV</button>
|
||||
</div>
|
||||
<div id="reportBox" class="search-result" style="display: none; margin-top: 10px;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<table id="dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>图片</th>
|
||||
<th>数据</th>
|
||||
<th>时间</th>
|
||||
<th>录入人</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -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,124 @@ function clearKeyFilter() {
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
async function initTypeFilter() {
|
||||
if (!typeFilterSelect) return;
|
||||
typeFilterSelect.innerHTML = '<option value="">全部类型</option>';
|
||||
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 = '<div>正在生成报表...</div>';
|
||||
try {
|
||||
const params = buildReportParams();
|
||||
const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' });
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.status !== 'success') {
|
||||
reportBox.className = 'search-result error';
|
||||
reportBox.innerHTML = `<div>生成失败:${data.message || '未知错误'}</div>`;
|
||||
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(`<div style="font-weight: 600;">总数:${total}</div>`);
|
||||
if (rng.from || rng.to) {
|
||||
lines.push(`<div class="muted" style="margin-top: 4px;">时间范围:${(rng.from || '')} ~ ${(rng.to || '')}</div>`);
|
||||
}
|
||||
if (filterParts.length) {
|
||||
lines.push(`<div class="muted" style="margin-top: 4px;">筛查:${filterParts.join(',')}</div>`);
|
||||
}
|
||||
|
||||
const typeRows = byType.map(it => `<tr><td style="padding:4px 8px;">${it.type || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
|
||||
const timeRows = byTime.map(it => `<tr><td style="padding:4px 8px;">${it.bucket || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
|
||||
|
||||
lines.push(`<div style="display:flex; gap:12px; flex-wrap: wrap; margin-top: 10px;">
|
||||
<div style="min-width: 260px; flex: 1;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">按成果类型</div>
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">类型</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
|
||||
<tbody>${typeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="min-width: 260px; flex: 1;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">按时间</div>
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">时间</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
|
||||
<tbody>${timeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
reportBox.innerHTML = lines.join('');
|
||||
} catch (e) {
|
||||
reportBox.className = 'search-result error';
|
||||
reportBox.innerHTML = `<div>生成失败:${e.message || '未知错误'}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<td colspan="4" style="text-align: center; color: #999;">暂无数据</td>';
|
||||
row.innerHTML = '<td colspan="5" style="text-align: center; color: #999;">暂无数据</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
@@ -422,6 +570,7 @@ function renderTable(data) {
|
||||
<td>
|
||||
${displayData}
|
||||
</td>
|
||||
<td style="font-size: 12px; white-space: nowrap;">${formatDateTime(item.time)}</td>
|
||||
<td style="font-size: 12px;">${item.writer_name || item.writer_id || ''}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" onclick="openEdit('${item._id || item.id}')">编辑</button>
|
||||
@@ -432,6 +581,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 +828,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<div class="upload-section" id="dropArea">
|
||||
<h3>上传文件</h3>
|
||||
<p>点击下方按钮选择图片或PDF文件,或拖拽文件到此区域</p>
|
||||
<p style="margin: 8px 0 0; font-size: 13px; color: #64748b;">单次最多上传 {{ max_single_upload_count|default:"3" }} 个文件。</p>
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="file" id="fileInput" name="file" accept="image/*,.pdf" multiple />
|
||||
@@ -155,6 +156,7 @@ const dropArea = document.getElementById('dropArea');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const MAX_SINGLE_UPLOAD_COUNT = Number('{{ max_single_upload_count|default:"3" }}');
|
||||
|
||||
let currentItems = []; // 存储当前待处理的所有文件结果
|
||||
let selectedFiles = [];
|
||||
@@ -277,13 +279,21 @@ function updateFileHint() {
|
||||
function addFiles(files) {
|
||||
const incoming = Array.from(files || []).filter(f => f && (f.type.startsWith('image/') || f.name.toLowerCase().endsWith('.pdf')));
|
||||
const existingKeys = new Set(selectedFiles.map(f => `${f.name}|${f.size}|${f.lastModified}`));
|
||||
const rejected = [];
|
||||
incoming.forEach(f => {
|
||||
const key = `${f.name}|${f.size}|${f.lastModified}`;
|
||||
if (!existingKeys.has(key)) {
|
||||
if (!existingKeys.has(key) && selectedFiles.length < MAX_SINGLE_UPLOAD_COUNT) {
|
||||
existingKeys.add(key);
|
||||
selectedFiles.push(f);
|
||||
} else if (!existingKeys.has(key) && selectedFiles.length >= MAX_SINGLE_UPLOAD_COUNT) {
|
||||
rejected.push(f.name);
|
||||
}
|
||||
});
|
||||
if (rejected.length) {
|
||||
uploadMsg.textContent = `单次最多上传 ${MAX_SINGLE_UPLOAD_COUNT} 个文件,以下文件未加入:${rejected.join('、')}`;
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
}
|
||||
const urls = selectedFiles.map(f => {
|
||||
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZjQ0NDQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQgMmgyYTIgMiAwIDAgMSAyIDJ2MTZhMiAyIDAgMCAxLTIgMmgtMTJhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJoMiIvPjxwYXRoIGQ9Ik0xNCAydjRjMCAxLjEgLjkgMiAyIDJoNCIvPjxwYXRoIGQ9Ik03IDloNSIvPjxwYXRoIGQ9Ik03IDEzaDUiLz48cGF0aCBkPSJNNyAxN2g4Ii8+PC9zdmc+';
|
||||
@@ -460,6 +470,12 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
uploadMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (selectedFiles.length > MAX_SINGLE_UPLOAD_COUNT) {
|
||||
uploadMsg.textContent = `单次最多上传 ${MAX_SINGLE_UPLOAD_COUNT} 个文件,请分批上传`;
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
showProgress();
|
||||
setProgress(5, '预处理中');
|
||||
@@ -494,6 +510,11 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
body: formData,
|
||||
});
|
||||
clearInterval(timer);
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.status !== 'success') {
|
||||
throw new Error(data.message || '上传识别失败');
|
||||
|
||||
@@ -289,40 +289,6 @@
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
{% if is_student %}
|
||||
<div class="card">
|
||||
<div class="header"><h2>修改密码</h2></div>
|
||||
<form id="selfPwdForm">
|
||||
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
|
||||
<div class="form-group">
|
||||
<label for="password">新密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认密码</label>
|
||||
<input type="password" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if is_tutor %}
|
||||
<div class="card">
|
||||
<div class="header"><h2>修改本人密码</h2></div>
|
||||
<form id="selfPwdForm">
|
||||
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
|
||||
<div class="form-group">
|
||||
<label for="password">新密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认密码</label>
|
||||
<input type="password" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2>用户管理</h2>
|
||||
@@ -356,7 +322,6 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑用户模态框 -->
|
||||
@@ -971,30 +936,6 @@
|
||||
// 页面加载时获取用户列表
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initKeyFilter();
|
||||
const selfForm = document.getElementById('selfPwdForm');
|
||||
if (selfForm) {
|
||||
selfForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const uid = document.getElementById('selfUserId').value;
|
||||
const pwd = document.getElementById('password').value;
|
||||
const cpwd = document.getElementById('confirmPassword').value;
|
||||
if (pwd !== cpwd) { showNotification('密码和确认密码不匹配', false); return; }
|
||||
if ((pwd || '').length < 6) { showNotification('密码长度至少为6位', false); return; }
|
||||
try {
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
const resp = await fetch(`/elastic/users/${uid}/update/`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
|
||||
body: JSON.stringify({ password: pwd })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.status === 'success') { showNotification('修改成功'); }
|
||||
else { showNotification(result.message || '操作失败', false); }
|
||||
} catch (error) {
|
||||
showNotification('保存失败', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
if (tbody) {
|
||||
const select = document.getElementById('keyFilter');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
524
elastic/views.py
524
elastic/views.py
@@ -6,10 +6,14 @@ import re
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone, timedelta
|
||||
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
|
||||
@@ -37,6 +41,8 @@ except ImportError as e:
|
||||
HAS_PDF_SUPPORT = False
|
||||
PDF_ERROR = str(e)
|
||||
|
||||
MAX_SINGLE_UPLOAD_COUNT = int(getattr(settings, "MAX_SINGLE_UPLOAD_COUNT", 3))
|
||||
|
||||
|
||||
def _filter_results_for_user(request, results):
|
||||
session_user_id = request.session.get("user_id")
|
||||
@@ -611,6 +617,7 @@ def ocr_and_extract_info(image_path: str):
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
base64_image = encode_image(image_path)
|
||||
mime_type = mimetypes.guess_type(image_path)[0] or "image/jpeg"
|
||||
|
||||
# api_key = getattr(settings, "AISTUDIO_API_KEY", "188f57db3766e02ed2c7e18373996d84f4112272")
|
||||
# base_url = getattr(settings, "OPENAI_BASE_URL", "https://aistudio.baidu.com/llm/lmapi/v3")
|
||||
@@ -662,7 +669,7 @@ def ocr_and_extract_info(image_path: str):
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"请识别这张图片中的信息,将你认为重要的数据转换为不包含嵌套的json,不要显示其它信息以便于解析,直接输出json结果即可。使用“数据类型”字段表示这个东西的大致类型,除此之外你可以自行决定使用哪些json字段。“数据类型”的内容有严格规定,请查看{json.dumps(types, ensure_ascii=False)}中是否包含你所需要的类型,确定不包含后你才可以填入你觉得合适的大致分类。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}},
|
||||
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -712,6 +719,7 @@ def upload_page(request):
|
||||
context = {
|
||||
"user_id": user_id_qs or session_user_id,
|
||||
"username": me.get("username"),
|
||||
"max_single_upload_count": MAX_SINGLE_UPLOAD_COUNT,
|
||||
}
|
||||
return render(request, "elastic/upload.html", context)
|
||||
|
||||
@@ -719,115 +727,133 @@ def upload_page(request):
|
||||
# 上传并识别(不入库)
|
||||
@require_http_methods(["POST"])
|
||||
def upload(request):
|
||||
if request.session.get("user_id") is None:
|
||||
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
|
||||
if fallback_uid:
|
||||
request.session["user_id"] = fallback_uid
|
||||
request.session.setdefault("permission", 1)
|
||||
else:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
try:
|
||||
if request.session.get("user_id") is None:
|
||||
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
|
||||
if fallback_uid:
|
||||
request.session["user_id"] = fallback_uid
|
||||
request.session.setdefault("permission", 1)
|
||||
else:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
|
||||
files = request.FILES.getlist("file")
|
||||
if not files:
|
||||
one = request.FILES.get("file")
|
||||
if one:
|
||||
files = [one]
|
||||
if not files:
|
||||
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
|
||||
files = request.FILES.getlist("file")
|
||||
if not files:
|
||||
one = request.FILES.get("file")
|
||||
if one:
|
||||
files = [one]
|
||||
if not files:
|
||||
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
|
||||
if len(files) > MAX_SINGLE_UPLOAD_COUNT:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"单次最多上传 {MAX_SINGLE_UPLOAD_COUNT} 个文件,请分批上传",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
# 按照原始文件进行分组处理
|
||||
file_results = []
|
||||
|
||||
for f in files:
|
||||
group_images = [] # 存储该文件生成的所有图片路径信息 (abs_path, filename)
|
||||
is_pdf = f.name.lower().endswith('.pdf')
|
||||
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if is_pdf:
|
||||
if not HAS_PDF_SUPPORT:
|
||||
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
|
||||
file_results = []
|
||||
|
||||
for f in files:
|
||||
group_images = []
|
||||
is_pdf = f.name.lower().endswith('.pdf')
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
|
||||
for chunk in f.chunks():
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
doc = fitz.open(tmp_path)
|
||||
for i in range(len(doc)):
|
||||
page = doc.load_page(i)
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
|
||||
img_abs_path = os.path.join(images_dir, img_filename)
|
||||
pix.save(img_abs_path)
|
||||
group_images.append((img_abs_path, img_filename))
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
else:
|
||||
filename = f"{uuid.uuid4()}_{f.name}"
|
||||
abs_path = os.path.join(images_dir, filename)
|
||||
with open(abs_path, "wb") as dst:
|
||||
for chunk in f.chunks():
|
||||
dst.write(chunk)
|
||||
group_images.append((abs_path, filename))
|
||||
if is_pdf:
|
||||
if not HAS_PDF_SUPPORT:
|
||||
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
|
||||
for chunk in f.chunks():
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
doc = fitz.open(tmp_path)
|
||||
for i in range(len(doc)):
|
||||
page = doc.load_page(i)
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
|
||||
img_abs_path = os.path.join(images_dir, img_filename)
|
||||
pix.save(img_abs_path)
|
||||
group_images.append((img_abs_path, img_filename))
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
else:
|
||||
filename = f"{uuid.uuid4()}_{f.name}"
|
||||
abs_path = os.path.join(images_dir, filename)
|
||||
with open(abs_path, "wb") as dst:
|
||||
for chunk in f.chunks():
|
||||
dst.write(chunk)
|
||||
group_images.append((abs_path, filename))
|
||||
|
||||
# 对该组图片并行进行 OCR 识别
|
||||
def run_ocr(img_info):
|
||||
abs_p, fname = img_info
|
||||
try:
|
||||
data = ocr_and_extract_info(abs_p)
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
def run_ocr(img_info):
|
||||
abs_p, fname = img_info
|
||||
try:
|
||||
data = ocr_and_extract_info(abs_p)
|
||||
return data, None
|
||||
except Exception as e:
|
||||
return None, f"{fname}: {str(e)}"
|
||||
|
||||
group_data_list = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
|
||||
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
res = future.result()
|
||||
if res:
|
||||
group_data_list.append(res)
|
||||
group_data_list = []
|
||||
group_errors = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
|
||||
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
res, err = future.result()
|
||||
if res:
|
||||
group_data_list.append(res)
|
||||
elif err:
|
||||
group_errors.append(err)
|
||||
|
||||
# 合并该文件的多页识别结果
|
||||
merged_group_data = {}
|
||||
for item in group_data_list:
|
||||
if not isinstance(item, dict): continue
|
||||
for k, v in item.items():
|
||||
key = str(k).strip()
|
||||
if not key: continue
|
||||
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
|
||||
merged_group_data[key] = v
|
||||
elif merged_group_data.get(key) != v:
|
||||
base = key
|
||||
idx = 2
|
||||
while f"{base}_{idx}" in merged_group_data: idx += 1
|
||||
merged_group_data[f"{base}_{idx}"] = v
|
||||
merged_group_data = {}
|
||||
for item in group_data_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for k, v in item.items():
|
||||
key = str(k).strip()
|
||||
if not key:
|
||||
continue
|
||||
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
|
||||
merged_group_data[key] = v
|
||||
elif merged_group_data.get(key) != v:
|
||||
base = key
|
||||
idx = 2
|
||||
while f"{base}_{idx}" in merged_group_data:
|
||||
idx += 1
|
||||
merged_group_data[f"{base}_{idx}"] = v
|
||||
|
||||
if not merged_group_data:
|
||||
# 如果没识别到,至少保留一个空结构或者包含文件名的提示
|
||||
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
|
||||
if not merged_group_data:
|
||||
merged_group_data = {
|
||||
"文件名": f.name,
|
||||
"提示": "未识别到具体内容" if not group_errors else "识别失败",
|
||||
}
|
||||
if group_errors:
|
||||
merged_group_data["错误信息"] = ";".join(group_errors[:3])
|
||||
|
||||
rel_paths = [f"images/{img[1]}" for img in group_images]
|
||||
image_urls = [request.build_absolute_uri(settings.MEDIA_URL + rp) for rp in rel_paths]
|
||||
rel_paths = [f"images/{img[1]}" for img in group_images]
|
||||
image_urls = [request.build_absolute_uri(settings.MEDIA_URL + rp) for rp in rel_paths]
|
||||
|
||||
file_results.append({
|
||||
"name": f.name,
|
||||
"data": merged_group_data,
|
||||
"images": rel_paths,
|
||||
"image_urls": image_urls,
|
||||
file_results.append({
|
||||
"name": f.name,
|
||||
"data": merged_group_data,
|
||||
"images": rel_paths,
|
||||
"image_urls": image_urls,
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
"status": "success",
|
||||
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
|
||||
"items": file_results,
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
"status": "success",
|
||||
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
|
||||
"items": file_results,
|
||||
})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e) or "上传失败"}, status=500)
|
||||
|
||||
|
||||
# 确认并入库
|
||||
@@ -953,6 +979,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 +1007,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"),
|
||||
},
|
||||
@@ -1020,7 +1048,7 @@ def analytics_types_view(request):
|
||||
size_int = int(size) if size is not None else 10
|
||||
except Exception:
|
||||
size_int = 10
|
||||
data = es_analytics_types(gte=gte, lte=lte, size=size_int)
|
||||
data = es_analytics_types(gte=gte, lte=lte, limit=size_int)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -1257,6 +1285,10 @@ def add_key_view(request):
|
||||
request.session.modified = True
|
||||
except Exception:
|
||||
pass
|
||||
cur_manage = [str(x).strip() for x in list((me or {}).get("manage_key") or []) if str(x).strip()]
|
||||
if key_name not in cur_manage:
|
||||
cur_manage.append(key_name)
|
||||
es_update_user_by_id(uid, manage_key=cur_manage)
|
||||
elif can_manage_reg:
|
||||
cur = [str(x).strip() for x in list((me or {}).get("registration_manage_keys") or []) if str(x).strip()]
|
||||
if key_name not in cur:
|
||||
@@ -1428,6 +1460,296 @@ 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):
|
||||
try:
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e) or "生成失败"}, status=500)
|
||||
|
||||
|
||||
@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):
|
||||
|
||||
Reference in New Issue
Block a user