18 Commits

Author SHA1 Message Date
DSQ
0404c7e274 修BUG[0.2.8.16][ci]
All checks were successful
CI / docker-ci (push) Successful in 21s
2026-05-31 15:17:28 +08:00
DSQ
69c5747867 增加一键导出excel的功能[0.2.8.15][ci]
All checks were successful
CI / docker-ci (push) Successful in 23s
2026-05-25 18:39:34 +08:00
DSQ
d4de99971a 修复了数据储存bug[0.2.8.15][ci]
All checks were successful
CI / docker-ci (push) Successful in 24s
2026-05-25 13:16:27 +08:00
DSQ
27f8a64fdb 部分修改[0.2.8.14][ci]
All checks were successful
CI / docker-ci (push) Successful in 3m28s
2026-05-25 10:57:23 +08:00
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
DSQ
fe7f08ed1c 数据管理添加时间的显示和统计报表的功能[0.2.7.6][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-19 16:38:16 +08:00
9 changed files with 1177 additions and 247 deletions

View File

@@ -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;

View File

@@ -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"),

View File

@@ -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")

View File

@@ -199,7 +199,8 @@ def get_registration_code(code: str):
def list_registration_codes():
try:
search = RegistrationCodeDocument.search()
# 增加 size=1000 以支持返回更多注册码
search = RegistrationCodeDocument.search()[:1000]
body = {
"sort": [{"created_at": {"order": "desc"}}],
"query": {"exists": {"field": "code"}}
@@ -297,7 +298,8 @@ def search_data(query):
"""
try:
# 使用Django-elasticsearch-dsl进行搜索
search = AchievementDocument.search()
# 增加 size=10000 以支持返回更多结果ES默认限制为10000如需更多需分页
search = AchievementDocument.search()[:10000]
search = search.query("multi_match", query=query, fields=['*'])
response = search.execute()
@@ -307,7 +309,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
@@ -318,7 +321,8 @@ def search_data(query):
def search_all():
"""获取所有文档"""
try:
search = AchievementDocument.search()
# 增加 size=10000 以支持返回更多结果ES默认限制为10000如需更多需分页
search = AchievementDocument.search()[:10000]
search = search.query("match_all")
response = search.execute()
@@ -328,7 +332,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
@@ -419,7 +424,8 @@ def search_by_any_field(keyword):
list: 包含搜索结果的列表
"""
try:
search = AchievementDocument.search()
# 增加 size=10000 以支持返回更多结果ES默认限制为10000如需更多需分页
search = AchievementDocument.search()[:10000]
# 使用multi_match查询在所有字段中搜索
search = search.query("multi_match",
@@ -435,7 +441,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 +835,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 +848,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)}")
@@ -990,7 +992,7 @@ def list_registration_code_manage_requests(status: str = None, limit: int = 200)
if status:
must.append({"term": {"status": str(status)}})
body = {
"size": max(1, min(int(limit or 200), 500)),
"size": max(1, min(int(limit or 200), 2000)),
"query": {"bool": {"must": must}},
"sort": [{"created_at": {"order": "desc"}}],
}

View File

@@ -81,9 +81,12 @@
<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>
<button class="btn btn-primary" onclick="exportAllData()">一键导出Excel</button>
<button class="btn" onclick="clearSearch()">清空结果</button>
</div>
@@ -93,12 +96,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 +175,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 +200,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 +212,7 @@ let currentSearchQuery = ''; // 记录当前搜索查询
let isFuzzySearch = false; // 记录当前是否为模糊搜索
let isDeleting = false; // 标记是否正在删除
let currentKeyFilter = '';
let currentTypeFilter = '';
// 图片缩放相关变量
let currentScale = 1;
@@ -205,6 +236,10 @@ async function performSearch(type) {
currentKeyFilter = '';
if (keyFilterSelect) keyFilterSelect.value = '';
}
if (currentTypeFilter) {
currentTypeFilter = '';
if (typeFilterSelect) typeFilterSelect.value = '';
}
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
@@ -270,11 +305,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 +341,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 +356,7 @@ function clearSearch() {
searchResultDiv.style.display = 'none';
currentSearchQuery = '';
if (currentKeyFilter) {
if (currentKeyFilter || currentTypeFilter) {
loadAllData();
return;
}
@@ -359,13 +397,128 @@ 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 exportAllData() {
window.location.href = "/elastic/export_achievements_csv/";
}
// 渲染表格
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 +575,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 +586,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 +833,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();
});

View File

@@ -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 || '上传识别失败');

View File

@@ -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');

View File

@@ -19,6 +19,11 @@ 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('export_achievements_csv/', views.export_achievements_csv, name='export_achievements_csv'),
# 用户管理
path('users/', views.get_users, name='get_users'),

View File

@@ -6,10 +6,21 @@ import re
import uuid
import base64
import json
import csv
import io
import mimetypes
try:
import openpyxl
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XLImage
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
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 +48,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 +624,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 +676,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 +726,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 +734,152 @@ 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
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))
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 = []
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
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]
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))
# 改进:如果配置了 MinIO则在上传阶段就同步到 MinIO确保在线版本待处理列表能显示图片
image_urls = []
from minio_storage.minio_connect import is_minio_configured, upload_file, presigned_get_url
minio_enabled = is_minio_configured()
for rp in rel_paths:
abs_p = os.path.join(settings.MEDIA_ROOT, rp)
if minio_enabled:
try:
# 上传到 MinIO
upload_file(abs_p, rp)
# 生成预签名 URL
url = presigned_get_url(rp)
image_urls.append(url)
except Exception as e:
print(f"上传临时图片到 MinIO 失败: {e}")
image_urls.append(request.build_absolute_uri(settings.MEDIA_URL + rp))
else:
image_urls.append(request.build_absolute_uri(settings.MEDIA_URL + rp))
# 对该组图片并行进行 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
file_results.append({
"name": f.name,
"data": merged_group_data,
"images": rel_paths,
"image_urls": image_urls,
})
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)
# 合并该文件的多页识别结果
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, "提示": "未识别到具体内容"}
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,
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 +1005,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 +1033,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 +1074,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 +1311,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 +1486,498 @@ 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(["GET"])
def export_achievements_csv(request):
"""一键导出所有可见成果为 Excel (如果支持) 或 CSV"""
try:
session_user_id = request.session.get("user_id")
if session_user_id is None:
return HttpResponse("Unauthorized", status=401)
# 1. 获取所有数据
results = search_all()
# 2. 根据权限过滤
results = _filter_results_for_user(request, results)
# 3. 补充录入人姓名
results = _attach_writer_names(results)
if not results:
return HttpResponse("No data to export", status=404)
# 4. 解析数据并准备数据列表
parsed_data_list = []
all_data_keys = set()
for item in results:
raw_data = item.get("data", "{}")
try:
if isinstance(raw_data, str):
parsed_dict = json.loads(raw_data)
else:
parsed_dict = raw_data
except Exception:
parsed_dict = {"原始数据": str(raw_data)}
if not isinstance(parsed_dict, dict):
parsed_dict = {"数据内容": str(parsed_dict)}
# 展平基础字段和动态数据字段
flat_item = {
"ID": item.get("_id", ""),
"录入人": item.get("writer_name") or item.get("writer_id", ""),
"时间": format_datetime_for_export(item.get("time")),
}
# 清理动态字段中的换行符
clean_parsed_dict = {}
for k, v in parsed_dict.items():
if isinstance(v, str):
clean_parsed_dict[k] = v.replace('\r', '').replace('\n', ' ')
else:
clean_parsed_dict[k] = v
flat_item.update(clean_parsed_dict)
# 保存原始图片引用以便导出 Excel 时使用
flat_item["_image_refs"] = _parse_image_refs(item.get("image", ""))
parsed_data_list.append(flat_item)
all_data_keys.update(clean_parsed_dict.keys())
# 确定表头:基础字段 + 动态字段(按字母排序)
dynamic_headers = sorted(list(all_data_keys))
headers = ["ID", "录入人", "时间"] + dynamic_headers
# 如果是 Excel 且支持图片,添加图片列
if HAS_OPENPYXL:
headers.append("成果图片")
filename_base = f"achievements_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# 5. 如果安装了 openpyxl生成 Excel
if HAS_OPENPYXL:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "成果数据"
# 写入表头
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = openpyxl.styles.Font(bold=True)
cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center')
# 写入数据
img_col_index = headers.index("成果图片") + 1 if "成果图片" in headers else None
for row_num, row_data in enumerate(parsed_data_list, 2):
for col_num, header in enumerate(headers, 1):
if header == "成果图片":
continue # 图片单独处理
ws.cell(row=row_num, column=col_num, value=row_data.get(header, ""))
# 处理图片插入
if img_col_index and row_data.get("_image_refs"):
first_ref = row_data["_image_refs"][0]
img_bytes = _get_image_bytes(first_ref)
if img_bytes:
try:
img = XLImage(img_bytes)
# 调整图片大小以适应单元格 (假设高度 80 像素左右)
aspect_ratio = img.width / img.height
img.height = 80
img.width = 80 * aspect_ratio
# 计算插入位置
cell_address = f"{get_column_letter(img_col_index)}{row_num}"
ws.add_image(img, cell_address)
# 设置行高以容纳图片 (80 像素约为 60 磅)
ws.row_dimensions[row_num].height = 65
except Exception as e:
ws.cell(row=row_num, column=img_col_index, value=f"图片加载失败: {str(e)}")
# 自动调整列宽
for i, column_cells in enumerate(ws.columns, 1):
header = headers[i-1]
if header == "成果图片":
ws.column_dimensions[get_column_letter(i)].width = 20
continue
length = max(len(str(cell.value or "")) for cell in column_cells)
ws.column_dimensions[get_column_letter(i)].width = min(length + 2, 50)
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
response['Content-Disposition'] = f'attachment; filename="{filename_base}.xlsx"'
wb.save(response)
return response
# 6. 否则回退到 CSV
output = io.StringIO()
output.write('\ufeff') # UTF-8 BOM
# 增加 extrasaction='ignore' 以忽略 _image_refs 等内部辅助字段
writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore')
writer.writeheader()
for row in parsed_data_list:
writer.writerow(row)
response = HttpResponse(output.getvalue(), content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{filename_base}.csv"'
return response
except Exception as e:
import traceback
traceback.print_exc()
return HttpResponse(f"导出失败: {str(e)}", status=500)
def format_datetime_for_export(t):
if not t: return ""
try:
if isinstance(t, datetime):
return t.strftime("%Y-%m-%d %H:%M:%S")
d = datetime.fromisoformat(str(t).replace('Z', '+00:00'))
return d.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return str(t)
def _get_image_bytes(image_ref):
"""根据 image_ref 获取图片字节流,并确保转换为 Excel 支持的格式 (如 JPEG/PNG)"""
s = str(image_ref or '').strip()
if not s:
return None
img_raw_bytes = None
if s.startswith('minio:'):
object_name = s[len('minio:'):].lstrip('/')
try:
from minio_storage.minio_connect import get_minio_client, get_bucket_name
client = get_minio_client()
bucket = get_bucket_name()
if client:
response = client.get_object(bucket, object_name)
img_raw_bytes = response.read()
except Exception:
pass
elif s.startswith('local:'):
rel_path = s[len('local:'):].lstrip('/')
abs_path = os.path.join(settings.MEDIA_ROOT, rel_path)
if os.path.isfile(abs_path):
try:
with open(abs_path, 'rb') as f:
img_raw_bytes = f.read()
except Exception:
pass
if not img_raw_bytes:
return None
# 处理 WebP 或其他 openpyxl 可能不支持的格式
try:
from PIL import Image as PILImage
img_io = io.BytesIO(img_raw_bytes)
with PILImage.open(img_io) as pil_img:
# 如果是 WebP 或带有透明通道的图片,转换为 RGB 格式并存为 JPEG 或 PNG
# Excel 对 PNG 支持较好
output_io = io.BytesIO()
if pil_img.format == 'WEBP' or pil_img.mode in ('RGBA', 'LA', 'P'):
rgb_img = pil_img.convert('RGB')
rgb_img.save(output_io, format='JPEG', quality=85)
else:
pil_img.save(output_io, format=pil_img.format or 'JPEG')
output_io.seek(0)
return output_io
except Exception as e:
print(f"图片转换失败: {str(e)}")
return io.BytesIO(img_raw_bytes) # 尝试直接返回原始数据作为最后手段
@require_http_methods(["POST"])
@csrf_protect
def revoke_registration_code_view(request):