Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4de99971a | |||
| 27f8a64fdb | |||
| 01a3b2dfdb |
@@ -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>
|
||||
@@ -79,7 +79,6 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if subpage == "password" %}
|
||||
{% if permission_name != "管理员" and not profile_user.manage_key %}
|
||||
<form id="pwdForm">
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
@@ -92,9 +91,6 @@
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<div id="pwdMsg" class="msg"></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="msg error">无权限</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if subpage == "registration-code" %}
|
||||
<form id="rcForm">
|
||||
@@ -138,9 +134,7 @@
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<a class="btn" href="{% url 'accounts:profile_username' %}">修改用户名</a>
|
||||
{% if permission_name != "管理员" and not profile_user.manage_key %}
|
||||
<a class="btn" href="{% url 'accounts:profile_password' %}">修改密码</a>
|
||||
{% endif %}
|
||||
<a class="btn" href="{% url 'accounts:profile_registration_code' %}">替换注册码</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,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');
|
||||
|
||||
@@ -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",
|
||||
@@ -988,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"}}],
|
||||
}
|
||||
|
||||
@@ -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, '预处理中');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -8,6 +8,7 @@ import base64
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import tempfile
|
||||
import concurrent.futures
|
||||
@@ -40,6 +41,8 @@ except ImportError as e:
|
||||
HAS_PDF_SUPPORT = False
|
||||
PDF_ERROR = str(e)
|
||||
|
||||
MAX_SINGLE_UPLOAD_COUNT = int(getattr(settings, "MAX_SINGLE_UPLOAD_COUNT", 3))
|
||||
|
||||
|
||||
def _filter_results_for_user(request, results):
|
||||
session_user_id = request.session.get("user_id")
|
||||
@@ -614,6 +617,7 @@ def ocr_and_extract_info(image_path: str):
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
base64_image = encode_image(image_path)
|
||||
mime_type = mimetypes.guess_type(image_path)[0] or "image/jpeg"
|
||||
|
||||
# api_key = getattr(settings, "AISTUDIO_API_KEY", "188f57db3766e02ed2c7e18373996d84f4112272")
|
||||
# base_url = getattr(settings, "OPENAI_BASE_URL", "https://aistudio.baidu.com/llm/lmapi/v3")
|
||||
@@ -665,7 +669,7 @@ def ocr_and_extract_info(image_path: str):
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"请识别这张图片中的信息,将你认为重要的数据转换为不包含嵌套的json,不要显示其它信息以便于解析,直接输出json结果即可。使用“数据类型”字段表示这个东西的大致类型,除此之外你可以自行决定使用哪些json字段。“数据类型”的内容有严格规定,请查看{json.dumps(types, ensure_ascii=False)}中是否包含你所需要的类型,确定不包含后你才可以填入你觉得合适的大致分类。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}},
|
||||
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -715,6 +719,7 @@ def upload_page(request):
|
||||
context = {
|
||||
"user_id": user_id_qs or session_user_id,
|
||||
"username": me.get("username"),
|
||||
"max_single_upload_count": MAX_SINGLE_UPLOAD_COUNT,
|
||||
}
|
||||
return render(request, "elastic/upload.html", context)
|
||||
|
||||
@@ -738,6 +743,14 @@ def upload(request):
|
||||
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)
|
||||
@@ -784,17 +797,20 @@ def upload(request):
|
||||
abs_p, fname = img_info
|
||||
try:
|
||||
data = ocr_and_extract_info(abs_p)
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
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 = future.result()
|
||||
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:
|
||||
@@ -814,7 +830,12 @@ def upload(request):
|
||||
merged_group_data[f"{base}_{idx}"] = v
|
||||
|
||||
if not merged_group_data:
|
||||
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user