diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html index 2431e5b..3d2e7ae 100644 --- a/accounts/templates/accounts/profile.html +++ b/accounts/templates/accounts/profile.html @@ -15,13 +15,14 @@ /* 主内容区 */ .main-content { margin-left: 220px; padding: 40px; } - .profile-card { background: #fff; border-radius: 14px; box-shadow: 0 10px 24px rgba(31,35,40,0.08); padding: 30px; margin-bottom: 30px; } + .profile-card { background: #fff; border-radius: 14px; box-shadow: 0 10px 24px rgba(31,35,40,0.08); padding: 30px; margin-bottom: 40px; } + .rc-card { margin-top: 18px; } .profile-header { display: flex; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #eee; padding-bottom: 20px; } .profile-info h2 { margin: 0; color: #1e1e2e; } .profile-info p { margin: 5px 0; color: #666; } .label { font-weight: bold; color: #333; margin-right: 10px; } - .section-title { font-size: 20px; font-weight: bold; margin-bottom: 20px; color: #1e1e2e; } + .section-title { font-size: 20px; font-weight: bold; margin: 34px 0 24px; color: #1e1e2e; } .image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; } .image-item { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05); transition: transform 0.2s; } .image-item:hover { transform: translateY(-5px); } @@ -66,34 +67,13 @@

用户名: {{ profile_user.username }}

用户ID: {{ profile_user.user_id }}

+

注册码: {{ profile_user.registration_code|default:"无" }}

所属: {{ profile_user.key|join:"、"|default:"未填写" }}

可管理级别: {{ profile_user.manage_key|join:"、"|default:"无" }}

权限级别: {{ permission_name }}

- {% if permission_name != "管理员" and not profile_user.manage_key %} -
-
-
-

修改密码

-
-
-
-
- - -
-
- - -
- -
-
-
- {% endif %} -
我的提交
{% if achievements %}
@@ -116,6 +96,50 @@ 去上传第一张图片吧!
{% endif %} + +
+
+
+

替换注册码

+
+
+
+
+ + +
+
+ +
+
输入注册码后自动显示 key 预览
+
+
+ +
+
+
+ + {% if permission_name != "管理员" and not profile_user.manage_key %} +
+
+
+

修改密码

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ {% endif %} @@ -205,6 +229,88 @@ } }); } + + const rcForm = document.getElementById('rcForm'); + if (rcForm) { + let rcPreviewTimer = null; + let rcPreviewSeq = 0; + const rcInput = document.getElementById('newRegCode'); + const rcPreview = document.getElementById('rcPreview'); + + async function refreshRcPreview(code) { + const seq = ++rcPreviewSeq; + if (!code) { + rcPreview.innerHTML = '
输入注册码后自动显示 key 预览
'; + return; + } + rcPreview.innerHTML = '
正在查询...
'; + try { + const resp = await fetch(`/accounts/profile/registration-code/preview/?code=${encodeURIComponent(code)}`, { method: 'GET', credentials: 'same-origin' }); + const data = await resp.json(); + if (seq !== rcPreviewSeq) return; + if (!(resp.ok && data && data.ok)) { + const msg = (data && data.message) ? data.message : '查询失败'; + rcPreview.innerHTML = `
${msg}
`; + return; + } + const keys = ((data.data || {}).keys || []).map(String).filter(Boolean); + const manageKeys = ((data.data || {}).manage_keys || []).map(String).filter(Boolean); + const keysText = keys.length ? keys.join('、') : '无'; + const manageText = manageKeys.length ? manageKeys.join('、') : '无'; + rcPreview.innerHTML = `
key:${keysText}
manage_key:${manageText}
`; + } catch (e) { + if (seq !== rcPreviewSeq) return; + rcPreview.innerHTML = '
查询失败
'; + } + } + + if (rcInput) { + rcInput.addEventListener('input', () => { + const code = (rcInput.value || '').trim(); + if (rcPreviewTimer) window.clearTimeout(rcPreviewTimer); + rcPreviewTimer = window.setTimeout(() => refreshRcPreview(code), 300); + }); + refreshRcPreview((rcInput.value || '').trim()); + } + + rcForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const msg = document.getElementById('rcMsg'); + msg.textContent = ''; + msg.className = 'msg'; + const code = (document.getElementById('newRegCode').value || '').trim(); + if (!code) { + msg.textContent = '请输入注册码'; + msg.className = 'msg error'; + return; + } + if (!confirm('确定要替换注册码吗?该操作会替换你当前的 key。')) return; + try { + const csrftoken = getCookie('csrftoken'); + const resp = await fetch('/accounts/profile/registration-code/replace/', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken || '' + }, + body: JSON.stringify({ code }) + }); + const data = await resp.json(); + if (resp.ok && data.ok) { + msg.textContent = '替换成功'; + msg.className = 'msg success'; + window.location.reload(); + } else { + msg.textContent = (data && data.message) ? data.message : '替换失败'; + msg.className = 'msg error'; + } + } catch (err) { + msg.textContent = '替换失败'; + msg.className = 'msg error'; + } + }); + } diff --git a/accounts/urls.py b/accounts/urls.py index 453a596..4eb62ed 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -13,4 +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"), -] \ No newline at end of file + 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"), + path("registration-code/requests/", views.registration_code_requests_page, name="registration_code_requests_page"), + path("registration-code/requests/list/", views.list_registration_code_requests_view, name="list_registration_code_requests"), + path("registration-code/requests/decide/", views.decide_registration_code_request_view, name="decide_registration_code_request"), +] diff --git a/accounts/views.py b/accounts/views.py index 8546698..b22b0ef 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -15,7 +15,7 @@ from django.conf import settings from .es_client import get_user_by_username from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password, generate_rsa_private_pem_b64, public_spki_b64_from_private_pem_b64, rsa_oaep_decrypt_b64_with_private_pem -from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data, update_user_by_id, get_user_by_id +from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data, update_user_by_id, get_user_by_id, create_registration_code_manage_request, find_pending_registration_code_manage_request, list_registration_code_manage_requests, decide_registration_code_manage_request, get_registration_code_manage_request @require_http_methods(["GET"]) @@ -259,6 +259,7 @@ def register_submit(request): "email": email, "key": (rc.get("keys") if rc else []) or [], "manage_key": (rc.get("manage_keys") if rc else []) or [], + "registration_code": (rc.get("code") if rc else None), }) if not ok: return JsonResponse({"ok": False, "message": "注册失败"}, status=500) @@ -269,6 +270,169 @@ def register_submit(request): pass return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"}) +@require_http_methods(["POST"]) +@csrf_protect +def replace_registration_code_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 HttpResponseBadRequest("Invalid JSON") + code = (payload.get("code") or "").strip() + if not code: + return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400) + rc = get_registration_code(code) + if not rc: + return JsonResponse({"ok": False, "message": "注册码无效"}, status=400) + try: + exp = rc.get("expires_at") + now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc) + if hasattr(exp, 'isoformat'): + exp_dt = exp + else: + exp_dt = __import__("datetime").datetime.fromisoformat(str(exp)) + if exp_dt <= now: + return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400) + except Exception: + pass + keys = list(rc.get("keys") or []) + manage_keys = list(rc.get("manage_keys") or []) + ok = update_user_by_id(session_user_id, key=keys, manage_key=manage_keys, registration_code=code) + if not ok: + return JsonResponse({"ok": False, "message": "替换失败"}, status=500) + return JsonResponse({"ok": True}) + +@require_http_methods(["GET"]) +def registration_code_preview_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"ok": False, "message": "未登录"}, status=401) + code = (request.GET.get("code") or "").strip() + if not code: + return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400) + rc = get_registration_code(code) + if not rc: + return JsonResponse({"ok": False, "message": "注册码无效"}, status=400) + try: + exp = rc.get("expires_at") + now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc) + if hasattr(exp, 'isoformat'): + exp_dt = exp + else: + exp_dt = __import__("datetime").datetime.fromisoformat(str(exp)) + if exp_dt <= now: + return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400) + except Exception: + pass + return JsonResponse( + { + "ok": True, + "data": { + "code": rc.get("code"), + "keys": list(rc.get("keys") or []), + "manage_keys": list(rc.get("manage_keys") or []), + "expires_at": rc.get("expires_at"), + }, + } + ) + +@require_http_methods(["POST"]) +@csrf_protect +def submit_registration_code_request_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"ok": False, "message": "未登录"}, status=401) + try: + perm = int(request.session.get("permission", 1)) + except Exception: + perm = 1 + if perm == 0: + return JsonResponse({"ok": False, "message": "无权限"}, status=403) + me = get_user_by_id(session_user_id) or {} + if (me.get("manage_key") or []) or int(me.get("can_manage_registration_codes") or 0) == 1: + return JsonResponse({"ok": False, "message": "无需申请"}, status=400) + if str(me.get("registration_code") or "").strip(): + return JsonResponse({"ok": False, "message": "已有注册码,无法申请"}, status=400) + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return HttpResponseBadRequest("Invalid JSON") + reason = (payload.get("reason") or "").strip() + if not reason: + return JsonResponse({"ok": False, "message": "请填写申请理由"}, status=400) + pending = find_pending_registration_code_manage_request(session_user_id) + if pending: + return JsonResponse({"ok": True, "message": "已提交申请"}) + rid = create_registration_code_manage_request(session_user_id, me.get("username"), reason) + if not rid: + return JsonResponse({"ok": False, "message": "提交失败"}, status=500) + return JsonResponse({"ok": True}) + +@require_http_methods(["GET"]) +@ensure_csrf_cookie +def registration_code_requests_page(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return redirect("/accounts/login/") + try: + perm = int(request.session.get("permission", 1)) + except Exception: + perm = 1 + if perm != 0: + return redirect("/main/home/") + me = get_user_by_id(session_user_id) or {} + return render(request, "accounts/registration_code_requests.html", {"username": me.get("username")}) + +@require_http_methods(["GET"]) +def list_registration_code_requests_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"ok": False, "message": "未登录"}, status=401) + try: + perm = int(request.session.get("permission", 1)) + except Exception: + perm = 1 + if perm != 0: + return JsonResponse({"ok": False, "message": "无权限"}, status=403) + status = (request.GET.get("status") or "").strip() or None + data = list_registration_code_manage_requests(status=status) + return JsonResponse({"ok": True, "data": data}) + +@require_http_methods(["POST"]) +@csrf_protect +def decide_registration_code_request_view(request): + session_user_id = request.session.get("user_id") + if session_user_id is None: + return JsonResponse({"ok": False, "message": "未登录"}, status=401) + try: + perm = int(request.session.get("permission", 1)) + except Exception: + perm = 1 + if perm != 0: + return JsonResponse({"ok": False, "message": "无权限"}, status=403) + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return HttpResponseBadRequest("Invalid JSON") + request_id = (payload.get("request_id") or "").strip() + action = (payload.get("action") or "").strip().lower() + note = (payload.get("note") or "").strip() + if not request_id or action not in ("approve", "reject"): + return JsonResponse({"ok": False, "message": "参数错误"}, status=400) + req = get_registration_code_manage_request(request_id) + if not req: + return JsonResponse({"ok": False, "message": "申请不存在"}, status=404) + status = "approved" if action == "approve" else "rejected" + ok = decide_registration_code_manage_request(request_id, status=status, reviewed_by=session_user_id, reviewer_note=note) + if not ok: + return JsonResponse({"ok": False, "message": "操作失败"}, status=500) + if status == "approved": + uid = req.get("user_id") + update_user_by_id(uid, can_manage_registration_codes=1, registration_manage_keys=[]) + return JsonResponse({"ok": True}) + @require_http_methods(["POST"]) @csrf_protect def send_email_code(request): @@ -327,4 +491,4 @@ def _send_smtp_email(to_email: str, code: str): pass return True, "" except Exception as e: - return False, str(e) \ No newline at end of file + return False, str(e) diff --git a/elastic/documents.py b/elastic/documents.py index b8c662e..1ba9d5b 100644 --- a/elastic/documents.py +++ b/elastic/documents.py @@ -35,6 +35,9 @@ class UserDocument(Document): user_id = fields.LongField() username = fields.KeywordField() email = fields.KeywordField() + registration_code = fields.KeywordField() + can_manage_registration_codes = fields.IntegerField() + registration_manage_keys = fields.KeywordField(multi=True) password_hash = fields.KeywordField() password_salt = fields.KeywordField() permission = fields.IntegerField() # 还是2种权限,0为管理员,1为用户(区别在于0有全部权限,1在数据管理页面有搜索框,但是索引到的录入信息要根据其用户id查询其key,若其中之一与用户的manage_key字段匹配就显示否则不显示) diff --git a/elastic/es_connect.py b/elastic/es_connect.py index 88e4a5f..d3d5bf0 100644 --- a/elastic/es_connect.py +++ b/elastic/es_connect.py @@ -783,6 +783,9 @@ def write_user_data(user_data): password_salt=pwd_salt_b64, permission=perm_val, email=user_data.get('email'), + registration_code=(user_data.get('registration_code') or None), + can_manage_registration_codes=int(user_data.get('can_manage_registration_codes') or 0), + registration_manage_keys=list(user_data.get('registration_manage_keys') or []), key=list(user_data.get('key') or []), manage_key=list(user_data.get('manage_key') or []), ) @@ -836,6 +839,9 @@ def get_all_users(): "username": hit.username, "permission": int(hit.permission), "email": getattr(hit, 'email', None), + "registration_code": getattr(hit, 'registration_code', None), + "can_manage_registration_codes": int(getattr(hit, 'can_manage_registration_codes', 0) or 0), + "registration_manage_keys": list(getattr(hit, 'registration_manage_keys', []) or []), "key": list(getattr(hit, 'key', []) or []), "manage_key": list(getattr(hit, 'manage_key', []) or []), }) @@ -857,6 +863,9 @@ def get_user_by_id(user_id): "username": hit.username, "permission": int(hit.permission), "email": getattr(hit, 'email', None), + "registration_code": getattr(hit, 'registration_code', None), + "can_manage_registration_codes": int(getattr(hit, 'can_manage_registration_codes', 0) or 0), + "registration_manage_keys": list(getattr(hit, 'registration_manage_keys', []) or []), "key": list(getattr(hit, 'key', []) or []), "manage_key": list(getattr(hit, 'manage_key', []) or []), } @@ -880,7 +889,7 @@ def delete_user_by_id(user_id): print(f"删除用户失败: {str(e)}") return False -def update_user_by_id(user_id, username=None, permission=None, password=None, key=None): +def update_user_by_id(user_id, username=None, permission=None, password=None, key=None, manage_key=None, registration_code=None, can_manage_registration_codes=None, registration_manage_keys=None): try: search = UserDocument.search() search = search.query("term", user_id=int(user_id)) @@ -898,9 +907,118 @@ def update_user_by_id(user_id, username=None, permission=None, password=None, ke doc.password_salt = salt_b64 if key is not None: doc.key = list(key) + if manage_key is not None: + doc.manage_key = list(manage_key) + if registration_code is not None: + doc.registration_code = str(registration_code) if str(registration_code).strip() else None + if can_manage_registration_codes is not None: + try: + doc.can_manage_registration_codes = int(can_manage_registration_codes) + except Exception: + doc.can_manage_registration_codes = 0 + if registration_manage_keys is not None: + doc.registration_manage_keys = list(registration_manage_keys) doc.save() return True return False except Exception as e: print(f"更新用户失败: {str(e)}") return False + +def _rc_request_now_iso(): + return datetime.now(timezone.utc).isoformat() + +def create_registration_code_manage_request(user_id: int, username: str, reason: str): + try: + rid = uuid.uuid4().hex + doc = { + "kind": "registration_code_manage_request", + "request_id": rid, + "user_id": int(user_id), + "username": str(username or ""), + "reason": str(reason or ""), + "status": "pending", + "created_at": _rc_request_now_iso(), + } + es.index(index=GLOBAL_INDEX_NAME, id=rid, body=doc) + return rid + except Exception as e: + print(f"创建注册码管理申请失败: {str(e)}") + return None + +def find_pending_registration_code_manage_request(user_id: int): + try: + body = { + "size": 1, + "query": { + "bool": { + "must": [ + {"term": {"kind": "registration_code_manage_request"}}, + {"term": {"user_id": int(user_id)}}, + {"term": {"status": "pending"}}, + ] + } + }, + "sort": [{"created_at": {"order": "desc"}}], + } + resp = es.search(index=GLOBAL_INDEX_NAME, body=body) + hits = (resp.get("hits") or {}).get("hits") or [] + if not hits: + return None + h = hits[0] + src = h.get("_source") or {} + src["_id"] = h.get("_id") + return src + except Exception as e: + print(f"查询注册码管理申请失败: {str(e)}") + return None + +def get_registration_code_manage_request(request_id: str): + try: + resp = es.get(index=GLOBAL_INDEX_NAME, id=str(request_id)) + src = resp.get("_source") or {} + if (src.get("kind") or "") != "registration_code_manage_request": + return None + src["_id"] = resp.get("_id") + return src + except Exception: + return None + +def list_registration_code_manage_requests(status: str = None, limit: int = 200): + try: + must = [{"term": {"kind": "registration_code_manage_request"}}] + if status: + must.append({"term": {"status": str(status)}}) + body = { + "size": max(1, min(int(limit or 200), 500)), + "query": {"bool": {"must": must}}, + "sort": [{"created_at": {"order": "desc"}}], + } + resp = es.search(index=GLOBAL_INDEX_NAME, body=body) + hits = (resp.get("hits") or {}).get("hits") or [] + out = [] + for h in hits: + src = h.get("_source") or {} + src["_id"] = h.get("_id") + out.append(src) + return out + except Exception as e: + print(f"列出注册码管理申请失败: {str(e)}") + return [] + +def decide_registration_code_manage_request(request_id: str, status: str, reviewed_by: int, reviewer_note: str = None): + try: + sid = str(status or "").strip().lower() + if sid not in ("approved", "rejected"): + return False + doc = { + "status": sid, + "reviewed_at": _rc_request_now_iso(), + "reviewed_by": int(reviewed_by), + "reviewer_note": str(reviewer_note or ""), + } + es.update(index=GLOBAL_INDEX_NAME, id=str(request_id), body={"doc": doc}) + return True + except Exception as e: + print(f"审批注册码管理申请失败: {str(e)}") + return False diff --git a/elastic/templates/elastic/registration_codes.html b/elastic/templates/elastic/registration_codes.html index f68fe7a..108c32e 100644 --- a/elastic/templates/elastic/registration_codes.html +++ b/elastic/templates/elastic/registration_codes.html @@ -38,6 +38,16 @@ {% csrf_token %} @@ -155,16 +229,20 @@

管理注册码

+ {% if is_admin or has_manage_key or can_manage_registration_codes %}
+ {% if is_admin or has_manage_key %} + {% endif %}
+ {% endif %}
@@ -384,4 +462,4 @@ loadRecent(); - \ No newline at end of file + diff --git a/elastic/templates/elastic/users.html b/elastic/templates/elastic/users.html index ebf86f4..b4eadd2 100644 --- a/elastic/templates/elastic/users.html +++ b/elastic/templates/elastic/users.html @@ -163,7 +163,7 @@ .modal-content { background-color: white; - margin: 10% auto; + margin: 6% auto; padding: 20px; border-radius: 8px; width: 80%; @@ -207,6 +207,71 @@ margin-top: 5px; text-align: center; } + + .keys-box { + max-height: 140px; + overflow: auto; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 8px 10px; + background: #fff; + } + + .key-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 14px; + color: #111827; + user-select: none; + } + + .key-item input[type="checkbox"] { + width: auto; + padding: 0; + margin: 0; + } + + .key-edit-row { + display: flex; + gap: 10px; + align-items: center; + } + + .selected-keys { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + + .key-tag { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: #eef2ff; + color: #1f2937; + border: 1px solid #c7d2fe; + font-size: 13px; + } + + .key-tag button { + border: none; + background: transparent; + cursor: pointer; + color: #4b5563; + font-size: 14px; + line-height: 1; + } + + .key-tag.locked { + background: #f3f4f6; + border: 1px solid #e5e7eb; + color: #374151; + } @@ -306,7 +371,7 @@
-
+
+
+ +
+ + + +
+
+ +
+
+ +
+ + + +
+
+
@@ -340,6 +427,14 @@