18 Commits

Author SHA1 Message Date
DSQ
2afc24a8d9 模型更换[0.2.8.16][ci]
All checks were successful
CI / docker-ci (push) Successful in 23s
2026-05-31 15:44:27 +08:00
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
9 changed files with 788 additions and 324 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()
@@ -319,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()
@@ -421,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",
@@ -831,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,
@@ -848,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)}")
@@ -993,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

@@ -86,6 +86,7 @@
<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>
@@ -445,6 +446,11 @@ async function generateReport() {
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';
@@ -502,6 +508,10 @@ function downloadReportCsv() {
window.location.href = `/elastic/report/csv/?${params.toString()}`;
}
function exportAllData() {
window.location.href = "/elastic/export_achievements_csv/";
}
// 渲染表格
function renderTable(data) {
tableBody.innerHTML = '';

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

@@ -23,6 +23,7 @@ urlpatterns = [
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

@@ -8,6 +8,15 @@ 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
@@ -39,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")
@@ -613,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")
@@ -655,7 +667,7 @@ def ocr_and_extract_info(image_path: str):
# or getattr(settings, "ZAI_BASE_URL", "")
# or "https://open.bigmodel.cn/api/paas/v4/"
# )
client = ZhipuAiClient(api_key="fb83a3f91e8c4e45af811236548765a2.cX4kUhigHm7VNowf")
client = ZhipuAiClient(api_key="982e9dc9d27a4f2f9db7e9effdb3c484.OfiJCdncJnTn0Q9h")
types = get_type_list()
chat_completion = client.chat.completions.create(
messages=[
@@ -664,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}"}},
],
},
],
@@ -714,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)
@@ -721,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)
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True)
# 按照原始文件进行分组处理
file_results = []
file_results = []
for f in files:
group_images = [] # 存储该文件生成的所有图片路径信息 (abs_path, filename)
is_pdf = f.name.lower().endswith('.pdf')
for f in files:
group_images = []
is_pdf = f.name.lower().endswith('.pdf')
if is_pdf:
if not HAS_PDF_SUPPORT:
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
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
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))
try:
doc = fitz.open(tmp_path)
for i in range(len(doc)):
page = doc.load_page(i)
pix = page.get_pixmap(dpi=150)
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
img_abs_path = os.path.join(images_dir, img_filename)
pix.save(img_abs_path)
group_images.append((img_abs_path, img_filename))
doc.close()
except Exception as e:
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
else:
filename = f"{uuid.uuid4()}_{f.name}"
abs_path = os.path.join(images_dir, filename)
with open(abs_path, "wb") as dst:
for chunk in f.chunks():
dst.write(chunk)
group_images.append((abs_path, filename))
# 对该组图片并行进行 OCR 识别
def run_ocr(img_info):
abs_p, fname = img_info
try:
data = ocr_and_extract_info(abs_p)
return data
except Exception:
return None
def run_ocr(img_info):
abs_p, fname = img_info
try:
data = ocr_and_extract_info(abs_p)
return data, None
except Exception as e:
return None, f"{fname}: {str(e)}"
group_data_list = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
group_data_list.append(res)
group_data_list = []
group_errors = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
for future in concurrent.futures.as_completed(futures):
res, err = future.result()
if res:
group_data_list.append(res)
elif err:
group_errors.append(err)
# 合并该文件的多页识别结果
merged_group_data = {}
for item in group_data_list:
if not isinstance(item, dict): continue
for k, v in item.items():
key = str(k).strip()
if not key: continue
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
merged_group_data[key] = v
elif merged_group_data.get(key) != v:
base = key
idx = 2
while f"{base}_{idx}" in merged_group_data: idx += 1
merged_group_data[f"{base}_{idx}"] = v
merged_group_data = {}
for item in group_data_list:
if not isinstance(item, dict):
continue
for k, v in item.items():
key = str(k).strip()
if not key:
continue
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
merged_group_data[key] = v
elif merged_group_data.get(key) != v:
base = key
idx = 2
while f"{base}_{idx}" in merged_group_data:
idx += 1
merged_group_data[f"{base}_{idx}"] = v
if not merged_group_data:
# 如果没识别到,至少保留一个空结构或者包含文件名的提示
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
if not merged_group_data:
merged_group_data = {
"文件名": f.name,
"提示": "未识别到具体内容" if not group_errors else "识别失败",
}
if group_errors:
merged_group_data["错误信息"] = "".join(group_errors[:3])
rel_paths = [f"images/{img[1]}" for img in group_images]
image_urls = [request.build_absolute_uri(settings.MEDIA_URL + rp) for rp in rel_paths]
rel_paths = [f"images/{img[1]}" for img in group_images]
file_results.append({
"name": f.name,
"data": merged_group_data,
"images": rel_paths,
"image_urls": image_urls,
# 改进:如果配置了 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))
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)
# 确认并入库
@@ -1024,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)
@@ -1261,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:
@@ -1580,104 +1634,107 @@ def _dt_label(dt, interval: str):
@require_http_methods(["GET"])
def report_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
is_admin = int(request.session.get("permission", 1)) == 0
me = get_user_by_id(session_user_id) or {}
has_manage_key = bool(me.get("manage_key") or [])
if (not is_admin) and (not has_manage_key):
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
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 = (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
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 [])
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:
if key:
selected = str(key).strip()
try:
u_id = str(u.get("user_id", "")).strip()
users = get_all_users() or []
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
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 = []
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:
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)
t = _parse_dt(r.get("time"))
if (gte_dt or lte_dt) and (t is None):
continue
if selected and selected in str(r.get("data", "")):
tmp.append(r)
filtered = tmp
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)
if typ:
tsel = str(typ).strip()
filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel]
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
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_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])]
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,
},
}
)
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"])
@@ -1719,6 +1776,208 @@ def report_csv_view(request):
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):