38 Commits

Author SHA1 Message Date
45005fcc92 更新工作流适配新runner[ci][0.2.5]
All checks were successful
CI / docker-ci (push) Successful in 19s
2025-12-26 00:00:35 -05:00
df18bdfa7e 使用环境变量管理模型名称[ci][0.2.5]
Some checks failed
CI / docker-ci (push) Failing after 39m44s
2025-12-24 15:46:07 +08:00
281ade6ac9 增加了进度条,提升等待感知[ci][0.2.4]
All checks were successful
CI / docker-ci (push) Successful in 34s
2025-11-27 12:21:08 +08:00
835426b133 修复了不支持webp格式的图片上传的问题
All checks were successful
CI / docker-ci (push) Has been skipped
2025-11-27 12:11:58 +08:00
d001fec21e 搞定(应该)😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 35s
2025-11-27 11:39:15 +08:00
253de3639c 😅😅😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:33:49 +08:00
a0507b8054 😅😅😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 28s
2025-11-27 11:31:38 +08:00
9f803880fa 😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:25:13 +08:00
71fe964476 😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 59s
2025-11-27 11:22:19 +08:00
0f5c8c08ff 再试一次[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:18:00 +08:00
e032253327 使用act_runner的服务器以提供下载[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:08:34 +08:00
3f108e2138 调整了一下yml进行构建和发布[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 5m31s
2025-11-26 22:33:12 +08:00
2d913e397f 调整了一下yml进行构建和发布[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 4m45s
2025-11-26 22:24:15 +08:00
74bc8aa498 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 22:11:34 +08:00
5d747faee1 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 30s
2025-11-26 22:07:50 +08:00
7bd8eeca77 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 22:01:14 +08:00
782b2dd82e 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 30s
2025-11-26 21:58:10 +08:00
f9c0abb3a0 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 29s
2025-11-26 21:55:50 +08:00
c5300591e6 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 21:51:52 +08:00
f96629566f 调整了一下yml[ci]
All checks were successful
CI / docker-ci (push) Successful in 13m56s
2025-11-26 18:12:03 +08:00
8d581ac638 不尝试对镜像进行测试[ci]
Some checks failed
CI / docker-ci (push) Failing after 4s
2025-11-26 18:09:13 +08:00
acc80074ea 使用[ci]触发工作流
Some checks failed
CI / docker-ci (push) Failing after 3m0s
2025-11-26 18:00:35 +08:00
DSQ
62d28be032 数据管理页面删除时刷新页面 2025-11-22 15:59:31 +08:00
DSQ
5b956e1365 数据管理页面删除时刷新页面 2025-11-22 13:05:29 +08:00
DSQ
7485ba16e6 修复了数据管理页面删除时不能及时刷新页面的BUG 2025-11-22 12:10:01 +08:00
DSQ
ac580599b3 Merge remote-tracking branch 'origin/Django' into Django 2025-11-22 11:59:48 +08:00
DSQ
faae7032f1 在查看图片时可以进行缩放 2025-11-22 11:59:41 +08:00
615d9433fe 注册码选填 2025-11-22 11:45:09 +08:00
d755f4710f 邮件验证码搞定 2025-11-21 09:53:16 +08:00
3e598fe0a1 Merge remote-tracking branch 'origin/Django' into Django 2025-11-18 15:20:39 +08:00
5a9d98282a 更新用户管理,现在能通过班导师,管理员,学生进入对应的页面进行密码修改 2025-11-18 15:20:30 +08:00
DSQ
8f9fc9c914 UI微调 2025-11-18 14:46:18 +08:00
DSQ
b5d76be37b Merge remote-tracking branch 'origin/Django' into Django 2025-11-18 14:04:22 +08:00
DSQ
100531ddd1 修复了图片放大比例问题 2025-11-18 14:04:14 +08:00
68bc4b54f5 修复了在实际部署环境中,请求可能命中不同进程导致的登录报错 2025-11-18 13:36:53 +08:00
5153017a80 更新注册码管理及页面动画 2025-11-17 23:59:16 +08:00
DSQ
2c58c1be29 Merge remote-tracking branch 'origin/Django' into Django 2025-11-17 19:23:59 +08:00
DSQ
8c14544ca1 UI微调 2025-11-17 19:23:51 +08:00
19 changed files with 1156 additions and 830 deletions

129
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,129 @@
name: CI
# Required Secrets:
# - DJANGO_SECRET_KEY: Django Secret Key
# - token: Gitea API token for creating releases
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
# - WEBDAV_USER: WebDAV username
# - WEBDAV_PASSWORD: WebDAV password
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
container:
image: catthehacker/ubuntu:act-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
# 请在 Secrets 中配置 ALIST_PUBLIC_URL例如 http://139.224.69.213:8080/d/ci
DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
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 WebDAV
env:
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
run: |
set -e
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
# 检查必要的 secrets 是否存在
if [ -z "$WEBDAV_URL" ]; then
echo "Error: WEBDAV_URL secret is not set."
exit 1
fi
# 确保 URL 以 / 结尾
case "$WEBDAV_URL" in
*/) ;;
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
esac
echo "Uploading $ART to $WEBDAV_URL..."
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
echo "Upload success."
- 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%/}"
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"

View File

@@ -166,3 +166,4 @@ ELASTICSEARCH_INDEX_NAMES = {
# AI Studio/OpenAI client settings # AI Studio/OpenAI client settings
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '') 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_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')

View File

@@ -90,4 +90,26 @@ def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool:
actual = hash_password_with_salt(password_plain, salt) actual = hash_password_with_salt(password_plain, salt)
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
except Exception: except Exception:
return False 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))

View File

@@ -77,8 +77,20 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const setKeyResp = await fetch('/accounts/session-key/', { 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 }) method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 })
}); });
const setKeyJson = await setKeyResp.json(); const setKeySnapshot = await (async () => {
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败'); 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 aesKey = await importAesKey(aesKeyRaw);
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv); 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/', { 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 }) 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 (!submitResp.ok || !submitJson.ok) {
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); } if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
throw new Error(submitJson.message || '登录失败'); throw new Error(submitJson.message || '登录失败');

View File

@@ -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); } .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; } h1 { font-size: 20px; margin: 0 0 16px; }
label { display: block; margin: 12px 0 6px; color: #333; } 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 { 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; } button:disabled { background: #9bbcf0; cursor: not-allowed; }
.error { color: #d93025; margin-top: 10px; min-height: 20px; } .error { color: #d93025; margin-top: 10px; min-height: 20px; }

View File

@@ -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); } .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; } h1 { font-size: 20px; margin: 0 0 16px; }
label { display:block; margin: 12px 0 6px; color:#333; } 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 { 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; } button:disabled { background:#9bbcf0; cursor:not-allowed; }
.error { color:#d93025; margin-top:10px; min-height:20px; } .error { color:#d93025; margin-top:10px; min-height:20px; }
@@ -20,10 +20,14 @@
<h1>注册新用户</h1> <h1>注册新用户</h1>
<form id="regForm"> <form id="regForm">
{% csrf_token %} {% csrf_token %}
<label for="code">注册码</label> <label for="code">注册码(选填)</label>
<input id="code" name="code" type="text" required /> <input id="code" name="code" type="text" />
<label for="email">邮箱</label> <label for="email">邮箱</label>
<input id="email" name="email" type="email" required /> <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> <label for="username">用户名</label>
<input id="username" name="username" type="text" required /> <input id="username" name="username" type="text" required />
<label for="password">密码</label> <label for="password">密码</label>
@@ -33,7 +37,7 @@
<button id="regBtn" type="submit">注册</button> <button id="regBtn" type="submit">注册</button>
<div id="error" class="error"></div> <div id="error" class="error"></div>
</form> </form>
<div class="hint">仅允许持有管理员提供注册码的学生注册</div> <div class="hint">有注册码请填写,否则可留空</div>
</div> </div>
<script> <script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();} 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 code=(document.getElementById('code').value||'').trim();
const email=(document.getElementById('email').value||'').trim(); const email=(document.getElementById('email').value||'').trim();
const username=(document.getElementById('username').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 password=document.getElementById('password').value||'';
const confirm=document.getElementById('confirm').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;} if(password!==confirm){err.textContent='两次密码不一致';return;}
const btn=document.getElementById('regBtn'); btn.disabled=true; const btn=document.getElementById('regBtn'); btn.disabled=true;
try{ try{
const csrftoken=getCookie('csrftoken'); 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(); const data=await resp.json();
if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');} if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');}
window.location.href=data.redirect_url; window.location.href=data.redirect_url;
}catch(e){err.textContent=e.message||'发生错误';} }catch(e){err.textContent=e.message||'发生错误';}
finally{btn.disabled=false;} 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> </script>
</body> </body>
</html> </html>

View File

@@ -11,4 +11,5 @@ urlpatterns = [
path("logout/", views.logout, name="logout"), path("logout/", views.logout, name="logout"),
path("register/", views.register_page, name="register"), path("register/", views.register_page, name="register"),
path("register/submit/", views.register_submit, name="register_submit"), path("register/submit/", views.register_submit, name="register_submit"),
path("email/send-code/", views.send_email_code, name="send_email_code"),
] ]

View File

@@ -4,6 +4,8 @@ import os
import io import io
import random import random
import string import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect 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 django.conf import settings
from .es_client import get_user_by_username 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 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"]) @require_http_methods(["GET"])
@ensure_csrf_cookie @ensure_csrf_cookie
def pubkey(request): 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}) return JsonResponse({"public_key_spki": pk_b64})
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -56,7 +62,10 @@ def set_session_key(request):
if not enc_key_b64: if not enc_key_b64:
return HttpResponseBadRequest("Missing fields") return HttpResponseBadRequest("Missing fields")
try: 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: except Exception:
return HttpResponseBadRequest("Decrypt error") return HttpResponseBadRequest("Decrypt error")
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii") 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 request.session["permission"] = 1
if "session_enc_key_b64" in request.session: if "session_enc_key_b64" in request.session:
del request.session["session_enc_key_b64"] 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: if "login_failed_once" in request.session:
del request.session["login_failed_once"] del request.session["login_failed_once"]
if "captcha_code" in request.session: if "captcha_code" in request.session:
@@ -174,24 +185,38 @@ def register_submit(request):
return HttpResponseBadRequest("Invalid JSON") return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip() code = (payload.get("code") or "").strip()
email = (payload.get("email") or "").strip() email = (payload.get("email") or "").strip()
email_code = (payload.get("email_code") or "").strip()
username = (payload.get("username") or "").strip() username = (payload.get("username") or "").strip()
password = (payload.get("password") or "") 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") return HttpResponseBadRequest("Missing fields")
rc = get_registration_code(code) v = request.session.get("email_verify") or {}
if not rc: if (v.get("email") or "") != email:
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400) return JsonResponse({"ok": False, "message": "请先验证邮箱"}, status=400)
try: try:
exp = rc.get("expires_at") exp_ts = int(v.get("expires_at") or 0)
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = __import__("datetime").datetime.fromisoformat(str(exp))
if exp_dt <= now:
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
except Exception: except Exception:
pass 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)
try:
exp = rc.get("expires_at")
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = __import__("datetime").datetime.fromisoformat(str(exp))
if exp_dt <= now:
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
except Exception:
pass
existing = es_get_user_by_username(username) existing = es_get_user_by_username(username)
if existing: if existing:
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409) return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
@@ -203,9 +228,74 @@ def register_submit(request):
"password": password, "password": password,
"permission": 1, "permission": 1,
"email": email, "email": email,
"key": rc.get("keys") or [], "key": (rc.get("keys") if rc else []) or [],
"manage_key": rc.get("manage_keys") or [], "manage_key": (rc.get("manage_keys") if rc else []) or [],
}) })
if not ok: if not ok:
return JsonResponse({"ok": False, "message": "注册失败"}, status=500) return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"}) 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)

Binary file not shown.

View File

@@ -60,6 +60,7 @@ class RegistrationCodeDocument(Document):
code = fields.KeywordField() #具体值 code = fields.KeywordField() #具体值
keys = fields.KeywordField(multi=True) #对应的key keys = fields.KeywordField(multi=True) #对应的key
manage_keys = fields.KeywordField(multi=True) #对应的manage_key manage_keys = fields.KeywordField(multi=True) #对应的manage_key
created_at = fields.DateField() #创建时间
expires_at = fields.DateField() #过期时间 expires_at = fields.DateField() #过期时间
created_by = fields.LongField() #创建者id created_by = fields.LongField() #创建者id
class Django: class Django:

View File

@@ -197,6 +197,55 @@ def get_registration_code(code: str):
except Exception: except Exception:
return None 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): def get_doc_id(data):
""" """
根据数据内容生成唯一ID用于去重 根据数据内容生成唯一ID用于去重
@@ -681,7 +730,10 @@ def get_all_users():
users.append({ users.append({
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "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 return users
@@ -700,6 +752,9 @@ def get_user_by_id(user_id):
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "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 None return None
except Exception as e: except Exception as e:

View File

@@ -1,5 +1,5 @@
INDEX_NAME = "wordsearch2666661" INDEX_NAME = "wordsearch21"
USER_NAME = "users11111666789" USER_NAME = "users16"
ACHIEVEMENT_INDEX_NAME = INDEX_NAME ACHIEVEMENT_INDEX_NAME = INDEX_NAME
USER_INDEX_NAME = USER_NAME USER_INDEX_NAME = USER_NAME
GLOBAL_INDEX_NAME = "global11111111" GLOBAL_INDEX_NAME = "global11121"

View File

@@ -3,286 +3,58 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>数据管理</title> <title>数据管理</title>
<style> <style>
body { body{margin:0;font-family:sans-serif;background:#fafafa}
margin: 0; .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}
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; .user-id{text-align:center;margin-bottom:0}
background: #fafafa; .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)}
.sidebar { .main-content{margin-left:200px;padding:20px;color:#333}
position: fixed; .container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
top: 0; table{width:100%;border-collapse:collapse;margin-top:20px}
left: 0; th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
width: 180px; th{background:#f8f9fa;font-weight:600}
height: 100vh; img{max-width:120px;border:1px solid #eee;border-radius:6px;cursor:pointer}
background: #1e1e2e; .btn{padding:6px 10px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin:2px}
color: white; .btn-primary{background:#1677ff;color:#fff}
padding: 20px; .btn-danger{background:#ff4d4f;color:#fff}
box-shadow: 2px 0 5px rgba(0,0,0,0.1); .btn-secondary{background:#f0f0f0;color:#333}
z-index: 1000; .muted{color:#666;font-size:12px}
display: flex; .modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
flex-direction: column; .modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
align-items: center; 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}
.user-id { .search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
text-align: center; .search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
margin-bottom: 0px; .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}
.sidebar h3 { .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}
margin-top: 0; @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
font-size: 18px; @media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
color: #add8e6; .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}
text-align: center; .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}
margin-bottom: 20px; .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}
.navigation-links { .zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
width: 100%; .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}
margin-top: 60px; .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}
.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;
}
</style> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a> <a href="{% url 'main:home' %}">返回主页</a>
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -351,7 +123,13 @@
<!-- 图片放大模态框 --> <!-- 图片放大模态框 -->
<div id="imageModal" class="image-modal"> <div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span> <span class="image-modal-close">&times;</span>
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
<img class="image-modal-content" id="expandedImage"> <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> </div>
<script> <script>
@@ -379,12 +157,29 @@ const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const imageModal = document.getElementById('imageModal'); const imageModal = document.getElementById('imageModal');
const expandedImage = document.getElementById('expandedImage'); const expandedImage = document.getElementById('expandedImage');
const imageModalClose = document.querySelector('.image-modal-close'); 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 currentId = '';
let currentWriter = ''; let currentWriter = '';
let currentImage = ''; let currentImage = '';
let allDataCache = []; // 缓存所有数据,避免重复请求 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) { async function performSearch(type) {
@@ -394,6 +189,8 @@ async function performSearch(type) {
return; return;
} }
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
showSearchLoading(); showSearchLoading();
try { try {
@@ -452,6 +249,7 @@ function showSearchMessage(message, type = '') {
// 加载所有数据 // 加载所有数据
async function loadAllData() { async function loadAllData() {
currentSearchQuery = '';
showSearchLoading(); showSearchLoading();
try { try {
@@ -490,6 +288,7 @@ function displayAllData(data) {
function clearSearch() { function clearSearch() {
searchQueryInput.value = ''; searchQueryInput.value = '';
searchResultDiv.style.display = 'none'; searchResultDiv.style.display = 'none';
currentSearchQuery = '';
// 如果有缓存数据,显示全部 // 如果有缓存数据,显示全部
if (allDataCache.length > 0) { if (allDataCache.length > 0) {
@@ -696,15 +495,9 @@ async function saveEdit(){
alert('保存成功'); alert('保存成功');
closeModal(); closeModal();
// 重新加载数据以显示更新 // 重新加载数据以显示更新
if (searchResultDiv.style.display !== 'none') { if (currentSearchQuery) {
// 如果当前显示的是搜索结果,重新执行搜索 // 如果当前显示的是搜索结果,重新执行搜索
const query = searchQueryInput.value.trim(); performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
if (query) {
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
performSearch(isFuzzy ? 'fuzzy' : 'exact');
} else {
loadAllData();
}
} else { } else {
loadAllData(); loadAllData();
} }
@@ -714,8 +507,20 @@ async function saveEdit(){
} }
async function doDelete(id){ async function doDelete(id){
if (isDeleting) {
alert('正在处理删除操作,请稍候...');
return;
}
if(!confirm('确认删除该记录?此操作不可撤销')) return; if(!confirm('确认删除该记录?此操作不可撤销')) return;
isDeleting = true;
const deleteButton = document.querySelector(`button[onclick="doDelete('${id}')"]`);
if (deleteButton) {
deleteButton.disabled = true;
deleteButton.textContent = '删除中...';
}
try { try {
const response = await fetch(`/elastic/data/${id}/delete/`, { const response = await fetch(`/elastic/data/${id}/delete/`, {
method:'DELETE', method:'DELETE',
@@ -727,20 +532,32 @@ async function doDelete(id){
if(data.status!=='success') throw new Error(data.message || '删除失败'); if(data.status!=='success') throw new Error(data.message || '删除失败');
alert('删除成功'); alert('删除成功');
// 重新加载数据 // 清空缓存,确保下次加载获取最新数据
if (searchResultDiv.style.display !== 'none') { allDataCache = [];
const query = searchQueryInput.value.trim();
if (query) { // 根据当前显示状态重新加载数据
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊'); if (currentSearchQuery) {
performSearch(isFuzzy ? 'fuzzy' : 'exact'); // 如果当前显示的是搜索结果,重新执行搜索
} else { performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
loadAllData();
}
} else { } 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');
}
} }
} catch (e) { } catch (e) {
alert(e.message||'删除失败'); 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() { document.addEventListener('DOMContentLoaded', function() {
// 为所有图片添加点击事件监听器 // 为所有图片添加点击事件监听器
@@ -784,6 +625,9 @@ document.addEventListener('DOMContentLoaded', function() {
const imgSrc = e.target.src; const imgSrc = e.target.src;
expandedImage.src = imgSrc; expandedImage.src = imgSrc;
imageModal.style.display = 'block'; imageModal.style.display = 'block';
// 重置缩放状态
resetZoom();
} }
}); });
@@ -798,6 +642,91 @@ document.addEventListener('DOMContentLoaded', function() {
imageModal.style.display = 'none'; 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> </script>
</body> </body>

View File

@@ -6,7 +6,7 @@
<style> <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:#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 { 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; } .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, .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); } .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.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; } .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; } .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> </style>
{% csrf_token %} {% csrf_token %}
<script> <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 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); } function clearSelection(id){ const sel=document.getElementById(id); Array.from(sel.options).forEach(o=>o.selected=false); }
async function generateCode(){ async function generateCode(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const csrftoken=getCookie('csrftoken'); const csrftoken=getCookie('csrftoken');
const keys=selectedValues(document.getElementById('keys')); const keys=selectedValues(document.getElementById('keys'));
const manageKeys=selectedValues(document.getElementById('manageKeys')); const manageKeys=selectedValues(document.getElementById('manageKeys'));
@@ -64,19 +73,46 @@
const msg=document.getElementById('msg'); 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';} 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';} 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> </script>
</head> </head>
<body> <body>
<div id="overlay" class="overlay" style="display:none"><div class="spinner"></div></div>
<div class="sidebar"> <div class="sidebar">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
<div class="navigation-links"> <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> </div>
<div class="main"> <div class="main">
<div class="card"> <div class="card fade-in">
<h2>管理注册码</h2> <h2>管理注册码</h2>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@@ -113,7 +149,193 @@
</div> </div>
<div id="msg" class="notice"></div> <div id="msg" class="notice"></div>
<div class="code-box" id="codeOut"></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>
</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> </body>
</html> </html>

View File

@@ -4,282 +4,71 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>图片上传与识别</title> <title>图片上传与识别</title>
<style> <style>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0; /* 导航栏样式 */
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; .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;
background: #fafafa; 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 a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
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 a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 - 改进后的样式 */ /* 主内容区 - 改进后的样式 */
.main-content { .main-content {margin-left: 200px;padding: 20px;color: #333;}
margin-left: 200px; .container { max-width: 1200px;margin: 0 auto;background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);
padding: 20px; padding: 24px;}
color: #333; .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;}
.container { .upload-section { background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;padding: 32px; text-align: center;transition: all 0.3s ease;
max-width: 1200px; margin-bottom: 24px;}
margin: 0 auto; .upload-section:hover {border-color: #4f46e5; background: #f1f5f9; }
background: #fff; .upload-section.drag-over {border-color: #4f46e5; background: #e0e7ff; }
border-radius: 14px; .upload-section input[type="file"] {margin: 15px 0;}
box-shadow: 0 10px 24px rgba(31,35,40,0.08); .btn {padding: 10px 16px;border: none;border-radius: 8px;cursor: pointer;margin: 0 4px;font-size: 14px;transition: all 0.2s ease; }
padding: 24px; .btn-primary { background: #4f46e5; color: #fff; }
} .btn-primary:hover { background: #4338ca;}
.btn-secondary {background: #e2e8f0;color: #334155; }
.header { .btn-secondary:hover { background: #cbd5e1;}
display: flex; .btn-danger { background: #ef4444;color: #fff;}
align-items: center; .btn-danger:hover { background: #dc2626;}
justify-content: space-between; .preview-container {display: flex; gap: 24px; margin: 24px 0;}
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) { @media (max-width: 768px) {
.preview-container { .preview-container {flex-direction: column;}
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-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> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a> <a href="{% url 'main:home' %}">返回主页</a>
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -305,6 +94,10 @@
<button type="submit" class="btn btn-primary">上传并识别</button> <button type="submit" class="btn btn-primary">上传并识别</button>
</form> </form>
<div class="status-message" id="uploadMsg"></div> <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>
<div class="preview-container"> <div class="preview-container">
@@ -351,9 +144,51 @@ const kvForm = document.getElementById('kvForm');
const addFieldBtn = document.getElementById('addFieldBtn'); const addFieldBtn = document.getElementById('addFieldBtn');
const syncFromTextBtn = document.getElementById('syncFromTextBtn'); const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const dropArea = document.getElementById('dropArea'); const dropArea = document.getElementById('dropArea');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let currentImageRel = ''; 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 => { ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false); dropArea.addEventListener(eventName, preventDefaults, false);
@@ -504,20 +339,39 @@ uploadForm.addEventListener('submit', async (e) => {
return; 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(); const formData = new FormData();
formData.append('file', file); formData.append('file', jpegFile);
try { try {
let prog = 50;
setProgress(prog, '识别中');
const timer = setInterval(() => {
prog = Math.min(95, prog + 1);
setProgress(prog, '识别中');
}, 120);
const resp = await fetch('/elastic/upload/', { const resp = await fetch('/elastic/upload/', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' }, headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
body: formData, body: formData,
}); });
clearInterval(timer);
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || data.status !== 'success') { if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '上传识别失败'); throw new Error(data.message || '上传识别失败');
} }
setProgress(100, '识别完成');
uploadMsg.textContent = data.message || '识别成功'; uploadMsg.textContent = data.message || '识别成功';
uploadMsg.className = 'status-message success'; uploadMsg.className = 'status-message success';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
@@ -525,10 +379,12 @@ uploadForm.addEventListener('submit', async (e) => {
renderFormFromObject(data.data || {}); renderFormFromObject(data.data || {});
currentImageRel = data.image; currentImageRel = data.image;
confirmBtn.disabled = false; confirmBtn.disabled = false;
setTimeout(hideProgress, 800);
} catch (e) { } catch (e) {
uploadMsg.textContent = e.message || '发生错误'; uploadMsg.textContent = e.message || '发生错误';
uploadMsg.className = 'status-message error'; uploadMsg.className = 'status-message error';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
progressText.textContent = '识别失败';
} }
}); });
@@ -596,4 +452,4 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,70 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>用户管理</title> <title>用户管理</title>
<style> <style>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */ /* 导航栏样式 */
.sidebar { .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;
position: fixed; flex-direction: column;align-items: center;}
top: 0; .user-id {text-align: center;margin-bottom: 0px;}
left: 0; .sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
width: 180px; .navigation-links {width: 100%;margin-top: 60px;}
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 a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
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 a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */ /* 主内容区 */
.main-content { .main-content {
margin-left: 200px; margin-left: 200px;
@@ -258,30 +206,59 @@
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a> <a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">返回主页</a>
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
</div> </div>
<!-- 主内容区域 -->
<div class="main-content"> <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="card">
<div class="header"> <div class="header">
<h2>用户管理</h2> <h2>用户管理</h2>
<button id="addUserBtn" class="btn btn-primary">添加用户</button> {% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
</div> </div>
<div class="notification success" id="successNotification"> <div class="notification success" id="successNotification">操作成功!</div>
操作成功! <div class="notification error" id="errorNotification">操作失败!</div>
</div>
<div class="notification error" id="errorNotification">
操作失败!
</div>
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="搜索用户名..."> <input type="text" id="searchInput" placeholder="搜索用户名...">
@@ -299,12 +276,11 @@
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="usersTableBody"> <tbody id="usersTableBody"></tbody>
<!-- 用户数据将通过JavaScript加载 -->
</tbody>
</table> </table>
</div> </div>
</div> </div>
{% endif %}
</div> </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); document.getElementById('userForm').addEventListener('submit', saveUser);
@@ -575,15 +554,21 @@
}); });
}); });
document.getElementById('searchBtn').addEventListener('click', function() { const searchBtn = document.getElementById('searchBtn');
const searchTerm = document.getElementById('searchInput').value; if (searchBtn) {
loadUsers(searchTerm); searchBtn.addEventListener('click', function() {
}); const searchTerm = document.getElementById('searchInput').value;
loadUsers(searchTerm);
});
}
document.getElementById('resetBtn').addEventListener('click', function() { const resetBtn = document.getElementById('resetBtn');
document.getElementById('searchInput').value = ''; if (resetBtn) {
loadUsers(); resetBtn.addEventListener('click', function() {
}); document.getElementById('searchInput').value = '';
loadUsers();
});
}
// 点击模态框外部关闭模态框 // 点击模态框外部关闭模态框
window.addEventListener('click', function(event) { window.addEventListener('click', function(event) {
@@ -624,7 +609,34 @@
// 页面加载时获取用户列表 // 页面加载时获取用户列表
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadUsers(); 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();
}
}); });
// 为表格中的编辑和删除按钮添加事件监听器 // 为表格中的编辑和删除按钮添加事件监听器

View File

@@ -36,6 +36,8 @@ urlpatterns = [
path('registration-codes/keys/', views.get_keys_list_view, name='get_keys_list'), 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/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/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'), path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),

View File

@@ -222,16 +222,27 @@ def add_user(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def get_users(request): 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) return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try: 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() q = (request.GET.get("search") or "").strip()
users = get_all_users() 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: if q:
users = [u for u in users if q in str(u.get("username", ""))] filtered = [u for u in filtered if q in str(u.get("username", ""))]
return JsonResponse({"status": "success", "data": users}) return JsonResponse({"status": "success", "data": filtered})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -239,10 +250,9 @@ def get_users(request):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
def update_user_by_id_view(request, user_id): 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) return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try: try:
payload = json.loads(request.body.decode("utf-8")) payload = json.loads(request.body.decode("utf-8"))
except Exception: except Exception:
@@ -250,21 +260,41 @@ def update_user_by_id_view(request, user_id):
new_username = (payload.get("username") or "").strip() new_username = (payload.get("username") or "").strip()
new_permission = payload.get("permission") new_permission = payload.get("permission")
new_password = (payload.get("password") or "").strip() new_password = (payload.get("password") or "").strip()
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: if new_password and len(new_password) < 6:
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400) return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
ok = es_update_user_by_id(
user_id, is_admin = int(request.session.get("permission", 1)) == 0
username=new_username if new_username else None, requester = get_user_by_id(uid) or {}
permission=int(new_permission) if new_permission is not None else None, target = get_user_by_id(user_id) or {}
password=new_password if new_password else None, requester_mgr = set(requester.get("manage_key") or [])
) target_keys = set(target.get("key") or [])
if not ok:
return JsonResponse({"status": "error", "message": "用户更新失败"}, status=500) if is_admin:
return JsonResponse({"status": "success", "message": "用户更新成功"}) 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)
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,
)
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"]) @require_http_methods(["POST"])
@csrf_protect @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 response_text = chat_completion.choices[0].message.content
@@ -598,11 +628,16 @@ def user_manage(request):
if session_user_id is None: if session_user_id is None:
from django.shortcuts import redirect from django.shortcuts import redirect
return redirect("/accounts/login/") return redirect("/accounts/login/")
if int(request.session.get("permission", 1)) != 0: is_admin = int(request.session.get("permission", 1)) == 0
from django.shortcuts import redirect me = get_user_by_id(session_user_id) or {}
return redirect("/main/home/") has_manage = bool(me.get("manage_key"))
user_id_qs = request.GET.get("user_id") 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) return render(request, "elastic/users.html", context)
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -668,3 +703,31 @@ def generate_registration_code_view(request):
if not result: if not result:
return JsonResponse({"status": "error", "message": "生成失败"}, status=500) return JsonResponse({"status": "error", "message": "生成失败"}, status=500)
return JsonResponse({"status": "success", "data": result}) 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"})

View File

@@ -6,148 +6,47 @@
<title>数据管理系统</title> <title>数据管理系统</title>
<script src="{% static 'vendor/echarts.min.js' %}"></script> <script src="{% static 'vendor/echarts.min.js' %}"></script>
<style> <style>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */ /* 导航栏样式 */
.sidebar { .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;
position: fixed; flex-direction: column;align-items: center;}
top: 0; .user-id {text-align: center;margin-bottom: 0px;}
left: 0; .sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
width: 180px; .navigation-links {width: 100%;margin-top: 60px;}
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 a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
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 a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */ /* 主内容区 */
.main-content { .main-content {margin-left: 200px;padding: 20px;color: #333;}
margin-left: 200px; .card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
padding: 20px; .grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
color: #333; .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; }
.card { .legend {display: flex;gap: 12px;align-items: center;}
background: #fff; .legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
border-radius: 14px; .muted {color: #6b7280;font-size: 12px;}
box-shadow: 0 10px 24px rgba(31,35,40,0.08); .btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
padding: 20px; .btn-primary {background: #4f46e5;color: #fff;}
}
.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> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a> <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:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</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> <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> <a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
{% endif %} {% endif %}
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -157,7 +56,7 @@
<div class="main-content"> <div class="main-content">
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h2>主页</h2> <h2>师生共创系统</h2>
<span class="badge">用户:{{ user_id }}</span> <span class="badge">用户:{{ user_id }}</span>
</div> </div>
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div> <div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>