13 Commits

Author SHA1 Message Date
DSQ
01a3b2dfdb 部分修改[0.2.8.13][ci]
All checks were successful
CI / docker-ci (push) Successful in 30s
2026-03-23 13:08:42 +08:00
DSQ
0dd7879389 [0.2.8.12][ci]
All checks were successful
CI / docker-ci (push) Successful in 30s
2026-03-23 12:42:27 +08:00
DSQ
19f805c818 Merge remote-tracking branch 'origin/Django' into Django 2026-03-23 12:38:33 +08:00
DSQ
d84d0218cd [0.2.8.11][ci] 2026-03-23 12:38:14 +08:00
e92964ce71 [0.2.8.10][ci]
All checks were successful
CI / docker-ci (push) Successful in 24s
2026-03-23 11:53:10 +08:00
1a3aee39e0 [0.2.8.10]
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:52:41 +08:00
DSQ
7fa7b42b1a Merge remote-tracking branch 'origin/Django' into Django
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:51:34 +08:00
DSQ
26452161f8 [0.2.8.0][ci] 2026-03-23 11:51:02 +08:00
07d3a4420c 生成镜像[0.2.7.9][ci]
All checks were successful
CI / docker-ci (push) Successful in 25s
2026-03-23 11:28:14 +08:00
2c3c2d6acf Merge branch 'Django' of gitea.spdis.space:Viajero/Achievement_Inputing into Django
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:06:59 +08:00
afc663844b 修复主页的类型分析的500问题[0.2.7.9][ci] 2026-03-23 11:06:45 +08:00
DSQ
9e3fe7150b [0.2.7.8][ci]
All checks were successful
CI / docker-ci (push) Successful in 26s
2026-03-23 11:02:00 +08:00
DSQ
c9611fa622 [0.2.7.7][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-23 10:37:46 +08:00
8 changed files with 502 additions and 313 deletions

View File

@@ -39,8 +39,8 @@
.msg.success { color: #166534; } .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 { 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; } .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; } .image-modal-close { position: absolute; top: 20px; right: 30px; color: white; font-size: 40px; font-weight: bold; cursor: pointer; }
</style> </style>
</head> </head>
@@ -48,7 +48,7 @@
<!-- 侧边栏 --> <!-- 侧边栏 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id-sidebar"> <div class="user-id-sidebar">
<h3>你好,{{ username|default:"访客" }}</h3> <h3>你好,<span id="sidebarUsername">{{ username|default:"访客" }}</span></h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a> <a href="{% url 'main:home' %}">返回主页</a>
@@ -58,6 +58,58 @@
</div> </div>
<div class="main-content"> <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-card">
<div class="profile-header"> <div class="profile-header">
<div class="profile-info"> <div class="profile-info">
@@ -65,7 +117,7 @@
</div> </div>
</div> </div>
<div class="profile-details"> <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">用户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.registration_code|default:"无" }}</p>
<p><span class="label">所属:</span> {{ profile_user.key|join:"、"|default:"未填写" }}</p> <p><span class="label">所属:</span> {{ profile_user.key|join:"、"|default:"未填写" }}</p>
@@ -74,6 +126,19 @@
</div> </div>
</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> <div class="section-title">我的提交</div>
{% if achievements %} {% if achievements %}
<div class="image-grid"> <div class="image-grid">
@@ -96,49 +161,6 @@
<a href="{% url 'elastic:upload_page' %}" style="color: #2d8cf0; text-decoration: none;">去上传第一张图片吧!</a> <a href="{% url 'elastic:upload_page' %}" style="color: #2d8cf0; text-decoration: none;">去上传第一张图片吧!</a>
</div> </div>
{% endif %} {% 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 %} {% endif %}
</div> </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) { function openModal(src) {
const modal = document.getElementById('imageModal'); const modal = document.getElementById('imageModal');
const modalImg = document.getElementById('modalImg'); const modalImg = document.getElementById('modalImg');
modal.style.display = "flex"; modal.style.display = "flex";
modalImg.src = src; modalImg.src = src;
resetModalTransform();
} }
function closeModal() { function closeModal() {
document.getElementById('imageModal').style.display = "none"; document.getElementById('imageModal').style.display = "none";
} }
window.onclick = function(event) { const modalEl = document.getElementById('imageModal');
const modal = document.getElementById('imageModal'); const modalImgEl = document.getElementById('modalImg');
if (event.target == modal) closeModal(); 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'); 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'); const rcForm = document.getElementById('rcForm');
if (rcForm) { if (rcForm) {
let rcPreviewTimer = null; let rcPreviewTimer = null;

View File

@@ -13,6 +13,10 @@ urlpatterns = [
path("register/submit/", views.register_submit, name="register_submit"), path("register/submit/", views.register_submit, name="register_submit"),
path("email/send-code/", views.send_email_code, name="send_email_code"), path("email/send-code/", views.send_email_code, name="send_email_code"),
path("profile/", views.profile_page, name="profile"), 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/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("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"), path("registration-code/request/submit/", views.submit_registration_code_request_view, name="submit_registration_code_request"),

View File

@@ -71,33 +71,62 @@ def set_session_key(request):
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii") request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
return JsonResponse({"ok": True}) return JsonResponse({"ok": True})
@require_http_methods(["GET"]) def _build_profile_context(request):
@ensure_csrf_cookie
def profile_page(request):
session_user_id = request.session.get("user_id") session_user_id = request.session.get("user_id")
if session_user_id is None: if session_user_id is None:
return redirect("/accounts/login/") return None
# 获取用户信息
user = get_user_by_id(session_user_id) user = get_user_by_id(session_user_id)
if not user: if not user:
return redirect("/accounts/login/") return None
# 获取个人提交的成就(图片)
from elastic.es_connect import search_all from elastic.es_connect import search_all
from elastic.views import _attach_image_urls 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)] 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) achievements = _attach_image_urls(request, raw_results)
permission_name = "管理员" if int(user.get("permission", 1)) == 0 else "普通用户" permission_name = "管理员" if int(user.get("permission", 1)) == 0 else "普通用户"
return {
context = {
"username": request.session.get("username"), "username": request.session.get("username"),
"profile_user": user, "profile_user": user,
"permission_name": permission_name, "permission_name": permission_name,
"achievements": achievements, "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) return render(request, "accounts/profile.html", context)
@require_http_methods(["POST"]) @require_http_methods(["POST"])
@@ -304,6 +333,34 @@ def replace_registration_code_view(request):
return JsonResponse({"ok": False, "message": "替换失败"}, status=500) return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
return JsonResponse({"ok": True}) 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"]) @require_http_methods(["GET"])
def registration_code_preview_view(request): def registration_code_preview_view(request):
session_user_id = request.session.get("user_id") session_user_id = request.session.get("user_id")

View File

@@ -831,12 +831,8 @@ def get_user_by_username(username):
def get_all_users(): def get_all_users():
"""获取所有用户""" """获取所有用户"""
try: try:
search = UserDocument.search()
search = search.query("match_all")
response = search.execute()
users = [] users = []
for hit in response: for hit in UserDocument.search().query("match_all").scan():
users.append({ users.append({
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
@@ -848,7 +844,6 @@ def get_all_users():
"key": list(getattr(hit, 'key', []) or []), "key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []), "manage_key": list(getattr(hit, 'manage_key', []) or []),
}) })
return users return users
except Exception as e: except Exception as e:
print(f"获取所有用户失败: {str(e)}") print(f"获取所有用户失败: {str(e)}")

View File

@@ -445,6 +445,11 @@ async function generateReport() {
try { try {
const params = buildReportParams(); const params = buildReportParams();
const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' }); 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(); const data = await resp.json();
if (data.status !== 'success') { if (data.status !== 'success') {
reportBox.className = 'search-result error'; reportBox.className = 'search-result error';

View File

@@ -494,6 +494,11 @@ uploadForm.addEventListener('submit', async (e) => {
body: formData, body: formData,
}); });
clearInterval(timer); 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(); const data = await resp.json();
if (!resp.ok || data.status !== 'success') { if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '上传识别失败'); throw new Error(data.message || '上传识别失败');

View File

@@ -289,40 +289,6 @@
</div> </div>
<div class="main-content"> <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="card">
<div class="header"> <div class="header">
<h2>用户管理</h2> <h2>用户管理</h2>
@@ -356,7 +322,6 @@
</table> </table>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<!-- 添加/编辑用户模态框 --> <!-- 添加/编辑用户模态框 -->
@@ -971,30 +936,6 @@
// 页面加载时获取用户列表 // 页面加载时获取用户列表
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initKeyFilter(); 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'); const tbody = document.getElementById('usersTableBody');
if (tbody) { if (tbody) {
const select = document.getElementById('keyFilter'); const select = document.getElementById('keyFilter');

View File

@@ -8,6 +8,7 @@ import base64
import json import json
import csv import csv
import io import io
from datetime import datetime, timezone, timedelta
import tempfile import tempfile
import concurrent.futures import concurrent.futures
from django.conf import settings from django.conf import settings
@@ -721,115 +722,117 @@ def upload_page(request):
# 上传并识别(不入库) # 上传并识别(不入库)
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def upload(request): def upload(request):
if request.session.get("user_id") is None: try:
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id") if request.session.get("user_id") is None:
if fallback_uid: fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
request.session["user_id"] = fallback_uid if fallback_uid:
request.session.setdefault("permission", 1) request.session["user_id"] = fallback_uid
else: request.session.setdefault("permission", 1)
return JsonResponse({"status": "error", "message": "未登录"}, status=401) else:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
files = request.FILES.getlist("file") files = request.FILES.getlist("file")
if not files: if not files:
one = request.FILES.get("file") one = request.FILES.get("file")
if one: if one:
files = [one] files = [one]
if not files: if not files:
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400) return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
images_dir = os.path.join(settings.MEDIA_ROOT, "images") images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True) os.makedirs(images_dir, exist_ok=True)
# 按照原始文件进行分组处理 file_results = []
file_results = []
for f in files: for f in files:
group_images = [] # 存储该文件生成的所有图片路径信息 (abs_path, filename) group_images = []
is_pdf = f.name.lower().endswith('.pdf') is_pdf = f.name.lower().endswith('.pdf')
if is_pdf: if is_pdf:
if not HAS_PDF_SUPPORT: if not HAS_PDF_SUPPORT:
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500) return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
for chunk in f.chunks(): for chunk in f.chunks():
tmp.write(chunk) tmp.write(chunk)
tmp_path = tmp.name tmp_path = tmp.name
try: try:
doc = fitz.open(tmp_path) doc = fitz.open(tmp_path)
for i in range(len(doc)): for i in range(len(doc)):
page = doc.load_page(i) page = doc.load_page(i)
pix = page.get_pixmap(dpi=150) pix = page.get_pixmap(dpi=150)
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg" img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
img_abs_path = os.path.join(images_dir, img_filename) img_abs_path = os.path.join(images_dir, img_filename)
pix.save(img_abs_path) pix.save(img_abs_path)
group_images.append((img_abs_path, img_filename)) group_images.append((img_abs_path, img_filename))
doc.close() doc.close()
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500) return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
else: else:
filename = f"{uuid.uuid4()}_{f.name}" filename = f"{uuid.uuid4()}_{f.name}"
abs_path = os.path.join(images_dir, filename) abs_path = os.path.join(images_dir, filename)
with open(abs_path, "wb") as dst: with open(abs_path, "wb") as dst:
for chunk in f.chunks(): for chunk in f.chunks():
dst.write(chunk) dst.write(chunk)
group_images.append((abs_path, filename)) group_images.append((abs_path, filename))
# 对该组图片并行进行 OCR 识别 def run_ocr(img_info):
def run_ocr(img_info): abs_p, fname = img_info
abs_p, fname = img_info try:
try: data = ocr_and_extract_info(abs_p)
data = ocr_and_extract_info(abs_p) return data
return data except Exception:
except Exception: return None
return None
group_data_list = [] group_data_list = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor: 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] futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
res = future.result() res = future.result()
if res: if res:
group_data_list.append(res) group_data_list.append(res)
# 合并该文件的多页识别结果 merged_group_data = {}
merged_group_data = {} for item in group_data_list:
for item in group_data_list: if not isinstance(item, dict):
if not isinstance(item, dict): continue continue
for k, v in item.items(): for k, v in item.items():
key = str(k).strip() key = str(k).strip()
if not key: continue if not key:
if key not in merged_group_data or merged_group_data.get(key) in (None, ''): continue
merged_group_data[key] = v if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
elif merged_group_data.get(key) != v: merged_group_data[key] = v
base = key elif merged_group_data.get(key) != v:
idx = 2 base = key
while f"{base}_{idx}" in merged_group_data: idx += 1 idx = 2
merged_group_data[f"{base}_{idx}"] = v while f"{base}_{idx}" in merged_group_data:
idx += 1
merged_group_data[f"{base}_{idx}"] = v
if not merged_group_data: if not merged_group_data:
# 如果没识别到,至少保留一个空结构或者包含文件名的提示 merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
rel_paths = [f"images/{img[1]}" for img in group_images] 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] image_urls = [request.build_absolute_uri(settings.MEDIA_URL + rp) for rp in rel_paths]
file_results.append({ file_results.append({
"name": f.name, "name": f.name,
"data": merged_group_data, "data": merged_group_data,
"images": rel_paths, "images": rel_paths,
"image_urls": image_urls, "image_urls": image_urls,
})
return JsonResponse({
"status": "success",
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
"items": file_results,
}) })
except Exception as e:
return JsonResponse({ return JsonResponse({"status": "error", "message": str(e) or "上传失败"}, status=500)
"status": "success",
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
"items": file_results,
})
# 确认并入库 # 确认并入库
@@ -1024,7 +1027,7 @@ def analytics_types_view(request):
size_int = int(size) if size is not None else 10 size_int = int(size) if size is not None else 10
except Exception: except Exception:
size_int = 10 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}) return JsonResponse({"status": "success", "data": data})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -1261,6 +1264,10 @@ def add_key_view(request):
request.session.modified = True request.session.modified = True
except Exception: except Exception:
pass 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: elif can_manage_reg:
cur = [str(x).strip() for x in list((me or {}).get("registration_manage_keys") or []) if str(x).strip()] 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: if key_name not in cur:
@@ -1580,104 +1587,107 @@ def _dt_label(dt, interval: str):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def report_view(request): def report_view(request):
session_user_id = request.session.get("user_id") try:
if session_user_id is None: session_user_id = request.session.get("user_id")
return JsonResponse({"status": "error", "message": "未登录"}, status=401) if session_user_id is None:
is_admin = int(request.session.get("permission", 1)) == 0 return JsonResponse({"status": "error", "message": "未登录"}, status=401)
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 []) me = get_user_by_id(session_user_id) or {}
if (not is_admin) and (not has_manage_key): has_manage_key = bool(me.get("manage_key") or [])
return JsonResponse({"status": "error", "message": "无权限"}, status=403) if (not is_admin) and (not has_manage_key):
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
gte = (request.GET.get("from") or "").strip() gte = (request.GET.get("from") or "").strip()
lte = (request.GET.get("to") or "").strip() lte = (request.GET.get("to") or "").strip()
interval = (request.GET.get("interval") or "day").strip() interval = (request.GET.get("interval") or "day").strip()
key = (request.GET.get("key") or "").strip() key = (request.GET.get("key") or "").strip()
typ = (request.GET.get("type") or "").strip() typ = (request.GET.get("type") or "").strip()
gte_dt = _parse_dt(gte) if gte else None gte_dt = _parse_dt(gte) if gte else None
lte_dt = _parse_dt(lte) if lte else None lte_dt = _parse_dt(lte) if lte else None
results = search_all() results = search_all()
results = _filter_results_for_user(request, results) results = _filter_results_for_user(request, results)
filtered = list(results or []) filtered = list(results or [])
if key: if key:
selected = str(key).strip() selected = str(key).strip()
try:
users = get_all_users() or []
except Exception:
users = []
writer_keys_by_id = {}
for u in users:
try: try:
u_id = str(u.get("user_id", "")).strip() users = get_all_users() or []
except Exception: except Exception:
u_id = "" users = []
if not u_id: writer_keys_by_id = {}
continue for u in users:
try: try:
u_keys = {str(k).strip() for k in (u.get("key") or []) if str(k).strip()} u_id = str(u.get("user_id", "")).strip()
except Exception: except Exception:
u_keys = set() u_id = ""
writer_keys_by_id[u_id] = u_keys 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 = [] 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: for r in filtered:
writer_id = str(r.get("writer_id", "")).strip() t = _parse_dt(r.get("time"))
writer_keys = writer_keys_by_id.get(writer_id) if (gte_dt or lte_dt) and (t is None):
if writer_keys and selected in writer_keys:
tmp.append(r)
continue continue
if selected and selected in str(r.get("data", "")): if gte_dt and t and t < gte_dt:
tmp.append(r) continue
filtered = tmp if lte_dt and t and t > lte_dt:
continue
rr = dict(r)
rr["_time_dt"] = t
ranged.append(rr)
if typ: by_type = {}
tsel = str(typ).strip() by_bucket = {}
filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel] 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
ranged = [] by_type_arr = [{"type": k, "count": int(v)} for k, v in sorted(by_type.items(), key=lambda x: (-x[1], x[0]))]
for r in filtered: by_time_arr = [{"bucket": k, "count": int(v)} for k, v in sorted(by_bucket.items(), key=lambda x: x[0])]
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 = {} return JsonResponse(
by_bucket = {} {
for r in ranged: "status": "success",
tval = _extract_type_from_data(r.get("data")) "data": {
if tval: "generated_at": datetime.now(timezone.utc).isoformat(),
by_type[tval] = by_type.get(tval, 0) + 1 "range": {"from": gte or "", "to": lte or ""},
bucket_dt = _floor_dt(r.get("_time_dt"), interval) "filters": {"key": key or "", "type": typ or "", "interval": interval or "day"},
if bucket_dt: "total": len(ranged),
label = _dt_label(bucket_dt, interval) "by_type": by_type_arr,
if label: "by_time": by_time_arr,
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])] except Exception as e:
return JsonResponse({"status": "error", "message": str(e) or "生成失败"}, status=500)
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"]) @require_http_methods(["GET"])