Compare commits
37 Commits
42bacbbc81
...
Django
| Author | SHA1 | Date | |
|---|---|---|---|
| df18bdfa7e | |||
| 281ade6ac9 | |||
| 835426b133 | |||
| d001fec21e | |||
| 253de3639c | |||
| a0507b8054 | |||
| 9f803880fa | |||
| 71fe964476 | |||
| 0f5c8c08ff | |||
| e032253327 | |||
| 3f108e2138 | |||
| 2d913e397f | |||
| 74bc8aa498 | |||
| 5d747faee1 | |||
| 7bd8eeca77 | |||
| 782b2dd82e | |||
| f9c0abb3a0 | |||
| c5300591e6 | |||
| f96629566f | |||
| 8d581ac638 | |||
| acc80074ea | |||
| 62d28be032 | |||
| 5b956e1365 | |||
| 7485ba16e6 | |||
| ac580599b3 | |||
| faae7032f1 | |||
| 615d9433fe | |||
| d755f4710f | |||
| 3e598fe0a1 | |||
| 5a9d98282a | |||
| 8f9fc9c914 | |||
| b5d76be37b | |||
| 100531ddd1 | |||
| 68bc4b54f5 | |||
| 5153017a80 | |||
| 2c58c1be29 | |||
| 8c14544ca1 |
101
.gitea/workflows/ci.yml
Normal file
101
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Django
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 版本号(如 0.2.2),为空则自动生成
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker-ci:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
|
||||
DJANGO_DEBUG: "False"
|
||||
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
|
||||
IMAGE_NAME: achievement_inputing_ci
|
||||
ARTIFACT_DIR: artifacts
|
||||
SERVER_DEST_DIR: /srv/ci
|
||||
DOWNLOAD_BASE: http://139.224.69.213:8080
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
RELEASE_TOKEN: ${{ secrets.token }}
|
||||
steps:
|
||||
- name: Ensure source present
|
||||
env:
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ -f "$GITHUB_WORKSPACE/Dockerfile" ]; then exit 0; fi
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
git init .
|
||||
if [ -z "$TOKEN" ]; then
|
||||
git fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||
else
|
||||
git -c http.extraHeader="Authorization: Bearer $TOKEN" fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||
fi
|
||||
git checkout FETCH_HEAD
|
||||
- name: Derive version
|
||||
run: |
|
||||
msg="${{ github.event.head_commit.message }}"
|
||||
ver_input="${{ github.event.inputs.version }}"
|
||||
ver=""
|
||||
if [ -n "$ver_input" ]; then
|
||||
ver="$ver_input"
|
||||
else
|
||||
ver=$(echo "$msg" | grep -Eo "\[[0-9]+(\.[0-9]+){1,}\]" | head -n1 | tr -d '[]')
|
||||
fi
|
||||
if [ -z "$ver" ]; then
|
||||
ver="$(date +%Y%m%d%H%M)-${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "VERSION=$ver" >> $GITHUB_ENV
|
||||
- name: Build application image
|
||||
run: |
|
||||
docker build -t "$IMAGE_NAME:$VERSION" -f "$GITHUB_WORKSPACE/Dockerfile" "$GITHUB_WORKSPACE"
|
||||
- name: Output image info
|
||||
run: |
|
||||
docker image inspect "$IMAGE_NAME:$VERSION" --format '{{.Id}} {{.Size}}'
|
||||
- name: Export image tar
|
||||
run: |
|
||||
ART="achievement_inputing_ci_${VERSION}.tar"
|
||||
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
|
||||
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
|
||||
- name: Publish artifact locally
|
||||
run: |
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
|
||||
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
|
||||
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||
- name: Publish to /srv/ci
|
||||
run: |
|
||||
set -e
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
cat "$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART" | docker run --rm -i -v "$SERVER_DEST_DIR:/srvci" "$IMAGE_NAME:$VERSION" sh -c "cat > /srvci/$ART && ls -l /srvci"
|
||||
echo "published: $SERVER_DEST_DIR/$ART"
|
||||
- name: Create release with download link
|
||||
if: env.RELEASE_TOKEN != ''
|
||||
run: |
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
TAG="$VERSION"
|
||||
NAME="$VERSION"
|
||||
BASE="${DOWNLOAD_BASE%/}"
|
||||
BASE="${BASE%/ci}"
|
||||
DL="$BASE/$ART"
|
||||
echo "download: $DL"
|
||||
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
|
||||
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"
|
||||
@@ -166,3 +166,4 @@ ELASTICSEARCH_INDEX_NAMES = {
|
||||
# AI Studio/OpenAI client settings
|
||||
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
|
||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
|
||||
OPENAI_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')
|
||||
|
||||
@@ -91,3 +91,25 @@ def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool:
|
||||
return hmac.compare_digest(actual, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_rsa_private_pem_b64() -> str:
|
||||
if rsa is None or serialization is None:
|
||||
raise RuntimeError("cryptography library is required for RSA operations")
|
||||
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
pem = priv.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
|
||||
return base64.b64encode(pem).decode('ascii')
|
||||
|
||||
def public_spki_b64_from_private_pem_b64(private_pem_b64: str) -> str:
|
||||
if serialization is None:
|
||||
raise RuntimeError("cryptography library is required for RSA operations")
|
||||
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
|
||||
pub = priv.public_key()
|
||||
spki = pub.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return base64.b64encode(spki).decode('ascii')
|
||||
|
||||
def rsa_oaep_decrypt_b64_with_private_pem(private_pem_b64: str, ciphertext_b64: str) -> bytes:
|
||||
if serialization is None or padding is None or hashes is None:
|
||||
raise RuntimeError("cryptography library is required for RSA operations")
|
||||
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
return priv.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))
|
||||
@@ -77,8 +77,20 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
const setKeyResp = await fetch('/accounts/session-key/', {
|
||||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 })
|
||||
});
|
||||
const setKeyJson = await setKeyResp.json();
|
||||
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败');
|
||||
const setKeySnapshot = await (async () => {
|
||||
const clone = setKeyResp.clone();
|
||||
const txt = await clone.text();
|
||||
let parsed = null;
|
||||
try { parsed = await setKeyResp.json(); } catch (_) {}
|
||||
return { txt, parsed };
|
||||
})();
|
||||
if (!setKeySnapshot.parsed) {
|
||||
const msg = (setKeySnapshot.txt || '').trim();
|
||||
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
|
||||
throw new Error(mapped);
|
||||
}
|
||||
const setKeyJson = setKeySnapshot.parsed;
|
||||
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
|
||||
|
||||
const aesKey = await importAesKey(aesKeyRaw);
|
||||
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
|
||||
@@ -92,7 +104,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
const submitResp = await fetch('/accounts/login/secure-submit/', {
|
||||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
|
||||
});
|
||||
const submitJson = await submitResp.json();
|
||||
const submitSnapshot = await (async () => {
|
||||
const clone = submitResp.clone();
|
||||
const txt = await clone.text();
|
||||
let parsed = null;
|
||||
try { parsed = await submitResp.json(); } catch (_) {}
|
||||
return { txt, parsed };
|
||||
})();
|
||||
if (!submitSnapshot.parsed) {
|
||||
const msg = (submitSnapshot.txt || '').trim();
|
||||
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
|
||||
throw new Error(mapped);
|
||||
}
|
||||
const submitJson = submitSnapshot.parsed;
|
||||
if (!submitResp.ok || !submitJson.ok) {
|
||||
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
|
||||
throw new Error(submitJson.message || '登录失败');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.container { max-width: 360px; margin: 12vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 16px; }
|
||||
label { display: block; margin: 12px 0 6px; color: #333; }
|
||||
input { width: 100%; padding: 10px 12px; border: 1px solid #dcdde1; border-radius: 6px; }
|
||||
input { width: 100%; padding: 10px 0px; border: 1px solid #dcdde1; border-radius: 6px; }
|
||||
button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
button:disabled { background: #9bbcf0; cursor: not-allowed; }
|
||||
.error { color: #d93025; margin-top: 10px; min-height: 20px; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.container { max-width: 400px; margin: 10vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 16px; }
|
||||
label { display:block; margin: 12px 0 6px; color:#333; }
|
||||
input { width:100%; padding:10px 12px; border:1px solid #dcdde1; border-radius:6px; }
|
||||
input { width:100%; padding:10px 0px; border:1px solid #dcdde1; border-radius:6px; }
|
||||
button { width:100%; margin-top:16px; padding:10px 12px; background:#2d8cf0; color:#fff; border:none; border-radius:6px; cursor:pointer; }
|
||||
button:disabled { background:#9bbcf0; cursor:not-allowed; }
|
||||
.error { color:#d93025; margin-top:10px; min-height:20px; }
|
||||
@@ -20,10 +20,14 @@
|
||||
<h1>注册新用户</h1>
|
||||
<form id="regForm">
|
||||
{% csrf_token %}
|
||||
<label for="code">注册码</label>
|
||||
<input id="code" name="code" type="text" required />
|
||||
<label for="code">注册码(选填)</label>
|
||||
<input id="code" name="code" type="text" />
|
||||
<label for="email">邮箱</label>
|
||||
<input id="email" name="email" type="email" required />
|
||||
<button id="sendCodeBtn" type="button">发送验证码</button>
|
||||
<div id="sendMsg" class="hint"></div>
|
||||
<label for="email_code">邮箱验证码</label>
|
||||
<input id="email_code" name="email_code" type="text" required />
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" required />
|
||||
<label for="password">密码</label>
|
||||
@@ -33,7 +37,7 @@
|
||||
<button id="regBtn" type="submit">注册</button>
|
||||
<div id="error" class="error"></div>
|
||||
</form>
|
||||
<div class="hint">仅允许持有管理员提供注册码的学生注册</div>
|
||||
<div class="hint">有注册码请填写,否则可留空</div>
|
||||
</div>
|
||||
<script>
|
||||
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
|
||||
@@ -43,20 +47,36 @@
|
||||
const code=(document.getElementById('code').value||'').trim();
|
||||
const email=(document.getElementById('email').value||'').trim();
|
||||
const username=(document.getElementById('username').value||'').trim();
|
||||
const email_code=(document.getElementById('email_code').value||'').trim();
|
||||
const password=document.getElementById('password').value||'';
|
||||
const confirm=document.getElementById('confirm').value||'';
|
||||
if(!code||!email||!username||!password){err.textContent='请填写所有字段';return;}
|
||||
if(!email||!email_code||!username||!password){err.textContent='请填写所有必填字段';return;}
|
||||
if(password!==confirm){err.textContent='两次密码不一致';return;}
|
||||
const btn=document.getElementById('regBtn'); btn.disabled=true;
|
||||
try{
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,username,password})});
|
||||
const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,email_code,username,password})});
|
||||
const data=await resp.json();
|
||||
if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');}
|
||||
window.location.href=data.redirect_url;
|
||||
}catch(e){err.textContent=e.message||'发生错误';}
|
||||
finally{btn.disabled=false;}
|
||||
});
|
||||
document.getElementById('sendCodeBtn').addEventListener('click',async()=>{
|
||||
const email=(document.getElementById('email').value||'').trim();
|
||||
const msg=document.getElementById('sendMsg');
|
||||
msg.textContent='';
|
||||
if(!email){msg.textContent='请输入邮箱';return;}
|
||||
const btn=document.getElementById('sendCodeBtn'); btn.disabled=true;
|
||||
try{
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const resp=await fetch('/accounts/email/send-code/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({email})});
|
||||
const data=await resp.json();
|
||||
if(!resp.ok||!data.ok){throw new Error(data.message||'发送失败');}
|
||||
msg.textContent='验证码已发送,请查收邮件';
|
||||
}catch(e){msg.textContent=e.message||'发送失败';}
|
||||
finally{btn.disabled=false;}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,4 +11,5 @@ urlpatterns = [
|
||||
path("logout/", views.logout, name="logout"),
|
||||
path("register/", views.register_page, name="register"),
|
||||
path("register/submit/", views.register_submit, name="register_submit"),
|
||||
path("email/send-code/", views.send_email_code, name="send_email_code"),
|
||||
]
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
import io
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import smtplib
|
||||
|
||||
from django.http import JsonResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import render, redirect
|
||||
@@ -12,7 +14,7 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -25,7 +27,11 @@ def login_page(request):
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def pubkey(request):
|
||||
pk_b64 = get_public_key_spki_b64()
|
||||
pem_b64 = request.session.get("rsa_private_pem_b64")
|
||||
if not pem_b64:
|
||||
pem_b64 = generate_rsa_private_pem_b64()
|
||||
request.session["rsa_private_pem_b64"] = pem_b64
|
||||
pk_b64 = public_spki_b64_from_private_pem_b64(pem_b64)
|
||||
return JsonResponse({"public_key_spki": pk_b64})
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@@ -56,7 +62,10 @@ def set_session_key(request):
|
||||
if not enc_key_b64:
|
||||
return HttpResponseBadRequest("Missing fields")
|
||||
try:
|
||||
key_bytes = rsa_oaep_decrypt_b64(enc_key_b64)
|
||||
pem_b64 = request.session.get("rsa_private_pem_b64")
|
||||
if not pem_b64:
|
||||
return HttpResponseBadRequest("Decrypt error")
|
||||
key_bytes = rsa_oaep_decrypt_b64_with_private_pem(pem_b64, enc_key_b64)
|
||||
except Exception:
|
||||
return HttpResponseBadRequest("Decrypt error")
|
||||
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
|
||||
@@ -110,6 +119,8 @@ def secure_login_submit(request):
|
||||
request.session["permission"] = 1
|
||||
if "session_enc_key_b64" in request.session:
|
||||
del request.session["session_enc_key_b64"]
|
||||
if "rsa_private_pem_b64" in request.session:
|
||||
del request.session["rsa_private_pem_b64"]
|
||||
if "login_failed_once" in request.session:
|
||||
del request.session["login_failed_once"]
|
||||
if "captcha_code" in request.session:
|
||||
@@ -174,10 +185,24 @@ def register_submit(request):
|
||||
return HttpResponseBadRequest("Invalid JSON")
|
||||
code = (payload.get("code") or "").strip()
|
||||
email = (payload.get("email") or "").strip()
|
||||
email_code = (payload.get("email_code") or "").strip()
|
||||
username = (payload.get("username") or "").strip()
|
||||
password = (payload.get("password") or "")
|
||||
if not code or not email or not username or not password:
|
||||
if not email or not email_code or not username or not password:
|
||||
return HttpResponseBadRequest("Missing fields")
|
||||
v = request.session.get("email_verify") or {}
|
||||
if (v.get("email") or "") != email:
|
||||
return JsonResponse({"ok": False, "message": "请先验证邮箱"}, status=400)
|
||||
try:
|
||||
exp_ts = int(v.get("expires_at") or 0)
|
||||
except Exception:
|
||||
exp_ts = 0
|
||||
if exp_ts < int(time.time()):
|
||||
return JsonResponse({"ok": False, "message": "验证码已过期"}, status=400)
|
||||
if (v.get("code") or "") != email_code:
|
||||
return JsonResponse({"ok": False, "message": "邮箱验证码错误"}, status=400)
|
||||
rc = None
|
||||
if code:
|
||||
rc = get_registration_code(code)
|
||||
if not rc:
|
||||
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
|
||||
@@ -203,9 +228,74 @@ def register_submit(request):
|
||||
"password": password,
|
||||
"permission": 1,
|
||||
"email": email,
|
||||
"key": rc.get("keys") or [],
|
||||
"manage_key": rc.get("manage_keys") or [],
|
||||
"key": (rc.get("keys") if rc else []) or [],
|
||||
"manage_key": (rc.get("manage_keys") if rc else []) or [],
|
||||
})
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
|
||||
try:
|
||||
if "email_verify" in request.session:
|
||||
del request.session["email_verify"]
|
||||
except Exception:
|
||||
pass
|
||||
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def send_email_code(request):
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("Invalid JSON")
|
||||
email = (payload.get("email") or "").strip()
|
||||
if not email:
|
||||
return HttpResponseBadRequest("Missing email")
|
||||
if "@" not in email:
|
||||
return JsonResponse({"ok": False, "message": "邮箱格式不正确"}, status=400)
|
||||
verify_code = "".join(random.choice(string.digits) for _ in range(6))
|
||||
ttl = int(os.environ.get("SMTP_CODE_TTL", "600") or 600)
|
||||
request.session["email_verify"] = {"email": email, "code": verify_code, "expires_at": int(time.time()) + max(60, ttl)}
|
||||
ok, msg = _send_smtp_email(email, verify_code)
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": msg or "验证码发送失败"}, status=500)
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
def _send_smtp_email(to_email: str, code: str):
|
||||
host = os.environ.get("SMTP_HOST", "")
|
||||
port_raw = os.environ.get("SMTP_PORT", "")
|
||||
try:
|
||||
port = int(port_raw) if port_raw else 0
|
||||
except Exception:
|
||||
port = 0
|
||||
user = os.environ.get("SMTP_USERNAME") or os.environ.get("SMTP_USER") or ""
|
||||
password = os.environ.get("SMTP_PASSWORD", "")
|
||||
use_tls = str(os.environ.get("SMTP_USE_TLS", "")).lower() in ("1", "true", "yes")
|
||||
use_ssl = str(os.environ.get("SMTP_USE_SSL", "")).lower() in ("1", "true", "yes")
|
||||
sender = os.environ.get("SMTP_FROM_EMAIL") or os.environ.get("SMTP_FROM") or user or ""
|
||||
subject = os.environ.get("SMTP_SUBJECT") or "邮箱验证码"
|
||||
if not host or not port or not sender:
|
||||
return False, "缺少SMTP配置"
|
||||
body = f"您的验证码是:{code},10分钟内有效。"
|
||||
msg = f"From: {sender}\r\nTo: {to_email}\r\nSubject: {subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{body}"
|
||||
try:
|
||||
if use_ssl:
|
||||
server = smtplib.SMTP_SSL(host, port)
|
||||
else:
|
||||
server = smtplib.SMTP(host, port)
|
||||
server.ehlo()
|
||||
if use_tls and not use_ssl:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if user and password:
|
||||
server.login(user, password)
|
||||
server.sendmail(sender, [to_email], msg.encode("utf-8"))
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
try:
|
||||
server.close()
|
||||
except Exception:
|
||||
pass
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
@@ -60,6 +60,7 @@ class RegistrationCodeDocument(Document):
|
||||
code = fields.KeywordField() #具体值
|
||||
keys = fields.KeywordField(multi=True) #对应的key
|
||||
manage_keys = fields.KeywordField(multi=True) #对应的manage_key
|
||||
created_at = fields.DateField() #创建时间
|
||||
expires_at = fields.DateField() #过期时间
|
||||
created_by = fields.LongField() #创建者id
|
||||
class Django:
|
||||
|
||||
@@ -197,6 +197,55 @@ def get_registration_code(code: str):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def list_registration_codes():
|
||||
try:
|
||||
search = RegistrationCodeDocument.search()
|
||||
body = {
|
||||
"sort": [{"created_at": {"order": "desc"}}],
|
||||
"query": {"exists": {"field": "code"}}
|
||||
}
|
||||
search = search.update_from_dict(body)
|
||||
resp = search.execute()
|
||||
out = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for hit in resp:
|
||||
try:
|
||||
if not getattr(hit, 'code', None):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
exp = getattr(hit, 'expires_at', None)
|
||||
try:
|
||||
if hasattr(exp, 'isoformat'):
|
||||
exp_dt = exp
|
||||
else:
|
||||
exp_dt = datetime.fromisoformat(str(exp))
|
||||
except Exception:
|
||||
exp_dt = None
|
||||
active = bool(exp_dt and exp_dt > now)
|
||||
out.append({
|
||||
"code": getattr(hit, 'code', ''),
|
||||
"keys": list(getattr(hit, 'keys', []) or []),
|
||||
"manage_keys": list(getattr(hit, 'manage_keys', []) or []),
|
||||
"created_at": getattr(hit, 'created_at', None),
|
||||
"expires_at": getattr(hit, 'expires_at', None),
|
||||
"created_by": getattr(hit, 'created_by', None),
|
||||
"active": active,
|
||||
})
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def revoke_registration_code(code: str):
|
||||
try:
|
||||
doc = RegistrationCodeDocument.get(id=str(code))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc.expires_at = now
|
||||
doc.save()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_doc_id(data):
|
||||
"""
|
||||
根据数据内容生成唯一ID(用于去重)
|
||||
@@ -681,7 +730,10 @@ def get_all_users():
|
||||
users.append({
|
||||
"user_id": hit.user_id,
|
||||
"username": hit.username,
|
||||
"permission": int(hit.permission)
|
||||
"permission": int(hit.permission),
|
||||
"email": getattr(hit, 'email', None),
|
||||
"key": list(getattr(hit, 'key', []) or []),
|
||||
"manage_key": list(getattr(hit, 'manage_key', []) or []),
|
||||
})
|
||||
|
||||
return users
|
||||
@@ -700,6 +752,9 @@ def get_user_by_id(user_id):
|
||||
"user_id": hit.user_id,
|
||||
"username": hit.username,
|
||||
"permission": int(hit.permission),
|
||||
"email": getattr(hit, 'email', None),
|
||||
"key": list(getattr(hit, 'key', []) or []),
|
||||
"manage_key": list(getattr(hit, 'manage_key', []) or []),
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
INDEX_NAME = "wordsearch2666661"
|
||||
USER_NAME = "users11111666789"
|
||||
INDEX_NAME = "wordsearch21"
|
||||
USER_NAME = "users16"
|
||||
ACHIEVEMENT_INDEX_NAME = INDEX_NAME
|
||||
USER_INDEX_NAME = USER_NAME
|
||||
GLOBAL_INDEX_NAME = "global11111111"
|
||||
GLOBAL_INDEX_NAME = "global11121"
|
||||
|
||||
@@ -4,285 +4,57 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>数据管理</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 100vh;
|
||||
background: #1e1e2e;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-links {
|
||||
width: 100%;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.sidebar a,
|
||||
.sidebar button {
|
||||
display: block;
|
||||
color: #8be9fd;
|
||||
text-decoration: none;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: calc(100% - 40px);
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar a:hover,
|
||||
.sidebar button:hover {
|
||||
color: #ff79c6;
|
||||
background-color: rgba(139, 233, 253, 0.2);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 原有样式保持不变 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.06);
|
||||
padding: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 12px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
img {
|
||||
max-width: 120px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
cursor: pointer; /* 添加指针样式 */
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 2px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
.muted {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(0,0,0,0.4);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal .dialog {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
#kvForm {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 搜索区域样式 */
|
||||
.search-container {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #e8f4ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-result.empty {
|
||||
background: #fff8e8;
|
||||
}
|
||||
.search-result.error {
|
||||
background: #ffe8e8;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #1677ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.search-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片放大模态框 */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.9);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.image-modal-content {
|
||||
margin: auto;
|
||||
display: block;
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 35px;
|
||||
color: #f1f1f1;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
transition: 0.3s;
|
||||
cursor: pointer;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.image-modal-close:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
body{margin:0;font-family:sans-serif;background:#fafafa}
|
||||
.sidebar{position:fixed;top:0;left:0;width:180px;height:100vh;background:#1e1e2e;color:white;padding:20px;box-shadow:2px 0 5px rgba(0,0,0,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
|
||||
.user-id{text-align:center;margin-bottom:0}
|
||||
.sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
|
||||
.navigation-links{width:100%;margin-top:60px}
|
||||
.sidebar a,.sidebar button{display:block;color:#8be9fd;text-decoration:none;margin:10px 0;font-size:16px;padding:15px;border-radius:4px;background:transparent;border:none;cursor:pointer;width:calc(100% - 40px);text-align:left;transition:.2s}
|
||||
.sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
|
||||
.main-content{margin-left:200px;padding:20px;color:#333}
|
||||
.container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:20px}
|
||||
th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
|
||||
th{background:#f8f9fa;font-weight:600}
|
||||
img{max-width:120px;border:1px solid #eee;border-radius:6px;cursor:pointer}
|
||||
.btn{padding:6px 10px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin:2px}
|
||||
.btn-primary{background:#1677ff;color:#fff}
|
||||
.btn-danger{background:#ff4d4f;color:#fff}
|
||||
.btn-secondary{background:#f0f0f0;color:#333}
|
||||
.muted{color:#666;font-size:12px}
|
||||
.modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
|
||||
.modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
|
||||
textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
|
||||
#kvForm{border:1px solid #eee;border-radius:6px;padding:8px;max-height:300px;overflow:auto}
|
||||
.search-container{background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px}
|
||||
.search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
|
||||
.search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
|
||||
.search-result.empty{background:#fff8e8}
|
||||
.search-result.error{background:#ffe8e8}
|
||||
.loading{display:inline-block;width:20px;height:20px;border:3px solid #f3f3f3;border-top:3px solid #1677ff;border-radius:50%;animation:spin 1s linear infinite}
|
||||
@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
|
||||
@media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
|
||||
.image-modal{display:none;position:fixed;z-index:2000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.9);overflow:hidden}
|
||||
.image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
|
||||
.image-modal-content.dragging{cursor:grabbing}
|
||||
.image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
|
||||
.image-modal-close:hover{color:#bbb}
|
||||
.zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
|
||||
.zoom-btn{background:rgba(255,255,255,.7);border:none;border-radius:50%;width:40px;height:40px;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.3);transition:background .3s}
|
||||
.zoom-btn:hover{background:rgba(255,255,255,.9)}
|
||||
.zoom-info{position:absolute;top:15px;left:15px;color:#f1f1f1;font-size:14px;z-index:2001;background:rgba(0,0,0,.5);padding:5px 10px;border-radius:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 左侧固定栏目 -->
|
||||
<div class="sidebar">
|
||||
<div class="user-id">
|
||||
<h3>用户ID:{{ user_id }}</h3>
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
</div>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}">主页</a>
|
||||
<button id="logoutBtn">退出登录</button>
|
||||
<a href="{% url 'main:home' %}">返回主页</a>
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
@@ -351,7 +123,13 @@
|
||||
<!-- 图片放大模态框 -->
|
||||
<div id="imageModal" class="image-modal">
|
||||
<span class="image-modal-close">×</span>
|
||||
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
|
||||
<img class="image-modal-content" id="expandedImage">
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" id="zoomOutBtn">-</button>
|
||||
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
|
||||
<button class="zoom-btn" id="zoomInBtn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -379,12 +157,29 @@ const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||
const imageModal = document.getElementById('imageModal');
|
||||
const expandedImage = document.getElementById('expandedImage');
|
||||
const imageModalClose = document.querySelector('.image-modal-close');
|
||||
const zoomInBtn = document.getElementById('zoomInBtn');
|
||||
const zoomOutBtn = document.getElementById('zoomOutBtn');
|
||||
const resetZoomBtn = document.getElementById('resetZoomBtn');
|
||||
const zoomValue = document.getElementById('zoomValue');
|
||||
|
||||
// 全局变量
|
||||
let currentId = '';
|
||||
let currentWriter = '';
|
||||
let currentImage = '';
|
||||
let allDataCache = []; // 缓存所有数据,避免重复请求
|
||||
let currentSearchQuery = ''; // 记录当前搜索查询
|
||||
let isFuzzySearch = false; // 记录当前是否为模糊搜索
|
||||
let isDeleting = false; // 标记是否正在删除
|
||||
|
||||
// 图片缩放相关变量
|
||||
let currentScale = 1;
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let isDragging = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let imgStartX = 0;
|
||||
let imgStartY = 0;
|
||||
|
||||
// 搜索功能
|
||||
async function performSearch(type) {
|
||||
@@ -394,6 +189,8 @@ async function performSearch(type) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSearchQuery = query;
|
||||
isFuzzySearch = type === 'fuzzy';
|
||||
showSearchLoading();
|
||||
|
||||
try {
|
||||
@@ -452,6 +249,7 @@ function showSearchMessage(message, type = '') {
|
||||
|
||||
// 加载所有数据
|
||||
async function loadAllData() {
|
||||
currentSearchQuery = '';
|
||||
showSearchLoading();
|
||||
|
||||
try {
|
||||
@@ -490,6 +288,7 @@ function displayAllData(data) {
|
||||
function clearSearch() {
|
||||
searchQueryInput.value = '';
|
||||
searchResultDiv.style.display = 'none';
|
||||
currentSearchQuery = '';
|
||||
|
||||
// 如果有缓存数据,显示全部
|
||||
if (allDataCache.length > 0) {
|
||||
@@ -696,15 +495,9 @@ async function saveEdit(){
|
||||
alert('保存成功');
|
||||
closeModal();
|
||||
// 重新加载数据以显示更新
|
||||
if (searchResultDiv.style.display !== 'none') {
|
||||
if (currentSearchQuery) {
|
||||
// 如果当前显示的是搜索结果,重新执行搜索
|
||||
const query = searchQueryInput.value.trim();
|
||||
if (query) {
|
||||
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
|
||||
performSearch(isFuzzy ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
@@ -714,8 +507,20 @@ async function saveEdit(){
|
||||
}
|
||||
|
||||
async function doDelete(id){
|
||||
if (isDeleting) {
|
||||
alert('正在处理删除操作,请稍候...');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!confirm('确认删除该记录?此操作不可撤销')) return;
|
||||
|
||||
isDeleting = true;
|
||||
const deleteButton = document.querySelector(`button[onclick="doDelete('${id}')"]`);
|
||||
if (deleteButton) {
|
||||
deleteButton.disabled = true;
|
||||
deleteButton.textContent = '删除中...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/elastic/data/${id}/delete/`, {
|
||||
method:'DELETE',
|
||||
@@ -727,20 +532,32 @@ async function doDelete(id){
|
||||
if(data.status!=='success') throw new Error(data.message || '删除失败');
|
||||
|
||||
alert('删除成功');
|
||||
// 重新加载数据
|
||||
if (searchResultDiv.style.display !== 'none') {
|
||||
const query = searchQueryInput.value.trim();
|
||||
if (query) {
|
||||
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
|
||||
performSearch(isFuzzy ? 'fuzzy' : 'exact');
|
||||
// 清空缓存,确保下次加载获取最新数据
|
||||
allDataCache = [];
|
||||
|
||||
// 根据当前显示状态重新加载数据
|
||||
if (currentSearchQuery) {
|
||||
// 如果当前显示的是搜索结果,重新执行搜索
|
||||
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
// 修复:重新加载所有数据时,强制刷新缓存
|
||||
const response = await fetch('/elastic/all-data/');
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
allDataCache = data.data || [];
|
||||
displayAllData(allDataCache);
|
||||
} else {
|
||||
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
|
||||
}
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message||'删除失败');
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
if (deleteButton) {
|
||||
deleteButton.disabled = false;
|
||||
deleteButton.textContent = '删除';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,6 +593,30 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 图片缩放功能
|
||||
function updateZoom() {
|
||||
expandedImage.style.transform = `translate(-50%, -50%) scale(${currentScale}) translate(${currentX}px, ${currentY}px)`;
|
||||
zoomValue.textContent = `${Math.round(currentScale * 100)}%`;
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
currentScale = 1;
|
||||
currentX = 0;
|
||||
currentY = 0;
|
||||
updateZoom();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
currentScale *= 1.2;
|
||||
updateZoom();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
currentScale /= 1.2;
|
||||
if (currentScale < 0.1) currentScale = 0.1; // 最小缩放限制
|
||||
updateZoom();
|
||||
}
|
||||
|
||||
// 图片放大功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 为所有图片添加点击事件监听器
|
||||
@@ -784,6 +625,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const imgSrc = e.target.src;
|
||||
expandedImage.src = imgSrc;
|
||||
imageModal.style.display = 'block';
|
||||
|
||||
// 重置缩放状态
|
||||
resetZoom();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,6 +642,91 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
imageModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放按钮事件
|
||||
zoomInBtn.addEventListener('click', zoomIn);
|
||||
zoomOutBtn.addEventListener('click', zoomOut);
|
||||
resetZoomBtn.addEventListener('click', resetZoom);
|
||||
|
||||
// 鼠标滚轮缩放
|
||||
expandedImage.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0) {
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽功能
|
||||
expandedImage.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
imgStartX = currentX;
|
||||
imgStartY = currentY;
|
||||
expandedImage.classList.add('dragging');
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStartX;
|
||||
const deltaY = e.clientY - dragStartY;
|
||||
currentX = imgStartX + deltaX / currentScale;
|
||||
currentY = imgStartY + deltaY / currentScale;
|
||||
updateZoom();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
expandedImage.classList.remove('dragging');
|
||||
});
|
||||
|
||||
// 触摸事件支持(移动端)
|
||||
expandedImage.addEventListener('touchstart', function(e) {
|
||||
if (e.touches.length === 1) {
|
||||
isDragging = true;
|
||||
dragStartX = e.touches[0].clientX;
|
||||
dragStartY = e.touches[0].clientY;
|
||||
imgStartX = currentX;
|
||||
imgStartY = currentY;
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指缩放
|
||||
initialDistance = getDistance(e.touches[0], e.touches[1]);
|
||||
initialScale = currentScale;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
e.preventDefault();
|
||||
if (isDragging && e.touches.length === 1) {
|
||||
const deltaX = e.touches[0].clientX - dragStartX;
|
||||
const deltaY = e.touches[0].clientY - dragStartY;
|
||||
currentX = imgStartX + deltaX / currentScale;
|
||||
currentY = imgStartY + deltaY / currentScale;
|
||||
updateZoom();
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指缩放
|
||||
const currentDistance = getDistance(e.touches[0], e.touches[1]);
|
||||
const scale = (currentDistance / initialDistance) * initialScale;
|
||||
currentScale = Math.max(0.1, Math.min(scale, 10)); // 限制缩放范围
|
||||
updateZoom();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', function() {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
// 计算两点间距离
|
||||
function getDistance(touch1, touch2) {
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<style>
|
||||
body { margin:0; font-family: system-ui,-apple-system, Segoe UI, Roboto, sans-serif; background:#fafafa; }
|
||||
.sidebar { position:fixed; top:0; left:0; width:180px; height:100vh; background:#1e1e2e; color:#fff; padding:20px; box-shadow:2px 0 5px rgba(0,0,0,0.1); z-index:1000; display:flex; flex-direction:column; align-items:center; }
|
||||
.sidebar h3 { margin:0; font-size:18px; color:#add8e6; text-align:center; }
|
||||
.sidebar h3 { margin:0; font-size:18px; color:#add8e6; text-align:center; margin-bottom: 20px; }
|
||||
.navigation-links { width:100%; margin-top:60px; }
|
||||
.sidebar a, .sidebar button { display:block; color:#8be9fd; text-decoration:none; margin:10px 0; font-size:16px; padding:15px; border-radius:4px; background:transparent; border:none; cursor:pointer; width:calc(100% - 40px); text-align:left; transition:all .2s ease; }
|
||||
.sidebar a:hover, .sidebar button:hover { color:#ff79c6; background-color:rgba(139,233,253,.2); }
|
||||
@@ -23,6 +23,14 @@
|
||||
.notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
|
||||
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
|
||||
.code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; }
|
||||
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); display:flex; align-items:center; justify-content:center; z-index:2000; }
|
||||
.spinner { width:42px; height:42px; border:4px solid #cbd5e1; border-top-color:#4f46e5; border-radius:50%; animation:spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.fade-in { animation: fadeUp 0.25s ease-out; }
|
||||
@keyframes fadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
|
||||
table tr:hover { background-color:#f3f4f6; transition: background-color 0.2s ease; }
|
||||
.btn { transition: transform 0.1s ease, box-shadow 0.2s ease; }
|
||||
.btn:hover { transform: translateY(-1px); box-shadow:0 6px 16px rgba(31,35,40,0.12); }
|
||||
</style>
|
||||
{% csrf_token %}
|
||||
<script>
|
||||
@@ -53,6 +61,7 @@
|
||||
function enableToggleSelect(sel){ sel.addEventListener('mousedown',function(e){ if(e.target && e.target.tagName==='OPTION'){ e.preventDefault(); const op=e.target; op.selected=!op.selected; this.dispatchEvent(new Event('change',{bubbles:true})); } }); }
|
||||
function clearSelection(id){ const sel=document.getElementById(id); Array.from(sel.options).forEach(o=>o.selected=false); }
|
||||
async function generateCode(){
|
||||
const ov=document.getElementById('overlay'); ov.style.display='flex';
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const keys=selectedValues(document.getElementById('keys'));
|
||||
const manageKeys=selectedValues(document.getElementById('manageKeys'));
|
||||
@@ -64,19 +73,46 @@
|
||||
const msg=document.getElementById('msg');
|
||||
if(resp.ok && data.status==='success'){out.textContent=data.data.code; msg.textContent='生成成功'; msg.className='notice success'; msg.style.display='block';}
|
||||
else{msg.textContent=data.message||'生成失败'; msg.className='notice error'; msg.style.display='block';}
|
||||
ov.style.display='none';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',()=>{loadKeys(); enableToggleSelect(document.getElementById('keys')); enableToggleSelect(document.getElementById('manageKeys'));});
|
||||
async function loadCodes(){
|
||||
const ov=document.getElementById('overlay'); ov.style.display='flex';
|
||||
const resp=await fetch('/elastic/registration-codes/list/');
|
||||
const data=await resp.json();
|
||||
const tbody=document.getElementById('codesBody');
|
||||
if(!tbody) return;
|
||||
tbody.innerHTML='';
|
||||
if(resp.ok && data.status==='success'){
|
||||
(data.data||[]).forEach(it=>{
|
||||
const tr=document.createElement('tr');
|
||||
const status = it.active? '有效' : '失效';
|
||||
const ka = Array.isArray(it.keys)? it.keys.join('、') : '';
|
||||
const mka = Array.isArray(it.manage_keys)? it.manage_keys.join('、') : '';
|
||||
tr.innerHTML = `<td>${it.code||''}</td><td>${ka}</td><td>${mka}</td><td>${formatDate(it.created_at)}</td><td>${formatDate(it.expires_at)}</td><td>${status}</td><td>${it.active? '<button class=\"btn btn-secondary\" data-code=\"'+it.code+'\">作废</button>':''}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
ov.style.display='none';
|
||||
}
|
||||
function formatDate(t){ if(!t) return ''; try{ const d = new Date(t); if(String(d)!='Invalid Date'){ const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;} }catch(e){} return ''; }
|
||||
async function revokeCode(code){ const csrftoken=getCookie('csrftoken'); const resp=await fetch('/elastic/registration-codes/revoke/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code})}); const msg=document.getElementById('msg'); const data=await resp.json(); if(resp.ok && data.status==='success'){ msg.textContent='已作废'; msg.className='notice success'; msg.style.display='block'; loadCodes(); } else { msg.textContent=data.message||'作废失败'; msg.className='notice error'; msg.style.display='block'; } }
|
||||
document.addEventListener('click',function(e){ const btn=e.target; if(btn && btn.matches('button[data-code]')){ revokeCode(btn.getAttribute('data-code')); }});
|
||||
document.addEventListener('DOMContentLoaded',()=>{loadKeys(); enableToggleSelect(document.getElementById('keys')); enableToggleSelect(document.getElementById('manageKeys')); loadCodes();});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="overlay" class="overlay" style="display:none"><div class="spinner"></div></div>
|
||||
<div class="sidebar">
|
||||
<h3>用户ID:{{ user_id }}</h3>
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}">主页</a>
|
||||
<a href="{% url 'main:home' %}">返回主页</a>
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="card">
|
||||
<div class="card fade-in">
|
||||
<h2>管理注册码</h2>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@@ -113,7 +149,193 @@
|
||||
</div>
|
||||
<div id="msg" class="notice"></div>
|
||||
<div class="code-box" id="codeOut"></div>
|
||||
<div class="row" style="margin-top:12px;">
|
||||
<div class="col">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3>已生成的注册码</h3>
|
||||
<div>
|
||||
<button class="btn btn-secondary" onclick="loadCodes()">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%; border-collapse:collapse; margin-top:10px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">code</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">keys</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">manage_keys</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">创建时间</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">过期时间</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">状态</th>
|
||||
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="codesBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 获取CSRF令牌的函数
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
}
|
||||
|
||||
// 导航点击处理函数,提供备用URL
|
||||
function handleNavClick(element, fallbackUrl) {
|
||||
// 尝试使用Django模板生成的URL,如果失败则使用备用URL
|
||||
try {
|
||||
// 如果模板渲染正常,直接返回true让默认行为处理
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 如果模板渲染有问题,使用备用URL
|
||||
window.location.href = fallbackUrl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 修复用户管理链接跳转问题
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 为用户管理链接添加事件监听器,确保正确跳转
|
||||
const userManagementLink = document.querySelector('a[href*="get_users"]');
|
||||
if (userManagementLink) {
|
||||
userManagementLink.addEventListener('click', function(e) {
|
||||
// 阻止默认行为
|
||||
e.preventDefault();
|
||||
|
||||
// 获取备用URL
|
||||
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
|
||||
|
||||
// 直接跳转到用户管理页面
|
||||
window.location.href = fallbackUrl;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 登出功能
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
const msg = document.getElementById('logoutMsg');
|
||||
msg.textContent = '';
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
try {
|
||||
const resp = await fetch('/accounts/logout/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken || ''
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error('登出失败');
|
||||
}
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
window.location.href = data.redirect_url;
|
||||
} catch (e) {
|
||||
msg.textContent = e.message || '发生错误';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
|
||||
function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
|
||||
|
||||
const trendChart = echarts.init(document.getElementById('chartTrend'));
|
||||
const typesChart = echarts.init(document.getElementById('chartTypes'));
|
||||
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
|
||||
|
||||
async function loadTrend(){
|
||||
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
|
||||
const res = await fetchJSON(url);
|
||||
if(res.status!=='success') return;
|
||||
const buckets = res.data || [];
|
||||
const x = buckets.map(b=>b.key_as_string||'');
|
||||
const y = buckets.map(b=>b.doc_count||0);
|
||||
trendChart.setOption({
|
||||
tooltip:{trigger:'axis'},
|
||||
xAxis:{type:'category', data:x},
|
||||
yAxis:{type:'value'},
|
||||
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTypes(){
|
||||
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
|
||||
const res = await fetchJSON(url);
|
||||
if(res.status!=='success') return;
|
||||
const buckets = res.data || [];
|
||||
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
|
||||
typesChart.setOption({
|
||||
tooltip:{trigger:'item'},
|
||||
legend:{type:'scroll'},
|
||||
series:[{ type:'pie', radius:['40%','70%'], data }]
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTypesTrend(){
|
||||
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
|
||||
const res = await fetchJSON(url);
|
||||
if(res.status!=='success') return;
|
||||
const rows = res.data || [];
|
||||
const x = rows.map(r=>r.key_as_string||'');
|
||||
const typeSet = new Set();
|
||||
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
|
||||
const types = Array.from(typeSet);
|
||||
const series = types.map(tp=>({
|
||||
name: tp,
|
||||
type:'line',
|
||||
smooth:true,
|
||||
data: rows.map(r=>{
|
||||
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
|
||||
return b? b.doc_count||0 : 0;
|
||||
})
|
||||
}));
|
||||
typesTrendChart.setOption({
|
||||
tooltip:{trigger:'axis'},
|
||||
legend:{type:'scroll'},
|
||||
xAxis:{type:'category', data:x},
|
||||
yAxis:{type:'value'},
|
||||
series
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(t){
|
||||
try{
|
||||
const d = new Date(t);
|
||||
if(String(d) !== 'Invalid Date'){
|
||||
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 t||'';
|
||||
}
|
||||
|
||||
async function loadRecent(){
|
||||
const listEl = document.getElementById('recentList');
|
||||
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
|
||||
const res = await fetchJSON(url);
|
||||
if(res.status!=='success') return;
|
||||
const items = res.data || [];
|
||||
listEl.innerHTML = '';
|
||||
items.forEach(it=>{
|
||||
const li = document.createElement('li');
|
||||
const t = formatTime(it.time);
|
||||
const u = it.username || '';
|
||||
const ty = it.type || '未知';
|
||||
li.textContent = `${t},${u},${ty}`;
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
loadTrend();
|
||||
loadTypes();
|
||||
loadTypesTrend();
|
||||
loadRecent();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,282 +4,71 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>图片上传与识别</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 导航栏样式 - 保持原有样式 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 100vh;
|
||||
background: #1e1e2e;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-links {
|
||||
width: 100%;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||
/* 导航栏样式 */
|
||||
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
|
||||
flex-direction: column;align-items: center;}
|
||||
.user-id {text-align: center;margin-bottom: 0px;}
|
||||
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||
.navigation-links {width: 100%;margin-top: 60px;}
|
||||
.sidebar a,
|
||||
.sidebar button {
|
||||
display: block;
|
||||
color: #8be9fd;
|
||||
text-decoration: none;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: calc(100% - 40px);
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||
.sidebar a:hover,
|
||||
.sidebar button:hover {
|
||||
color: #ff79c6;
|
||||
background-color: rgba(139, 233, 253, 0.2);
|
||||
}
|
||||
|
||||
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||
/* 主内容区 - 改进后的样式 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: #f8fafc;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.upload-section:hover {
|
||||
border-color: #4f46e5;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.upload-section.drag-over {
|
||||
border-color: #4f46e5;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.upload-section input[type="file"] {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.main-content {margin-left: 200px;padding: 20px;color: #333;}
|
||||
.container { max-width: 1200px;margin: 0 auto;background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||
padding: 24px;}
|
||||
.header {display: flex;align-items: center;justify-content: space-between;margin-bottom: 12px;}
|
||||
.header h2 {margin: 0; color: #1e293b;}
|
||||
.header p {margin: 5px 0 0 0;color: #64748b;font-size: 14px;}
|
||||
.upload-section { background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;padding: 32px; text-align: center;transition: all 0.3s ease;
|
||||
margin-bottom: 24px;}
|
||||
.upload-section:hover {border-color: #4f46e5; background: #f1f5f9; }
|
||||
.upload-section.drag-over {border-color: #4f46e5; background: #e0e7ff; }
|
||||
.upload-section input[type="file"] {margin: 15px 0;}
|
||||
.btn {padding: 10px 16px;border: none;border-radius: 8px;cursor: pointer;margin: 0 4px;font-size: 14px;transition: all 0.2s ease; }
|
||||
.btn-primary { background: #4f46e5; color: #fff; }
|
||||
.btn-primary:hover { background: #4338ca;}
|
||||
.btn-secondary {background: #e2e8f0;color: #334155; }
|
||||
.btn-secondary:hover { background: #cbd5e1;}
|
||||
.btn-danger { background: #ef4444;color: #fff;}
|
||||
.btn-danger:hover { background: #dc2626;}
|
||||
.preview-container {display: flex; gap: 24px; margin: 24px 0;}
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-box h3 {
|
||||
margin-top: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-box h3 {
|
||||
margin-top: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.form-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#kvForm {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
margin-bottom: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-row input {
|
||||
padding: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#resultBox {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
.preview-container {flex-direction: column;}
|
||||
}
|
||||
.preview-box {flex: 1; text-align: center; }
|
||||
.preview-box h3 {margin-top: 0;color: #334155; }
|
||||
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
|
||||
.result-box {flex: 1;}
|
||||
.result-box h3 { margin-top: 0; color: #334155;}
|
||||
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
||||
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
|
||||
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
|
||||
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
|
||||
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
|
||||
border-radius: 8px; resize: vertical;box-sizing: border-box; }
|
||||
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
|
||||
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
|
||||
.progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
|
||||
.progress-wrap {display:none; margin-top: 8px;}
|
||||
.progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 左侧固定栏目 -->
|
||||
<div class="sidebar">
|
||||
<div class="user-id">
|
||||
<h3>用户ID:{{ user_id }}</h3>
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
</div>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}">主页</a>
|
||||
<button id="logoutBtn">退出登录</button>
|
||||
<a href="{% url 'main:home' %}">返回主页</a>
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
@@ -305,6 +94,10 @@
|
||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||
</form>
|
||||
<div class="status-message" id="uploadMsg"></div>
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
|
||||
<div class="progress-text" id="progressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
@@ -351,9 +144,51 @@ const kvForm = document.getElementById('kvForm');
|
||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||
const dropArea = document.getElementById('dropArea');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
|
||||
let currentImageRel = '';
|
||||
|
||||
function setProgress(p, text){
|
||||
const v = Math.max(0, Math.min(100, Math.round(p||0)));
|
||||
progressBar.style.width = v + '%';
|
||||
progressText.textContent = (text||'') + (text? ' ' : '') + v + '%';
|
||||
}
|
||||
function showProgress(){
|
||||
progressWrap.style.display = 'block';
|
||||
}
|
||||
function hideProgress(){
|
||||
progressWrap.style.display = 'none';
|
||||
setProgress(0, '');
|
||||
}
|
||||
|
||||
async function convertToJpeg(file){
|
||||
const url = URL.createObjectURL(file);
|
||||
let img;
|
||||
try{
|
||||
const blob = await fetch(url).then(r=>r.blob());
|
||||
img = await createImageBitmap(blob);
|
||||
}catch(e){
|
||||
img = await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=url;});
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
const maxDim = 2000;
|
||||
const w = img.width;
|
||||
const h = img.height;
|
||||
const scale = Math.min(1, maxDim/Math.max(w,h));
|
||||
const nw = Math.round(w*scale);
|
||||
const nh = Math.round(h*scale);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = nw;
|
||||
canvas.height = nh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, nw, nh);
|
||||
const blob = await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.82));
|
||||
const name = (file.name||'image').replace(/\.[^/.]+$/, '') + '.jpg';
|
||||
return new File([blob], name, {type:'image/jpeg'});
|
||||
}
|
||||
|
||||
// 拖拽上传功能
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||
@@ -504,20 +339,39 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
showProgress();
|
||||
setProgress(5, '转换为JPG');
|
||||
let jpegFile = file;
|
||||
try {
|
||||
jpegFile = await convertToJpeg(file);
|
||||
setProgress(50, '转换为JPG');
|
||||
preview.src = URL.createObjectURL(jpegFile);
|
||||
} catch (_) {
|
||||
jpegFile = file;
|
||||
setProgress(50, '转换为JPG');
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', jpegFile);
|
||||
|
||||
try {
|
||||
let prog = 50;
|
||||
setProgress(prog, '识别中');
|
||||
const timer = setInterval(() => {
|
||||
prog = Math.min(95, prog + 1);
|
||||
setProgress(prog, '识别中');
|
||||
}, 120);
|
||||
const resp = await fetch('/elastic/upload/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
||||
body: formData,
|
||||
});
|
||||
clearInterval(timer);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.status !== 'success') {
|
||||
throw new Error(data.message || '上传识别失败');
|
||||
}
|
||||
setProgress(100, '识别完成');
|
||||
uploadMsg.textContent = data.message || '识别成功';
|
||||
uploadMsg.className = 'status-message success';
|
||||
uploadMsg.style.display = 'block';
|
||||
@@ -525,10 +379,12 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
renderFormFromObject(data.data || {});
|
||||
currentImageRel = data.image;
|
||||
confirmBtn.disabled = false;
|
||||
setTimeout(hideProgress, 800);
|
||||
} catch (e) {
|
||||
uploadMsg.textContent = e.message || '发生错误';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
progressText.textContent = '识别失败';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,70 +4,18 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>用户管理</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||
/* 导航栏样式 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 100vh;
|
||||
background: #1e1e2e;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-links {
|
||||
width: 100%;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
|
||||
flex-direction: column;align-items: center;}
|
||||
.user-id {text-align: center;margin-bottom: 0px;}
|
||||
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||
.navigation-links {width: 100%;margin-top: 60px;}
|
||||
.sidebar a,
|
||||
.sidebar button {
|
||||
display: block;
|
||||
color: #8be9fd;
|
||||
text-decoration: none;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: calc(100% - 40px);
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||
.sidebar a:hover,
|
||||
.sidebar button:hover {
|
||||
color: #ff79c6;
|
||||
background-color: rgba(139, 233, 253, 0.2);
|
||||
}
|
||||
|
||||
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
@@ -258,30 +206,59 @@
|
||||
<!-- 左侧固定栏目 -->
|
||||
<div class="sidebar">
|
||||
<div class="user-id">
|
||||
<h3>用户ID:{{ user_id }}</h3>
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
</div>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
|
||||
<button id="logoutBtn">退出登录</button>
|
||||
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">返回主页</a>
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
</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>
|
||||
<button id="addUserBtn" class="btn btn-primary">添加用户</button>
|
||||
{% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="notification success" id="successNotification">
|
||||
操作成功!
|
||||
</div>
|
||||
<div class="notification error" id="errorNotification">
|
||||
操作失败!
|
||||
</div>
|
||||
<div class="notification success" id="successNotification">操作成功!</div>
|
||||
<div class="notification error" id="errorNotification">操作失败!</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="搜索用户名...">
|
||||
@@ -299,12 +276,11 @@
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- 用户数据将通过JavaScript加载 -->
|
||||
</tbody>
|
||||
<tbody id="usersTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑用户模态框 -->
|
||||
@@ -563,7 +539,10 @@
|
||||
}
|
||||
|
||||
// 事件监听器
|
||||
document.getElementById('addUserBtn').addEventListener('click', openAddModal);
|
||||
const addBtn = document.getElementById('addUserBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', openAddModal);
|
||||
}
|
||||
|
||||
document.getElementById('userForm').addEventListener('submit', saveUser);
|
||||
|
||||
@@ -575,15 +554,21 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('searchBtn').addEventListener('click', function() {
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', function() {
|
||||
const searchTerm = document.getElementById('searchInput').value;
|
||||
loadUsers(searchTerm);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', function() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
loadUsers();
|
||||
});
|
||||
}
|
||||
|
||||
// 点击模态框外部关闭模态框
|
||||
window.addEventListener('click', function(event) {
|
||||
@@ -624,7 +609,34 @@
|
||||
|
||||
// 页面加载时获取用户列表
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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) {
|
||||
loadUsers();
|
||||
}
|
||||
});
|
||||
|
||||
// 为表格中的编辑和删除按钮添加事件监听器
|
||||
|
||||
@@ -36,6 +36,8 @@ urlpatterns = [
|
||||
path('registration-codes/keys/', views.get_keys_list_view, name='get_keys_list'),
|
||||
path('registration-codes/keys/add/', views.add_key_view, name='add_key'),
|
||||
path('registration-codes/generate/', views.generate_registration_code_view, name='generate_registration_code'),
|
||||
path('registration-codes/list/', views.list_registration_codes_view, name='list_registration_codes'),
|
||||
path('registration-codes/revoke/', views.revoke_registration_code_view, name='revoke_registration_code'),
|
||||
|
||||
# 分析接口
|
||||
path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),
|
||||
|
||||
@@ -222,16 +222,27 @@ def add_user(request):
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def get_users(request):
|
||||
if request.session.get("user_id") is None:
|
||||
uid = request.session.get("user_id")
|
||||
if uid is None:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
if int(request.session.get("permission", 1)) != 0:
|
||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||
try:
|
||||
is_admin = int(request.session.get("permission", 1)) == 0
|
||||
requester = get_user_by_id(uid) or {}
|
||||
mgr_keys = set(requester.get("manage_key") or [])
|
||||
q = (request.GET.get("search") or "").strip()
|
||||
users = get_all_users()
|
||||
if is_admin:
|
||||
filtered = users
|
||||
elif mgr_keys:
|
||||
def match_manage(user):
|
||||
ukeys = set(user.get("key") or [])
|
||||
return bool(ukeys & mgr_keys)
|
||||
filtered = [u for u in users if match_manage(u)]
|
||||
else:
|
||||
filtered = [u for u in users if str(u.get("user_id")) == str(uid)]
|
||||
if q:
|
||||
users = [u for u in users if q in str(u.get("username", ""))]
|
||||
return JsonResponse({"status": "success", "data": users})
|
||||
filtered = [u for u in filtered if q in str(u.get("username", ""))]
|
||||
return JsonResponse({"status": "success", "data": filtered})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@@ -239,10 +250,9 @@ def get_users(request):
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def update_user_by_id_view(request, user_id):
|
||||
if request.session.get("user_id") is None:
|
||||
uid = request.session.get("user_id")
|
||||
if uid is None:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
if int(request.session.get("permission", 1)) != 0:
|
||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except Exception:
|
||||
@@ -250,21 +260,41 @@ def update_user_by_id_view(request, user_id):
|
||||
new_username = (payload.get("username") or "").strip()
|
||||
new_permission = payload.get("permission")
|
||||
new_password = (payload.get("password") or "").strip()
|
||||
if new_password and len(new_password) < 6:
|
||||
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
|
||||
|
||||
is_admin = int(request.session.get("permission", 1)) == 0
|
||||
requester = get_user_by_id(uid) or {}
|
||||
target = get_user_by_id(user_id) or {}
|
||||
requester_mgr = set(requester.get("manage_key") or [])
|
||||
target_keys = set(target.get("key") or [])
|
||||
|
||||
if is_admin:
|
||||
if new_username:
|
||||
other = get_user_by_username(new_username)
|
||||
if other and int(other.get("user_id", -1)) != int(user_id):
|
||||
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
|
||||
if new_password and len(new_password) < 6:
|
||||
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
|
||||
ok = es_update_user_by_id(
|
||||
user_id,
|
||||
username=new_username if new_username else None,
|
||||
permission=int(new_permission) if new_permission is not None else None,
|
||||
password=new_password if new_password else None,
|
||||
)
|
||||
if not ok:
|
||||
return JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||
return JsonResponse({"status": "success", "message": "用户更新成功"})
|
||||
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||
|
||||
if str(uid) == str(user_id):
|
||||
if not new_password:
|
||||
return JsonResponse({"status": "error", "message": "仅允许修改密码"}, status=400)
|
||||
ok = es_update_user_by_id(user_id, password=new_password)
|
||||
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||
|
||||
if requester_mgr and (target_keys & requester_mgr):
|
||||
if not new_password or new_username or new_permission is not None:
|
||||
return JsonResponse({"status": "error", "message": "导师仅允许修改密码"}, status=403)
|
||||
ok = es_update_user_by_id(user_id, password=new_password)
|
||||
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||
|
||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
@@ -327,7 +357,7 @@ def ocr_and_extract_info(image_path: str):
|
||||
],
|
||||
},
|
||||
],
|
||||
model="ernie-4.5-turbo-vl-32k",
|
||||
model=getattr(settings, "OPENAI_MODEL_NAME", "ernie-4.5-turbo-vl-32k"),
|
||||
)
|
||||
|
||||
response_text = chat_completion.choices[0].message.content
|
||||
@@ -598,11 +628,16 @@ def user_manage(request):
|
||||
if session_user_id is None:
|
||||
from django.shortcuts import redirect
|
||||
return redirect("/accounts/login/")
|
||||
if int(request.session.get("permission", 1)) != 0:
|
||||
from django.shortcuts import redirect
|
||||
return redirect("/main/home/")
|
||||
is_admin = int(request.session.get("permission", 1)) == 0
|
||||
me = get_user_by_id(session_user_id) or {}
|
||||
has_manage = bool(me.get("manage_key"))
|
||||
user_id_qs = request.GET.get("user_id")
|
||||
context = {"user_id": user_id_qs or session_user_id}
|
||||
context = {
|
||||
"user_id": user_id_qs or session_user_id,
|
||||
"is_admin": is_admin,
|
||||
"is_tutor": (not is_admin) and has_manage,
|
||||
"is_student": (not is_admin) and (not has_manage),
|
||||
}
|
||||
return render(request, "elastic/users.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@@ -668,3 +703,31 @@ def generate_registration_code_view(request):
|
||||
if not result:
|
||||
return JsonResponse({"status": "error", "message": "生成失败"}, status=500)
|
||||
return JsonResponse({"status": "success", "data": result})
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def list_registration_codes_view(request):
|
||||
if request.session.get("user_id") is None:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
if int(request.session.get("permission", 1)) != 0:
|
||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||
data = list_registration_codes()
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def revoke_registration_code_view(request):
|
||||
if request.session.get("user_id") is None:
|
||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||
if int(request.session.get("permission", 1)) != 0:
|
||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except Exception:
|
||||
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
|
||||
code = (payload.get("code") or "").strip()
|
||||
if not code:
|
||||
return JsonResponse({"status": "error", "message": "缺少code"}, status=400)
|
||||
ok = revoke_registration_code(code)
|
||||
if not ok:
|
||||
return JsonResponse({"status": "error", "message": "作废失败"}, status=500)
|
||||
return JsonResponse({"status": "success"})
|
||||
|
||||
@@ -6,148 +6,47 @@
|
||||
<title>数据管理系统</title>
|
||||
<script src="{% static 'vendor/echarts.min.js' %}"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||
/* 导航栏样式 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 100vh;
|
||||
background: #1e1e2e;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-links {
|
||||
width: 100%;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
|
||||
flex-direction: column;align-items: center;}
|
||||
.user-id {text-align: center;margin-bottom: 0px;}
|
||||
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||
.navigation-links {width: 100%;margin-top: 60px;}
|
||||
.sidebar a,
|
||||
.sidebar button {
|
||||
display: block;
|
||||
color: #8be9fd;
|
||||
text-decoration: none;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: calc(100% - 40px);
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||
.sidebar a:hover,
|
||||
.sidebar button:hover {
|
||||
color: #ff79c6;
|
||||
background-color: rgba(139, 233, 253, 0.2);
|
||||
}
|
||||
|
||||
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||
padding: 20px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.badge {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.legend .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
.main-content {margin-left: 200px;padding: 20px;color: #333;}
|
||||
.card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
|
||||
.grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
|
||||
.grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
|
||||
.header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
||||
.legend {display: flex;gap: 12px;align-items: center;}
|
||||
.legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
|
||||
.muted {color: #6b7280;font-size: 12px;}
|
||||
.btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
|
||||
.btn-primary {background: #4f46e5;color: #fff;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 左侧固定栏目 -->
|
||||
<div class="sidebar">
|
||||
<div class="user-id">
|
||||
<h3>用户ID:{{ user_id }}</h3>
|
||||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||
</div>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
|
||||
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
|
||||
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a>
|
||||
{% if is_admin %}
|
||||
<a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a>
|
||||
{% if is_admin %}
|
||||
<a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
|
||||
{% endif %}
|
||||
<button id="logoutBtn">退出登录</button>
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
@@ -157,7 +56,7 @@
|
||||
<div class="main-content">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2>主页</h2>
|
||||
<h2>师生共创系统</h2>
|
||||
<span class="badge">用户:{{ user_id }}</span>
|
||||
</div>
|
||||
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
|
||||
|
||||
Reference in New Issue
Block a user