34 Commits

Author SHA1 Message Date
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
42bacbbc81 添加 README.md 2025-11-17 18:23:51 +08:00
32ff920921 注册码生成以及用户注册 2025-11-17 18:03:13 +08:00
6e332f248f 更新依赖列表 2025-11-17 17:03:30 +08:00
1392275337 补充漏推送的东西 2025-11-17 16:22:47 +08:00
f93286a5fe 修改登录逻辑,使用RSA-OAEP 包裹每会话独立 AES-GCM 密钥 + 加密提交凭据 2025-11-17 15:33:40 +08:00
dc57d88779 确定用户的数据结构修改 2025-11-17 15:05:35 +08:00
9665e81698 Merge remote-tracking branch 'origin/Django' into Django 2025-11-17 14:16:30 +08:00
7afc6ba06b 修复普通用户无法进入数据管理的问题 2025-11-17 14:12:36 +08:00
23 changed files with 1724 additions and 961 deletions

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

@@ -0,0 +1,115 @@
name: CI
on:
push:
branches:
- Django
workflow_dispatch:
inputs:
version:
description: 版本号(如 0.2.2),为空则自动生成
required: false
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
docker-ci:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
runs-on: ubuntu-latest
timeout-minutes: 40
env:
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
DJANGO_DEBUG: "False"
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
IMAGE_NAME: achievement_inputing_ci
ALIST_URL: https://alist.spdis.top
ALIST_USER: ${{ secrets.ALIST_USER }}
ALIST_PASSWORD: ${{ secrets.ALIST_PASSWORD }}
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: Upload to Alist
run: |
set -e
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
BASE="${ALIST_URL%/}"
curl -sS -o "$GITHUB_WORKSPACE/login.json" -w "%{http_code}" -X POST "$BASE/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"$ALIST_USER\",\"password\":\"$ALIST_PASSWORD\"}" > "$GITHUB_WORKSPACE/login.code"
if [ "$(cat "$GITHUB_WORKSPACE/login.code")" != "200" ]; then
echo login_failed
cat "$GITHUB_WORKSPACE/login.json"
exit 1
fi
TOKEN=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
if [ -z "$TOKEN" ]; then
TOKEN=$(sed -n 's/.*"auth":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
fi
if [ -z "$TOKEN" ]; then
echo token_not_found
cat "$GITHUB_WORKSPACE/login.json"
exit 1
fi
curl -sS -X POST "$BASE/api/fs/mkdir" -H "Authorization: $TOKEN" -H "Content-Type: application/json" -d "{\"path\":\"/ci\"}" >/dev/null 2>&1 || true
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
if [ "$UPLOAD_STATUS" -ge 400 ]; then
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: Bearer $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
fi
if [ "$UPLOAD_STATUS" -ge 400 ]; then
echo upload_failed
exit 1
fi
- 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"
DL="${ALIST_URL%/}/ci/$ART"
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

@@ -33,6 +33,7 @@ ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').sp
# Application definition
INSTALLED_APPS = [
'django_browser_reload',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -49,6 +50,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django_browser_reload.middleware.BrowserReloadMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',

View File

@@ -21,6 +21,7 @@ from django.conf.urls.static import static
from main.views import home as main_home
urlpatterns = [
path("__reload__/", include("django_browser_reload.urls")),
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls', namespace='accounts')),
path('main/', include('main.urls', namespace='main')),

1
README.md Normal file
View File

@@ -0,0 +1 @@
python manage.py shell -c "from elastic.es_connect import create_index_with_mapping; create_index_with_mapping()"

View File

@@ -1,5 +1,23 @@
import hashlib
import hmac
import os
import base64
from typing import Tuple
try:
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
except Exception:
rsa = None
padding = None
serialization = None
hashes = None
Cipher = None
algorithms = None
modes = None
default_backend = None
def salt_for_username(username: str) -> bytes:
@@ -17,4 +35,81 @@ def derive_password(password_plain: str, salt: bytes, iterations: int = 100_000,
def hmac_sha256(key: bytes, message: bytes) -> bytes:
"""Compute HMAC-SHA256 signature for the given message using key bytes."""
return hmac.new(key, message, hashlib.sha256).digest()
return hmac.new(key, message, hashlib.sha256).digest()
_RSA_PRIVATE = None
_RSA_PUBLIC = None
def _ensure_rsa_keys():
global _RSA_PRIVATE, _RSA_PUBLIC
if _RSA_PRIVATE is None:
if rsa is None:
raise RuntimeError("cryptography library is required for RSA operations")
_RSA_PRIVATE = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_RSA_PUBLIC = _RSA_PRIVATE.public_key()
def get_public_key_spki_b64() -> str:
_ensure_rsa_keys()
spki = _RSA_PUBLIC.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
return base64.b64encode(spki).decode('ascii')
def rsa_oaep_decrypt_b64(ciphertext_b64: str) -> bytes:
_ensure_rsa_keys()
ct = base64.b64decode(ciphertext_b64)
return _RSA_PRIVATE.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))
def aes_gcm_decrypt_b64(key_bytes: bytes, iv_b64: str, ciphertext_b64: str) -> bytes:
if Cipher is None:
raise RuntimeError("cryptography library is required for AES operations")
iv = base64.b64decode(iv_b64)
data = base64.b64decode(ciphertext_b64)
if len(data) < 16:
raise ValueError("ciphertext too short")
ct = data[:-16]
tag = data[-16:]
decryptor = Cipher(algorithms.AES(key_bytes), modes.GCM(iv, tag), backend=default_backend()).decryptor()
pt = decryptor.update(ct) + decryptor.finalize()
return pt
def gen_salt(length: int = 16) -> bytes:
return os.urandom(length)
def hash_password_with_salt(password_plain: str, salt: bytes, iterations: int = 200_000, dklen: int = 32) -> bytes:
return hashlib.pbkdf2_hmac('sha256', password_plain.encode('utf-8'), salt, iterations, dklen=dklen)
def hash_password_random_salt(password_plain: str) -> Tuple[str, str]:
salt = gen_salt(16)
h = hash_password_with_salt(password_plain, salt)
return base64.b64encode(salt).decode('ascii'), base64.b64encode(h).decode('ascii')
def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool:
try:
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
actual = hash_password_with_salt(password_plain, salt)
return hmac.compare_digest(actual, expected)
except Exception:
return False
def generate_rsa_private_pem_b64() -> str:
if rsa is None or serialization is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = priv.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
return base64.b64encode(pem).decode('ascii')
def public_spki_b64_from_private_pem_b64(private_pem_b64: str) -> str:
if serialization is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
pub = priv.public_key()
spki = pub.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
return base64.b64encode(spki).decode('ascii')
def rsa_oaep_decrypt_b64_with_private_pem(private_pem_b64: str, ciphertext_b64: str) -> bytes:
if serialization is None or padding is None or hashes is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
ct = base64.b64decode(ciphertext_b64)
return priv.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))

View File

@@ -1,19 +1,14 @@
import base64
from elastic.es_connect import get_user_by_username as es_get_user_by_username
from .crypto import salt_for_username, derive_password
def get_user_by_username(username: str):
"""
期望ES中存储的是明文密码登录时按用户名盐派生后对nonce做HMAC验证。
"""
es_user = es_get_user_by_username(username)
if es_user:
salt = salt_for_username(username)
derived = derive_password(es_user.get('password', ''), salt)
return {
'user_id': es_user.get('user_id', 0),
'username': es_user.get('username', ''),
'password': base64.b64encode(derived).decode('ascii'),
'password_hash': es_user.get('password_hash'),
'password_salt': es_user.get('password_salt'),
'permission': es_user.get('permission', 1),
}
return None

View File

@@ -1,64 +1,53 @@
// Utility: read cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Convert base64 string to ArrayBuffer
function base64ToArrayBuffer(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// ArrayBuffer to base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
async function deriveKey(password, saltBytes, iterations = 100000, length = 32) {
const encoder = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits']
);
const derivedBits = await window.crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations,
hash: 'SHA-256'
},
keyMaterial,
length * 8
);
return new Uint8Array(derivedBits);
async function importRsaPublicKey(spkiBytes) {
return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
}
async function hmacSha256(keyBytes, messageBytes) {
const key = await window.crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const signature = await window.crypto.subtle.sign('HMAC', key, messageBytes);
return new Uint8Array(signature);
async function rsaOaepEncrypt(publicKey, dataBytes) {
const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, dataBytes);
return new Uint8Array(encrypted);
}
async function importAesKey(keyBytes) {
return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
}
async function aesGcmEncrypt(aesKey, ivBytes, dataBytes) {
const ct = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, aesKey, dataBytes);
return new Uint8Array(ct);
}
let needCaptcha = false;
async function loadCaptcha() {
const csrftoken = getCookie('csrftoken');
const resp = await fetch('/accounts/captcha/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
const data = await resp.json();
if (resp.ok && data.ok) {
const img = document.getElementById('captchaImg');
const box = document.getElementById('captchaBox');
img.src = 'data:image/png;base64,' + data.image_b64;
box.style.display = 'block';
}
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
@@ -68,53 +57,70 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) {
errorEl.textContent = '请输入账户与密码';
return;
}
if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; }
const btn = document.getElementById('loginBtn');
btn.disabled = true;
try {
// Step 1: get challenge (nonce + salt)
const csrftoken = getCookie('csrftoken');
const chalResp = await fetch('/accounts/challenge/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username })
const pkResp = await fetch('/accounts/pubkey/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
if (!pkResp.ok) throw new Error('获取公钥失败');
const pkJson = await pkResp.json();
const spkiBytes = new Uint8Array(base64ToArrayBuffer(pkJson.public_key_spki));
const pubKey = await importRsaPublicKey(spkiBytes);
const aesKeyRaw = new Uint8Array(32); window.crypto.getRandomValues(aesKeyRaw);
const encAesKey = await rsaOaepEncrypt(pubKey, aesKeyRaw);
const encAesKeyB64 = arrayBufferToBase64(encAesKey);
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 })
});
if (!chalResp.ok) {
throw new Error('获取挑战失败');
const setKeySnapshot = await (async () => {
const clone = setKeyResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await setKeyResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!setKeySnapshot.parsed) {
const msg = (setKeySnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
throw new Error(mapped);
}
const chal = await chalResp.json();
const nonceBytes = new Uint8Array(base64ToArrayBuffer(chal.nonce));
const saltBytes = new Uint8Array(base64ToArrayBuffer(chal.salt));
const setKeyJson = setKeySnapshot.parsed;
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
// Step 2: derive secret and compute HMAC
const derived = await deriveKey(password, saltBytes, 100000, 32);
const hmac = await hmacSha256(derived, nonceBytes);
const hmacB64 = arrayBufferToBase64(hmac);
const aesKey = await importAesKey(aesKeyRaw);
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
const obj = { username, password };
if (needCaptcha) obj.captcha = (document.getElementById('captcha').value || '').trim();
const payload = new TextEncoder().encode(JSON.stringify(obj));
const ct = await aesGcmEncrypt(aesKey, iv, payload);
const ctB64 = arrayBufferToBase64(ct);
const ivB64 = arrayBufferToBase64(iv);
// Step 3: submit login with username and hmac
const submitResp = await fetch('/accounts/login/submit/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username, hmac: hmacB64 })
const submitResp = await fetch('/accounts/login/secure-submit/', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
});
const submitJson = await submitResp.json();
const submitSnapshot = await (async () => {
const clone = submitResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await submitResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!submitSnapshot.parsed) {
const msg = (submitSnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
throw new Error(mapped);
}
const submitJson = submitSnapshot.parsed;
if (!submitResp.ok || !submitJson.ok) {
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
throw new Error(submitJson.message || '登录失败');
}
// Redirect to home with user_id
window.location.href = submitJson.redirect_url;
} catch (err) {
console.error(err);
@@ -122,4 +128,9 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
} finally {
btn.disabled = false;
}
});
document.getElementById('refreshCaptcha').addEventListener('click', async () => {
needCaptcha = true;
await loadCaptcha();
});

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); }
h1 { font-size: 20px; margin: 0 0 16px; }
label { display: block; margin: 12px 0 6px; color: #333; }
input { width: 100%; padding: 10px 12px; border: 1px solid #dcdde1; border-radius: 6px; }
input { width: 100%; padding: 10px 0px; border: 1px solid #dcdde1; border-radius: 6px; }
button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
button:disabled { background: #9bbcf0; cursor: not-allowed; }
.error { color: #d93025; margin-top: 10px; min-height: 20px; }
@@ -30,9 +30,22 @@
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<div id="captchaBox" style="display:none; margin-top:12px;">
<label for="captcha">验证码</label>
<div style="display:flex; gap:8px; align-items:center;">
<input id="captcha" name="captcha" type="text" autocomplete="off" style="flex:1;" />
<img id="captchaImg" alt="验证码" style="height:40px; border:1px solid #dcdde1; border-radius:6px;" />
<button id="refreshCaptcha" type="button" style="width:auto;">刷新</button>
</div>
</div>
<button id="loginBtn" type="submit">登录</button>
<div id="error" class="error"></div>
</form>
<div class="hint" style="text-align:center; margin-top:12px;">
还没有账号?
<a href="/accounts/register/" style="color:#2d8cf0; text-decoration:none;">去注册</a>
</div>
</div>
<script src="{% static 'accounts/login.js' %}"></script>

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户注册</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #f5f6fa; }
.container { max-width: 400px; margin: 10vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
h1 { font-size: 20px; margin: 0 0 16px; }
label { display:block; margin: 12px 0 6px; color:#333; }
input { width:100%; padding:10px 0px; border:1px solid #dcdde1; border-radius:6px; }
button { width:100%; margin-top:16px; padding:10px 12px; background:#2d8cf0; color:#fff; border:none; border-radius:6px; cursor:pointer; }
button:disabled { background:#9bbcf0; cursor:not-allowed; }
.error { color:#d93025; margin-top:10px; min-height:20px; }
.hint { color:#888; font-size:12px; margin-top:10px; }
</style>
</head>
<body>
<div class="container">
<h1>注册新用户</h1>
<form id="regForm">
{% csrf_token %}
<label for="code">注册码(选填)</label>
<input id="code" name="code" type="text" />
<label for="email">邮箱</label>
<input id="email" name="email" type="email" required />
<button id="sendCodeBtn" type="button">发送验证码</button>
<div id="sendMsg" class="hint"></div>
<label for="email_code">邮箱验证码</label>
<input id="email_code" name="email_code" type="text" required />
<label for="username">用户名</label>
<input id="username" name="username" type="text" required />
<label for="password">密码</label>
<input id="password" name="password" type="password" required />
<label for="confirm">确认密码</label>
<input id="confirm" name="confirm" type="password" required />
<button id="regBtn" type="submit">注册</button>
<div id="error" class="error"></div>
</form>
<div class="hint">有注册码请填写,否则可留空</div>
</div>
<script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
document.getElementById('regForm').addEventListener('submit',async(e)=>{
e.preventDefault();
const err=document.getElementById('error'); err.textContent='';
const code=(document.getElementById('code').value||'').trim();
const email=(document.getElementById('email').value||'').trim();
const username=(document.getElementById('username').value||'').trim();
const email_code=(document.getElementById('email_code').value||'').trim();
const password=document.getElementById('password').value||'';
const confirm=document.getElementById('confirm').value||'';
if(!email||!email_code||!username||!password){err.textContent='请填写所有必填字段';return;}
if(password!==confirm){err.textContent='两次密码不一致';return;}
const btn=document.getElementById('regBtn'); btn.disabled=true;
try{
const csrftoken=getCookie('csrftoken');
const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,email_code,username,password})});
const data=await resp.json();
if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');}
window.location.href=data.redirect_url;
}catch(e){err.textContent=e.message||'发生错误';}
finally{btn.disabled=false;}
});
document.getElementById('sendCodeBtn').addEventListener('click',async()=>{
const email=(document.getElementById('email').value||'').trim();
const msg=document.getElementById('sendMsg');
msg.textContent='';
if(!email){msg.textContent='请输入邮箱';return;}
const btn=document.getElementById('sendCodeBtn'); btn.disabled=true;
try{
const csrftoken=getCookie('csrftoken');
const resp=await fetch('/accounts/email/send-code/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({email})});
const data=await resp.json();
if(!resp.ok||!data.ok){throw new Error(data.message||'发送失败');}
msg.textContent='验证码已发送,请查收邮件';
}catch(e){msg.textContent=e.message||'发送失败';}
finally{btn.disabled=false;}
});
</script>
</body>
</html>

View File

@@ -4,7 +4,12 @@ app_name = "accounts"
urlpatterns = [
path("login/", views.login_page, name="login"),
path("challenge/", views.challenge, name="challenge"),
path("login/submit/", views.login_submit, name="login_submit"),
path("pubkey/", views.pubkey, name="pubkey"),
path("captcha/", views.captcha, name="captcha"),
path("session-key/", views.set_session_key, name="set_session_key"),
path("login/secure-submit/", views.secure_login_submit, name="secure_login_submit"),
path("logout/", views.logout, name="logout"),
path("register/", views.register_page, name="register"),
path("register/submit/", views.register_submit, name="register_submit"),
path("email/send-code/", views.send_email_code, name="send_email_code"),
]

View File

@@ -1,7 +1,11 @@
import base64
import json
import os
import hmac
import io
import random
import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect
@@ -10,7 +14,8 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from django.conf import settings
from .es_client import get_user_by_username
from .crypto import salt_for_username, hmac_sha256
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
@require_http_methods(["GET"])
@@ -19,90 +24,108 @@ def login_page(request):
return render(request, "accounts/login.html")
@require_http_methods(["POST"])
@csrf_protect
def challenge(request):
@require_http_methods(["GET"])
@ensure_csrf_cookie
def pubkey(request):
pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
pem_b64 = generate_rsa_private_pem_b64()
request.session["rsa_private_pem_b64"] = pem_b64
pk_b64 = public_spki_b64_from_private_pem_b64(pem_b64)
return JsonResponse({"public_key_spki": pk_b64})
@require_http_methods(["GET"])
@ensure_csrf_cookie
def captcha(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
username = payload.get("username", "").strip()
if not username:
return HttpResponseBadRequest("Username required")
# Generate nonce and compute per-username salt
nonce = os.urandom(16)
salt = salt_for_username(username)
# Persist challenge in session to prevent replay with mismatched user
request.session["challenge_nonce"] = base64.b64encode(nonce).decode("ascii")
request.session["challenge_username"] = username
return JsonResponse({
"nonce": base64.b64encode(nonce).decode("ascii"),
"salt": base64.b64encode(salt).decode("ascii"),
})
from captcha.image import ImageCaptcha
except Exception:
return JsonResponse({"ok": False, "message": "captcha unavailable"}, status=500)
code = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(5))
request.session["captcha_code"] = code
img = ImageCaptcha(width=160, height=60)
image = img.generate_image(code)
buf = io.BytesIO()
image.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return JsonResponse({"ok": True, "image_b64": b64})
@require_http_methods(["POST"])
@csrf_protect
def login_submit(request):
def set_session_key(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
username = payload.get("username", "").strip()
client_hmac_b64 = payload.get("hmac", "")
if not username or not client_hmac_b64:
enc_key_b64 = payload.get("encrypted_key", "")
if not enc_key_b64:
return HttpResponseBadRequest("Missing fields")
try:
pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
return HttpResponseBadRequest("Decrypt error")
key_bytes = rsa_oaep_decrypt_b64_with_private_pem(pem_b64, enc_key_b64)
except Exception:
return HttpResponseBadRequest("Decrypt error")
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
return JsonResponse({"ok": True})
# Validate challenge stored in session
session_username = request.session.get("challenge_username")
nonce_b64 = request.session.get("challenge_nonce")
if not session_username or not nonce_b64 or session_username != username:
return HttpResponseBadRequest("Challenge not found or mismatched user")
# Lookup user in ES (placeholder)
@require_http_methods(["POST"])
@csrf_protect
def secure_login_submit(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
iv_b64 = payload.get("iv", "")
ct_b64 = payload.get("ciphertext", "")
if not iv_b64 or not ct_b64:
return HttpResponseBadRequest("Missing fields")
key_b64 = request.session.get("session_enc_key_b64")
if not key_b64:
return HttpResponseBadRequest("Session key missing")
try:
key_bytes = base64.b64decode(key_b64)
pt = aes_gcm_decrypt_b64(key_bytes, iv_b64, ct_b64)
obj = json.loads(pt.decode("utf-8"))
except Exception:
return HttpResponseBadRequest("Decrypt error")
username = (obj.get("username") or "").strip()
password = (obj.get("password") or "")
if not username or not password:
return HttpResponseBadRequest("Missing credentials")
if bool(request.session.get("login_failed_once")):
ans = (obj.get("captcha") or "").strip()
code = request.session.get("captcha_code")
if not ans or not code or ans.lower() != str(code).lower():
return JsonResponse({"ok": False, "message": "验证码错误", "captcha_required": True}, status=401)
user = get_user_by_username(username)
if not user:
return JsonResponse({"ok": False, "message": "User not found"}, status=401)
# Server-side HMAC verification
try:
nonce = base64.b64decode(nonce_b64)
stored_derived_b64 = user.get("password", "")
stored_derived = base64.b64decode(stored_derived_b64)
server_hmac_b64 = base64.b64encode(hmac_sha256(stored_derived, nonce)).decode("ascii")
except Exception:
return HttpResponseBadRequest("Verification error")
if not hmac.compare_digest(server_hmac_b64, client_hmac_b64):
return JsonResponse({"ok": False, "message": "Invalid credentials"}, status=401)
# Successful login: rotate session key and set user session
request.session["login_failed_once"] = True
return JsonResponse({"ok": False, "message": "用户不存在", "captcha_required": True}, status=401)
if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""):
request.session["login_failed_once"] = True
return JsonResponse({"ok": False, "message": "账户或密码错误", "captcha_required": True}, status=401)
try:
request.session.cycle_key()
except Exception:
pass
request.session["user_id"] = user["user_id"]
request.session["username"] = user["username"]
try:
request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1
except Exception:
request.session["permission"] = 1
# Clear challenge to prevent reuse
for k in ("challenge_username", "challenge_nonce"):
if k in request.session:
del request.session[k]
return JsonResponse({
"ok": True,
"redirect_url": f"/main/home/?user_id={user['user_id']}",
})
if "session_enc_key_b64" in request.session:
del request.session["session_enc_key_b64"]
if "rsa_private_pem_b64" in request.session:
del request.session["rsa_private_pem_b64"]
if "login_failed_once" in request.session:
del request.session["login_failed_once"]
if "captcha_code" in request.session:
del request.session["captcha_code"]
return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})
@require_http_methods(["GET"])
@@ -146,4 +169,133 @@ def logout(request):
except Exception:
pass
return resp
return resp
@require_http_methods(["GET"])
@ensure_csrf_cookie
def register_page(request):
return render(request, "accounts/register.html")
@require_http_methods(["POST"])
@csrf_protect
def register_submit(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip()
email = (payload.get("email") or "").strip()
email_code = (payload.get("email_code") or "").strip()
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "")
if not email or not email_code or not username or not password:
return HttpResponseBadRequest("Missing fields")
v = request.session.get("email_verify") or {}
if (v.get("email") or "") != email:
return JsonResponse({"ok": False, "message": "请先验证邮箱"}, status=400)
try:
exp_ts = int(v.get("expires_at") or 0)
except Exception:
exp_ts = 0
if exp_ts < int(time.time()):
return JsonResponse({"ok": False, "message": "验证码已过期"}, status=400)
if (v.get("code") or "") != email_code:
return JsonResponse({"ok": False, "message": "邮箱验证码错误"}, status=400)
rc = None
if code:
rc = get_registration_code(code)
if not rc:
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
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)
if existing:
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
users = es_get_all_users()
next_id = (max([int(u.get("user_id", 0)) for u in users]) + 1) if users else 1
ok = write_user_data({
"user_id": next_id,
"username": username,
"password": password,
"permission": 1,
"email": email,
"key": (rc.get("keys") if rc else []) or [],
"manage_key": (rc.get("manage_keys") if rc else []) or [],
})
if not ok:
return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
try:
if "email_verify" in request.session:
del request.session["email_verify"]
except Exception:
pass
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
@require_http_methods(["POST"])
@csrf_protect
def send_email_code(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
email = (payload.get("email") or "").strip()
if not email:
return HttpResponseBadRequest("Missing email")
if "@" not in email:
return JsonResponse({"ok": False, "message": "邮箱格式不正确"}, status=400)
verify_code = "".join(random.choice(string.digits) for _ in range(6))
ttl = int(os.environ.get("SMTP_CODE_TTL", "600") or 600)
request.session["email_verify"] = {"email": email, "code": verify_code, "expires_at": int(time.time()) + max(60, ttl)}
ok, msg = _send_smtp_email(email, verify_code)
if not ok:
return JsonResponse({"ok": False, "message": msg or "验证码发送失败"}, status=500)
return JsonResponse({"ok": True})
def _send_smtp_email(to_email: str, code: str):
host = os.environ.get("SMTP_HOST", "")
port_raw = os.environ.get("SMTP_PORT", "")
try:
port = int(port_raw) if port_raw else 0
except Exception:
port = 0
user = os.environ.get("SMTP_USERNAME") or os.environ.get("SMTP_USER") or ""
password = os.environ.get("SMTP_PASSWORD", "")
use_tls = str(os.environ.get("SMTP_USE_TLS", "")).lower() in ("1", "true", "yes")
use_ssl = str(os.environ.get("SMTP_USE_SSL", "")).lower() in ("1", "true", "yes")
sender = os.environ.get("SMTP_FROM_EMAIL") or os.environ.get("SMTP_FROM") or user or ""
subject = os.environ.get("SMTP_SUBJECT") or "邮箱验证码"
if not host or not port or not sender:
return False, "缺少SMTP配置"
body = f"您的验证码是:{code}10分钟内有效。"
msg = f"From: {sender}\r\nTo: {to_email}\r\nSubject: {subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{body}"
try:
if use_ssl:
server = smtplib.SMTP_SSL(host, port)
else:
server = smtplib.SMTP(host, port)
server.ehlo()
if use_tls and not use_ssl:
server.starttls()
server.ehlo()
if user and password:
server.login(user, password)
server.sendmail(sender, [to_email], msg.encode("utf-8"))
try:
server.quit()
except Exception:
try:
server.close()
except Exception:
pass
return True, ""
except Exception as e:
return False, str(e)

Binary file not shown.

View File

@@ -34,9 +34,14 @@ class UserDocument(Document):
"""用户数据文档映射"""
user_id = fields.LongField()
username = fields.KeywordField()
password = fields.KeywordField()
permission = fields.IntegerField()
email = fields.KeywordField()
password_hash = fields.KeywordField()
password_salt = fields.KeywordField()
permission = fields.IntegerField() # 还是2种权限0为管理员1为用户区别在于0有全部权限1在数据管理页面有搜索框但是索引到的录入信息要根据其用户id查询其key若其中之一与用户的manage_key字段匹配就显示否则不显示
key = fields.KeywordField(multi=True) #表示该用户的关键字举个例子学生A的key为"2024届人工智能1班","2024届""计算机与人工智能学院" 班导师B的key为"计算机与人工智能学院"
manage_key = fields.KeywordField(multi=True) #表示该用户管理的关键字非管理员班导师B的manage_key为"2024届人工智能1班"
#那么学生A就可以在数据管理页面搜索到自己的获奖数据而班导师B就可以在数据管理页面搜索到所有人工智能1班的获奖数据。也就是说学生A和班导师B都其实只有用户权限
class Django:
model = User
# fields列表应该只包含需要特殊处理的字段或者可以完全省略
@@ -45,6 +50,18 @@ class UserDocument(Document):
@GLOBAL_INDEX.doc_type
class GlobalDocument(Document):
type_list = fields.KeywordField()
keys_list = fields.KeywordField(multi=True)
class Django:
model = ElasticNews
@GLOBAL_INDEX.doc_type
class RegistrationCodeDocument(Document):
code = fields.KeywordField() #具体值
keys = fields.KeywordField(multi=True) #对应的key
manage_keys = fields.KeywordField(multi=True) #对应的manage_key
created_at = fields.DateField() #创建时间
expires_at = fields.DateField() #过期时间
created_by = fields.LongField() #创建者id
class Django:
model = ElasticNews

View File

@@ -5,11 +5,13 @@ Django版本的ES连接和操作模块
from elasticsearch import Elasticsearch
from elasticsearch_dsl import connections
import os
from .documents import AchievementDocument, UserDocument, GlobalDocument
from .documents import AchievementDocument, UserDocument, GlobalDocument, RegistrationCodeDocument
from accounts.crypto import hash_password_random_salt
from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
import hashlib
import time
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
import uuid
import json
# 使用环境变量配置ES连接默认为本机
@@ -63,10 +65,12 @@ def create_index_with_mapping():
# --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)---
# 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断
_salt_b64, _hash_b64 = hash_password_random_salt("admin")
admin_user = {
"user_id": 0,
"username": "admin",
"password": "admin", # ⚠️ 生产环境务必加密!
"password_hash": _hash_b64,
"password_salt": _salt_b64,
"permission": 0
}
# 可选:检查 admin 是否已存在(根据 user_id 或 username
@@ -112,6 +116,136 @@ def ensure_type_in_list(type_name: str):
except Exception:
return False
def get_keys_list():
try:
try:
doc = GlobalDocument.get(id='keys')
cur = list(doc.keys_list or [])
except Exception:
cur = []
doc = GlobalDocument(keys_list=cur)
doc.meta.id = 'keys'
doc.save()
return [str(t).strip().strip(';') for t in cur]
except Exception:
return []
def ensure_key_in_list(key_name: str):
if not key_name:
return False
norm = str(key_name).strip().strip(';')
try:
try:
doc = GlobalDocument.get(id='keys')
cur = list(doc.keys_list or [])
except Exception:
cur = []
doc = GlobalDocument(keys_list=cur)
doc.meta.id = 'keys'
cur_sanitized = {str(t).strip().strip(';') for t in cur}
if norm not in cur_sanitized:
cur.append(norm)
doc.keys_list = cur
doc.save()
return True
return False
except Exception:
return False
def generate_registration_code(keys=None, manage_keys=None, expires_in_days: int = 30, created_by: int = None):
try:
keys = list(keys or [])
manage_keys = list(manage_keys or [])
for k in list(keys):
ensure_key_in_list(k)
for mk in list(manage_keys):
ensure_key_in_list(mk)
code = uuid.uuid4().hex + str(int(time.time()))[-6:]
now = datetime.now(timezone.utc)
expires = now + timedelta(days=max(1, int(expires_in_days or 30)))
doc = RegistrationCodeDocument(
code=code,
keys=keys,
manage_keys=manage_keys,
created_at=now.isoformat(),
expires_at=expires.isoformat(),
created_by=created_by,
)
doc.meta.id = code
doc.save()
return {
"code": code,
"keys": keys,
"manage_keys": manage_keys,
"created_at": now.isoformat(),
"expires_at": expires.isoformat(),
}
except Exception as e:
return None
def get_registration_code(code: str):
try:
doc = RegistrationCodeDocument.get(id=str(code))
return {
"code": getattr(doc, 'code', str(code)),
"keys": list(getattr(doc, 'keys', []) or []),
"manage_keys": list(getattr(doc, 'manage_keys', []) or []),
"created_at": getattr(doc, 'created_at', None),
"expires_at": getattr(doc, 'expires_at', None),
"created_by": getattr(doc, 'created_by', None),
}
except Exception:
return None
def list_registration_codes():
try:
search = RegistrationCodeDocument.search()
body = {
"sort": [{"created_at": {"order": "desc"}}],
"query": {"exists": {"field": "code"}}
}
search = search.update_from_dict(body)
resp = search.execute()
out = []
now = datetime.now(timezone.utc)
for hit in resp:
try:
if not getattr(hit, 'code', None):
continue
except Exception:
continue
exp = getattr(hit, 'expires_at', None)
try:
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = datetime.fromisoformat(str(exp))
except Exception:
exp_dt = None
active = bool(exp_dt and exp_dt > now)
out.append({
"code": getattr(hit, 'code', ''),
"keys": list(getattr(hit, 'keys', []) or []),
"manage_keys": list(getattr(hit, 'manage_keys', []) or []),
"created_at": getattr(hit, 'created_at', None),
"expires_at": getattr(hit, 'expires_at', None),
"created_by": getattr(hit, 'created_by', None),
"active": active,
})
return out
except Exception:
return []
def revoke_registration_code(code: str):
try:
doc = RegistrationCodeDocument.get(id=str(code))
now = datetime.now(timezone.utc).isoformat()
doc.expires_at = now
doc.save()
return True
except Exception:
return False
def get_doc_id(data):
"""
根据数据内容生成唯一ID用于去重
@@ -513,11 +647,21 @@ def write_user_data(user_data):
perm_val = int(user_data.get('permission', 1))
except Exception:
perm_val = 1
pwd = str(user_data.get('password') or '').strip()
pwd_hash_b64 = user_data.get('password_hash')
pwd_salt_b64 = user_data.get('password_salt')
if pwd:
salt_b64, hash_b64 = hash_password_random_salt(pwd)
pwd_hash_b64, pwd_salt_b64 = hash_b64, salt_b64
user = UserDocument(
user_id=user_data.get('user_id'),
username=user_data.get('username'),
password=user_data.get('password'),
permission=perm_val
password_hash=pwd_hash_b64,
password_salt=pwd_salt_b64,
permission=perm_val,
email=user_data.get('email'),
key=list(user_data.get('key') or []),
manage_key=list(user_data.get('manage_key') or []),
)
user.save()
print(f"用户数据写入成功: {user_data.get('username')}")
@@ -535,11 +679,10 @@ def get_user_by_id(user_id):
if response.hits:
hit = response.hits[0]
return {
"user_id": hit.user_id,
"username": hit.username,
"password": hit.password,
"permission": hit.permission
}
"user_id": hit.user_id,
"username": hit.username,
"permission": hit.permission
}
return None
except Exception as e:
@@ -566,7 +709,8 @@ def get_user_by_username(username):
return {
"user_id": hit.user_id,
"username": hit.username,
"password": hit.password,
"password_hash": getattr(hit, 'password_hash', None),
"password_salt": getattr(hit, 'password_salt', None),
"permission": int(hit.permission)
}
return None
@@ -586,7 +730,10 @@ def get_all_users():
users.append({
"user_id": hit.user_id,
"username": hit.username,
"permission": int(hit.permission)
"permission": int(hit.permission),
"email": getattr(hit, 'email', None),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
})
return users
@@ -605,6 +752,9 @@ def get_user_by_id(user_id):
"user_id": hit.user_id,
"username": hit.username,
"permission": int(hit.permission),
"email": getattr(hit, 'email', None),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
}
return None
except Exception as e:
@@ -639,7 +789,9 @@ def update_user_by_id(user_id, username=None, permission=None, password=None):
if permission is not None:
doc.permission = int(permission)
if password is not None:
doc.password = password
salt_b64, hash_b64 = hash_password_random_salt(str(password))
doc.password_hash = hash_b64
doc.password_salt = salt_b64
doc.save()
return True
return False

View File

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

View File

@@ -3,286 +3,58 @@
<head>
<meta charset="UTF-8" />
<title>数据管理</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 180px;
height: 100vh;
background: #1e1e2e;
color: white;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
}
.user-id {
text-align: center;
margin-bottom: 0px;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #add8e6;
text-align: center;
margin-bottom: 20px;
}
.navigation-links {
width: 100%;
margin-top: 60px;
}
.sidebar a,
.sidebar button {
display: block;
color: #8be9fd;
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar a:hover,
.sidebar button:hover {
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */
.main-content {
margin-left: 200px;
padding: 20px;
color: #333;
}
/* 原有样式保持不变 */
.container {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0,0,0,0.06);
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
border-bottom: 1px solid #eee;
padding: 12px 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #f8f9fa;
font-weight: 600;
}
img {
max-width: 120px;
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer; /* 添加指针样式 */
}
.btn {
padding: 6px 10px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin: 2px;
}
.btn-primary {
background: #1677ff;
color: #fff;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.muted {
color: #666;
font-size: 12px;
}
.modal {
position: fixed;
inset: 0;
display: none;
background: rgba(0,0,0,0.4);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal .dialog {
width: 720px;
max-width: 92vw;
background: #fff;
border-radius: 10px;
padding: 20px;
max-height: 80vh;
overflow-y: auto;
}
textarea {
width: 100%;
min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
}
#kvForm {
border: 1px solid #eee;
border-radius: 6px;
padding: 8px;
max-height: 300px;
overflow: auto;
}
/* 搜索区域样式 */
.search-container {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.search-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-result {
margin-top: 10px;
padding: 10px;
background: #e8f4ff;
border-radius: 4px;
font-size: 14px;
}
.search-result.empty {
background: #fff8e8;
}
.search-result.error {
background: #ffe8e8;
}
/* 加载动画 */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1677ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式调整 */
@media (max-width: 768px) {
.search-controls {
flex-direction: column;
align-items: stretch;
}
.search-input {
min-width: auto;
}
.btn {
width: 100%;
margin: 2px 0;
}
}
/* 图片放大模态框 */
.image-modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.9);
overflow: auto;
}
.image-modal-content {
margin: auto;
display: block;
width: 80%;
max-width: 800px;
max-height: 80%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.image-modal-close {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.image-modal-close:hover {
color: #bbb;
}
<style>
body{margin:0;font-family:sans-serif;background:#fafafa}
.sidebar{position:fixed;top:0;left:0;width:180px;height:100vh;background:#1e1e2e;color:white;padding:20px;box-shadow:2px 0 5px rgba(0,0,0,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
.user-id{text-align:center;margin-bottom:0}
.sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
.navigation-links{width:100%;margin-top:60px}
.sidebar a,.sidebar button{display:block;color:#8be9fd;text-decoration:none;margin:10px 0;font-size:16px;padding:15px;border-radius:4px;background:transparent;border:none;cursor:pointer;width:calc(100% - 40px);text-align:left;transition:.2s}
.sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
.main-content{margin-left:200px;padding:20px;color:#333}
.container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
table{width:100%;border-collapse:collapse;margin-top:20px}
th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
th{background:#f8f9fa;font-weight:600}
img{max-width:120px;border:1px solid #eee;border-radius:6px;cursor:pointer}
.btn{padding:6px 10px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin:2px}
.btn-primary{background:#1677ff;color:#fff}
.btn-danger{background:#ff4d4f;color:#fff}
.btn-secondary{background:#f0f0f0;color:#333}
.muted{color:#666;font-size:12px}
.modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
.modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
#kvForm{border:1px solid #eee;border-radius:6px;padding:8px;max-height:300px;overflow:auto}
.search-container{background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px}
.search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
.search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
.search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
.search-result.empty{background:#fff8e8}
.search-result.error{background:#ffe8e8}
.loading{display:inline-block;width:20px;height:20px;border:3px solid #f3f3f3;border-top:3px solid #1677ff;border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
@media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
.image-modal{display:none;position:fixed;z-index:2000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.9);overflow:hidden}
.image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
.image-modal-content.dragging{cursor:grabbing}
.image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
.image-modal-close:hover{color:#bbb}
.zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
.zoom-btn{background:rgba(255,255,255,.7);border:none;border-radius:50%;width:40px;height:40px;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.3);transition:background .3s}
.zoom-btn:hover{background:rgba(255,255,255,.9)}
.zoom-info{position:absolute;top:15px;left:15px;color:#f1f1f1;font-size:14px;z-index:2001;background:rgba(0,0,0,.5);padding:5px 10px;border-radius:4px}
</style>
</head>
<body>
<!-- 左侧固定栏目 -->
<div class="sidebar">
<div class="user-id">
<h3>用户ID{{ user_id }}</h3>
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a>
<button id="logoutBtn">退出登录</button>
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
@@ -317,7 +89,7 @@
<th>ID</th>
<th>图片</th>
<th>数据</th>
<th>作者</th>
<th>录入人</th>
<th>操作</th>
</tr>
</thead>
@@ -351,7 +123,13 @@
<!-- 图片放大模态框 -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span>
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
<img class="image-modal-content" id="expandedImage">
<div class="zoom-controls">
<button class="zoom-btn" id="zoomOutBtn">-</button>
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
<button class="zoom-btn" id="zoomInBtn">+</button>
</div>
</div>
<script>
@@ -379,12 +157,29 @@ const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const imageModal = document.getElementById('imageModal');
const expandedImage = document.getElementById('expandedImage');
const imageModalClose = document.querySelector('.image-modal-close');
const zoomInBtn = document.getElementById('zoomInBtn');
const zoomOutBtn = document.getElementById('zoomOutBtn');
const resetZoomBtn = document.getElementById('resetZoomBtn');
const zoomValue = document.getElementById('zoomValue');
// 全局变量
let currentId = '';
let currentWriter = '';
let currentImage = '';
let allDataCache = []; // 缓存所有数据,避免重复请求
let currentSearchQuery = ''; // 记录当前搜索查询
let isFuzzySearch = false; // 记录当前是否为模糊搜索
let isDeleting = false; // 标记是否正在删除
// 图片缩放相关变量
let currentScale = 1;
let currentX = 0;
let currentY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let imgStartX = 0;
let imgStartY = 0;
// 搜索功能
async function performSearch(type) {
@@ -394,6 +189,8 @@ async function performSearch(type) {
return;
}
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
showSearchLoading();
try {
@@ -452,6 +249,7 @@ function showSearchMessage(message, type = '') {
// 加载所有数据
async function loadAllData() {
currentSearchQuery = '';
showSearchLoading();
try {
@@ -490,6 +288,7 @@ function displayAllData(data) {
function clearSearch() {
searchQueryInput.value = '';
searchResultDiv.style.display = 'none';
currentSearchQuery = '';
// 如果有缓存数据,显示全部
if (allDataCache.length > 0) {
@@ -696,15 +495,9 @@ async function saveEdit(){
alert('保存成功');
closeModal();
// 重新加载数据以显示更新
if (searchResultDiv.style.display !== 'none') {
if (currentSearchQuery) {
// 如果当前显示的是搜索结果,重新执行搜索
const query = searchQueryInput.value.trim();
if (query) {
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
performSearch(isFuzzy ? 'fuzzy' : 'exact');
} else {
loadAllData();
}
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
} else {
loadAllData();
}
@@ -714,8 +507,20 @@ async function saveEdit(){
}
async function doDelete(id){
if (isDeleting) {
alert('正在处理删除操作,请稍候...');
return;
}
if(!confirm('确认删除该记录?此操作不可撤销')) return;
isDeleting = true;
const deleteButton = document.querySelector(`button[onclick="doDelete('${id}')"]`);
if (deleteButton) {
deleteButton.disabled = true;
deleteButton.textContent = '删除中...';
}
try {
const response = await fetch(`/elastic/data/${id}/delete/`, {
method:'DELETE',
@@ -727,20 +532,32 @@ async function doDelete(id){
if(data.status!=='success') throw new Error(data.message || '删除失败');
alert('删除成功');
// 重新加载数据
if (searchResultDiv.style.display !== 'none') {
const query = searchQueryInput.value.trim();
if (query) {
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
performSearch(isFuzzy ? 'fuzzy' : 'exact');
} else {
loadAllData();
}
// 清空缓存,确保下次加载获取最新数据
allDataCache = [];
// 根据当前显示状态重新加载数据
if (currentSearchQuery) {
// 如果当前显示的是搜索结果,重新执行搜索
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
} else {
loadAllData();
// 修复:重新加载所有数据时,强制刷新缓存
const response = await fetch('/elastic/all-data/');
const data = await response.json();
if (data.status === 'success') {
allDataCache = data.data || [];
displayAllData(allDataCache);
} else {
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
}
}
} catch (e) {
alert(e.message||'删除失败');
} finally {
isDeleting = false;
if (deleteButton) {
deleteButton.disabled = false;
deleteButton.textContent = '删除';
}
}
}
@@ -776,6 +593,30 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
}
});
// 图片缩放功能
function updateZoom() {
expandedImage.style.transform = `translate(-50%, -50%) scale(${currentScale}) translate(${currentX}px, ${currentY}px)`;
zoomValue.textContent = `${Math.round(currentScale * 100)}%`;
}
function resetZoom() {
currentScale = 1;
currentX = 0;
currentY = 0;
updateZoom();
}
function zoomIn() {
currentScale *= 1.2;
updateZoom();
}
function zoomOut() {
currentScale /= 1.2;
if (currentScale < 0.1) currentScale = 0.1; // 最小缩放限制
updateZoom();
}
// 图片放大功能
document.addEventListener('DOMContentLoaded', function() {
// 为所有图片添加点击事件监听器
@@ -784,6 +625,9 @@ document.addEventListener('DOMContentLoaded', function() {
const imgSrc = e.target.src;
expandedImage.src = imgSrc;
imageModal.style.display = 'block';
// 重置缩放状态
resetZoom();
}
});
@@ -798,6 +642,91 @@ document.addEventListener('DOMContentLoaded', function() {
imageModal.style.display = 'none';
}
}
// 缩放按钮事件
zoomInBtn.addEventListener('click', zoomIn);
zoomOutBtn.addEventListener('click', zoomOut);
resetZoomBtn.addEventListener('click', resetZoom);
// 鼠标滚轮缩放
expandedImage.addEventListener('wheel', function(e) {
e.preventDefault();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
});
// 拖拽功能
expandedImage.addEventListener('mousedown', function(e) {
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
imgStartX = currentX;
imgStartY = currentY;
expandedImage.classList.add('dragging');
});
document.addEventListener('mousemove', function(e) {
if (isDragging) {
const deltaX = e.clientX - dragStartX;
const deltaY = e.clientY - dragStartY;
currentX = imgStartX + deltaX / currentScale;
currentY = imgStartY + deltaY / currentScale;
updateZoom();
}
});
document.addEventListener('mouseup', function() {
isDragging = false;
expandedImage.classList.remove('dragging');
});
// 触摸事件支持(移动端)
expandedImage.addEventListener('touchstart', function(e) {
if (e.touches.length === 1) {
isDragging = true;
dragStartX = e.touches[0].clientX;
dragStartY = e.touches[0].clientY;
imgStartX = currentX;
imgStartY = currentY;
} else if (e.touches.length === 2) {
// 双指缩放
initialDistance = getDistance(e.touches[0], e.touches[1]);
initialScale = currentScale;
}
});
document.addEventListener('touchmove', function(e) {
e.preventDefault();
if (isDragging && e.touches.length === 1) {
const deltaX = e.touches[0].clientX - dragStartX;
const deltaY = e.touches[0].clientY - dragStartY;
currentX = imgStartX + deltaX / currentScale;
currentY = imgStartY + deltaY / currentScale;
updateZoom();
} else if (e.touches.length === 2) {
// 双指缩放
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const scale = (currentDistance / initialDistance) * initialScale;
currentScale = Math.max(0.1, Math.min(scale, 10)); // 限制缩放范围
updateZoom();
}
});
document.addEventListener('touchend', function() {
isDragging = false;
});
// 计算两点间距离
function getDistance(touch1, touch2) {
return Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
}
});
</script>
</body>

View File

@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>注册码管理</title>
<style>
body { margin:0; font-family: system-ui,-apple-system, Segoe UI, Roboto, sans-serif; background:#fafafa; }
.sidebar { position:fixed; top:0; left:0; width:180px; height:100vh; background:#1e1e2e; color:#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; margin-bottom: 20px; }
.navigation-links { width:100%; margin-top:60px; }
.sidebar a, .sidebar button { display:block; color:#8be9fd; text-decoration:none; margin:10px 0; font-size:16px; padding:15px; border-radius:4px; background:transparent; border:none; cursor:pointer; width:calc(100% - 40px); text-align:left; transition:all .2s ease; }
.sidebar a:hover, .sidebar button:hover { color:#ff79c6; background-color:rgba(139,233,253,.2); }
.main { margin-left:200px; padding:20px; color:#333; }
.card { background:#fff; border-radius:14px; box-shadow:0 10px 24px rgba(31,35,40,.08); padding:20px; margin-bottom:20px; }
.row { display:flex; gap:16px; }
.col { flex:1; }
label { display:block; margin-bottom:6px; font-weight:600; }
input[type=text], input[type=number], select { width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:6px; box-sizing:border-box; }
.btn { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; margin:0 4px; }
.btn-primary { background:#4f46e5; color:#fff; }
.btn-secondary { background:#64748b; color:#fff; }
.notice { padding:10px; border-radius:6px; margin-top:10px; display:none; }
.notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
.code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); display:flex; align-items:center; justify-content:center; z-index:2000; }
.spinner { width:42px; height:42px; border:4px solid #cbd5e1; border-top-color:#4f46e5; border-radius:50%; animation:spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeUp 0.25s ease-out; }
@keyframes fadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
table tr:hover { background-color:#f3f4f6; transition: background-color 0.2s ease; }
.btn { transition: transform 0.1s ease, box-shadow 0.2s ease; }
.btn:hover { transform: translateY(-1px); box-shadow:0 6px 16px rgba(31,35,40,0.12); }
</style>
{% csrf_token %}
<script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
async function loadKeys(){
const resp=await fetch('/elastic/registration-codes/keys/');
const data=await resp.json();
const opts=(data.data||[]);
const keySel=document.getElementById('keys');
const mkeySel=document.getElementById('manageKeys');
keySel.innerHTML=''; mkeySel.innerHTML='';
opts.forEach(k=>{
const o=document.createElement('option'); o.value=k; o.textContent=k; keySel.appendChild(o);
const o2=document.createElement('option'); o2.value=k; o2.textContent=k; mkeySel.appendChild(o2);
});
}
async function addKey(){
const keyName=(document.getElementById('newKey').value||'').trim();
if(!keyName) return;
const csrftoken=getCookie('csrftoken');
const resp=await fetch('/elastic/registration-codes/keys/add/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({key:keyName})});
const data=await resp.json();
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){msg.textContent='新增key成功'; msg.className='notice success'; msg.style.display='block'; document.getElementById('newKey').value=''; loadKeys();}
else{msg.textContent=data.message||'新增失败'; msg.className='notice error'; msg.style.display='block';}
}
function selectedValues(sel){return Array.from(sel.selectedOptions).map(o=>o.value);}
function enableToggleSelect(sel){ sel.addEventListener('mousedown',function(e){ if(e.target && e.target.tagName==='OPTION'){ e.preventDefault(); const op=e.target; op.selected=!op.selected; this.dispatchEvent(new Event('change',{bubbles:true})); } }); }
function clearSelection(id){ const sel=document.getElementById(id); Array.from(sel.options).forEach(o=>o.selected=false); }
async function generateCode(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const csrftoken=getCookie('csrftoken');
const keys=selectedValues(document.getElementById('keys'));
const manageKeys=selectedValues(document.getElementById('manageKeys'));
const mode=document.getElementById('expireMode').value;
let days=30; if(mode==='month') days=30; else if(mode==='fouryears') days=1460; else { const d=parseInt(document.getElementById('customDays').value||'30'); days=isNaN(d)?30:Math.max(1,d);}
const resp=await fetch('/elastic/registration-codes/generate/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({keys,manage_keys:manageKeys,expires_in_days:days})});
const data=await resp.json();
const out=document.getElementById('codeOut');
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){out.textContent=data.data.code; msg.textContent='生成成功'; msg.className='notice success'; msg.style.display='block';}
else{msg.textContent=data.message||'生成失败'; msg.className='notice error'; msg.style.display='block';}
ov.style.display='none';
}
async function loadCodes(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const resp=await fetch('/elastic/registration-codes/list/');
const data=await resp.json();
const tbody=document.getElementById('codesBody');
if(!tbody) return;
tbody.innerHTML='';
if(resp.ok && data.status==='success'){
(data.data||[]).forEach(it=>{
const tr=document.createElement('tr');
const status = it.active? '有效' : '失效';
const ka = Array.isArray(it.keys)? it.keys.join('、') : '';
const mka = Array.isArray(it.manage_keys)? it.manage_keys.join('、') : '';
tr.innerHTML = `<td>${it.code||''}</td><td>${ka}</td><td>${mka}</td><td>${formatDate(it.created_at)}</td><td>${formatDate(it.expires_at)}</td><td>${status}</td><td>${it.active? '<button class=\"btn btn-secondary\" data-code=\"'+it.code+'\">作废</button>':''}</td>`;
tbody.appendChild(tr);
});
}
ov.style.display='none';
}
function formatDate(t){ if(!t) return ''; try{ const d = new Date(t); if(String(d)!='Invalid Date'){ const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;} }catch(e){} return ''; }
async function revokeCode(code){ const csrftoken=getCookie('csrftoken'); const resp=await fetch('/elastic/registration-codes/revoke/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code})}); const msg=document.getElementById('msg'); const data=await resp.json(); if(resp.ok && data.status==='success'){ msg.textContent='已作废'; msg.className='notice success'; msg.style.display='block'; loadCodes(); } else { msg.textContent=data.message||'作废失败'; msg.className='notice error'; msg.style.display='block'; } }
document.addEventListener('click',function(e){ const btn=e.target; if(btn && btn.matches('button[data-code]')){ revokeCode(btn.getAttribute('data-code')); }});
document.addEventListener('DOMContentLoaded',()=>{loadKeys(); enableToggleSelect(document.getElementById('keys')); enableToggleSelect(document.getElementById('manageKeys')); loadCodes();});
</script>
</head>
<body>
<div id="overlay" class="overlay" style="display:none"><div class="spinner"></div></div>
<div class="sidebar">
<h3>你好,{{ username|default:"访客" }}</h3>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
</div>
<div class="main">
<div class="card fade-in">
<h2>管理注册码</h2>
<div class="row">
<div class="col">
<label>新增key</label>
<input id="newKey" type="text" placeholder="输入新的key" />
<button class="btn btn-secondary" onclick="addKey()">新增</button>
</div>
</div>
<div class="row" style="margin-top:12px;">
<div class="col">
<label>选择keys</label>
<select id="keys" multiple size="10"></select>
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('keys')">清空选择</button></div>
</div>
<div class="col">
<label>选择manage_keys</label>
<select id="manageKeys" multiple size="10"></select>
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('manageKeys')">清空选择</button></div>
</div>
</div>
<div class="row" style="margin-top:12px;">
<div class="col">
<label>有效期</label>
<select id="expireMode">
<option value="month">一个月</option>
<option value="fouryears">四年</option>
<option value="custom">自定义天数</option>
</select>
<input id="customDays" type="number" min="1" placeholder="自定义天数" />
</div>
<div class="col" style="display:flex; align-items:flex-end;">
<button class="btn btn-primary" onclick="generateCode()">生成注册码</button>
</div>
</div>
<div id="msg" class="notice"></div>
<div class="code-box" id="codeOut"></div>
<div class="row" style="margin-top:12px;">
<div class="col">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3>已生成的注册码</h3>
<div>
<button class="btn btn-secondary" onclick="loadCodes()">刷新列表</button>
</div>
</div>
<table style="width:100%; border-collapse:collapse; margin-top:10px;">
<thead>
<tr>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">code</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">manage_keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">创建时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">过期时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">状态</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">操作</th>
</tr>
</thead>
<tbody id="codesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// 获取CSRF令牌的函数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 导航点击处理函数提供备用URL
function handleNavClick(element, fallbackUrl) {
// 尝试使用Django模板生成的URL如果失败则使用备用URL
try {
// 如果模板渲染正常直接返回true让默认行为处理
return true;
} catch (e) {
// 如果模板渲染有问题使用备用URL
window.location.href = fallbackUrl;
return false;
}
}
// 修复用户管理链接跳转问题
document.addEventListener('DOMContentLoaded', function() {
// 为用户管理链接添加事件监听器,确保正确跳转
const userManagementLink = document.querySelector('a[href*="get_users"]');
if (userManagementLink) {
userManagementLink.addEventListener('click', function(e) {
// 阻止默认行为
e.preventDefault();
// 获取备用URL
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
// 直接跳转到用户管理页面
window.location.href = fallbackUrl;
});
}
});
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
const trendChart = echarts.init(document.getElementById('chartTrend'));
const typesChart = echarts.init(document.getElementById('chartTypes'));
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
async function loadTrend(){
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const x = buckets.map(b=>b.key_as_string||'');
const y = buckets.map(b=>b.doc_count||0);
trendChart.setOption({
tooltip:{trigger:'axis'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
});
}
async function loadTypes(){
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
typesChart.setOption({
tooltip:{trigger:'item'},
legend:{type:'scroll'},
series:[{ type:'pie', radius:['40%','70%'], data }]
});
}
async function loadTypesTrend(){
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const rows = res.data || [];
const x = rows.map(r=>r.key_as_string||'');
const typeSet = new Set();
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
const types = Array.from(typeSet);
const series = types.map(tp=>({
name: tp,
type:'line',
smooth:true,
data: rows.map(r=>{
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
return b? b.doc_count||0 : 0;
})
}));
typesTrendChart.setOption({
tooltip:{trigger:'axis'},
legend:{type:'scroll'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series
});
}
function formatTime(t){
try{
const d = new Date(t);
if(String(d) !== 'Invalid Date'){
const pad = n=> String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
}catch(e){}
return t||'';
}
async function loadRecent(){
const listEl = document.getElementById('recentList');
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const items = res.data || [];
listEl.innerHTML = '';
items.forEach(it=>{
const li = document.createElement('li');
const t = formatTime(it.time);
const u = it.username || '';
const ty = it.type || '未知';
li.textContent = `${t}${u}${ty}`;
listEl.appendChild(li);
});
}
loadTrend();
loadTypes();
loadTypesTrend();
loadRecent();
</script>
</body>
</html>

View File

@@ -4,282 +4,67 @@
<meta charset="UTF-8" />
<title>图片上传与识别</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 - 保持原有样式 */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 180px;
height: 100vh;
background: #1e1e2e;
color: white;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
}
.user-id {
text-align: center;
margin-bottom: 0px;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #add8e6;
text-align: center;
margin-bottom: 20px;
}
.navigation-links {
width: 100%;
margin-top: 60px;
}
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
/* 导航栏样式 */
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
flex-direction: column;align-items: center;}
.user-id {text-align: center;margin-bottom: 0px;}
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
.navigation-links {width: 100%;margin-top: 60px;}
.sidebar a,
.sidebar button {
display: block;
color: #8be9fd;
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
.sidebar a:hover,
.sidebar button:hover {
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
/* 主内容区 - 改进后的样式 */
.main-content {
margin-left: 200px;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 14px;
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
padding: 24px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h2 {
margin: 0;
color: #1e293b;
}
.header p {
margin: 5px 0 0 0;
color: #64748b;
font-size: 14px;
}
.upload-section {
background: #f8fafc;
border: 2px dashed #cbd5e1;
border-radius: 12px;
padding: 32px;
text-align: center;
transition: all 0.3s ease;
margin-bottom: 24px;
}
.upload-section:hover {
border-color: #4f46e5;
background: #f1f5f9;
}
.upload-section.drag-over {
border-color: #4f46e5;
background: #e0e7ff;
}
.upload-section input[type="file"] {
margin: 15px 0;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
cursor: pointer;
margin: 0 4px;
font-size: 14px;
transition: all 0.2s ease;
}
.btn-primary {
background: #4f46e5;
color: #fff;
}
.btn-primary:hover {
background: #4338ca;
}
.btn-secondary {
background: #e2e8f0;
color: #334155;
}
.btn-secondary:hover {
background: #cbd5e1;
}
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-danger:hover {
background: #dc2626;
}
.preview-container {
display: flex;
gap: 24px;
margin: 24px 0;
}
.main-content {margin-left: 200px;padding: 20px;color: #333;}
.container { max-width: 1200px;margin: 0 auto;background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);
padding: 24px;}
.header {display: flex;align-items: center;justify-content: space-between;margin-bottom: 12px;}
.header h2 {margin: 0; color: #1e293b;}
.header p {margin: 5px 0 0 0;color: #64748b;font-size: 14px;}
.upload-section { background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;padding: 32px; text-align: center;transition: all 0.3s ease;
margin-bottom: 24px;}
.upload-section:hover {border-color: #4f46e5; background: #f1f5f9; }
.upload-section.drag-over {border-color: #4f46e5; background: #e0e7ff; }
.upload-section input[type="file"] {margin: 15px 0;}
.btn {padding: 10px 16px;border: none;border-radius: 8px;cursor: pointer;margin: 0 4px;font-size: 14px;transition: all 0.2s ease; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-primary:hover { background: #4338ca;}
.btn-secondary {background: #e2e8f0;color: #334155; }
.btn-secondary:hover { background: #cbd5e1;}
.btn-danger { background: #ef4444;color: #fff;}
.btn-danger:hover { background: #dc2626;}
.preview-container {display: flex; gap: 24px; margin: 24px 0;}
@media (max-width: 768px) {
.preview-container {
flex-direction: column;
}
}
.preview-box {
flex: 1;
text-align: center;
}
.preview-box h3 {
margin-top: 0;
color: #334155;
}
.preview-box img {
max-width: 100%;
max-height: 300px;
border: 1px solid #e2e8f0;
border-radius: 8px;
object-fit: contain;
}
.result-box {
flex: 1;
}
.result-box h3 {
margin-top: 0;
color: #334155;
}
.form-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
#kvForm {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
max-height: 300px;
overflow: auto;
margin-bottom: 12px;
background: white;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 8px;
margin-bottom: 6px;
}
.form-row input {
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
}
#resultBox {
width: 100%;
min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
resize: vertical;
box-sizing: border-box;
}
.status-message {
padding: 10px;
margin: 10px 0;
border-radius: 6px;
display: none;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.action-buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
.preview-container {flex-direction: column;}
}
.preview-box {flex: 1; text-align: center; }
.preview-box h3 {margin-top: 0;color: #334155; }
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
.result-box {flex: 1;}
.result-box h3 { margin-top: 0; color: #334155;}
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
border-radius: 8px; resize: vertical;box-sizing: border-box; }
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
</style>
</head>
<body>
<!-- 左侧固定栏目 -->
<div class="sidebar">
<div class="user-id">
<h3>用户ID{{ user_id }}</h3>
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a>
<button id="logoutBtn">退出登录</button>
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>

View File

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

@@ -32,6 +32,12 @@ urlpatterns = [
# 管理页面
path('manage/', views.manage_page, name='manage_page'),
path('user_manage/', views.user_manage, name='user_manage'),
path('registration-codes/manage/', views.registration_code_manage_page, name='registration_code_manage_page'),
path('registration-codes/keys/', views.get_keys_list_view, name='get_keys_list'),
path('registration-codes/keys/add/', views.add_key_view, name='add_key'),
path('registration-codes/generate/', views.generate_registration_code_view, name='generate_registration_code'),
path('registration-codes/list/', views.list_registration_codes_view, name='list_registration_codes'),
path('registration-codes/revoke/', views.revoke_registration_code_view, name='revoke_registration_code'),
# 分析接口
path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),

View File

@@ -222,16 +222,27 @@ def add_user(request):
@require_http_methods(["GET"])
def get_users(request):
if request.session.get("user_id") is None:
uid = request.session.get("user_id")
if uid is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try:
is_admin = int(request.session.get("permission", 1)) == 0
requester = get_user_by_id(uid) or {}
mgr_keys = set(requester.get("manage_key") or [])
q = (request.GET.get("search") or "").strip()
users = get_all_users()
if is_admin:
filtered = users
elif mgr_keys:
def match_manage(user):
ukeys = set(user.get("key") or [])
return bool(ukeys & mgr_keys)
filtered = [u for u in users if match_manage(u)]
else:
filtered = [u for u in users if str(u.get("user_id")) == str(uid)]
if q:
users = [u for u in users if q in str(u.get("username", ""))]
return JsonResponse({"status": "success", "data": users})
filtered = [u for u in filtered if q in str(u.get("username", ""))]
return JsonResponse({"status": "success", "data": filtered})
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -239,10 +250,9 @@ def get_users(request):
@require_http_methods(["POST"])
@csrf_protect
def update_user_by_id_view(request, user_id):
if request.session.get("user_id") is None:
uid = request.session.get("user_id")
if uid is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try:
payload = json.loads(request.body.decode("utf-8"))
except Exception:
@@ -250,21 +260,41 @@ def update_user_by_id_view(request, user_id):
new_username = (payload.get("username") or "").strip()
new_permission = payload.get("permission")
new_password = (payload.get("password") or "").strip()
if new_username:
other = get_user_by_username(new_username)
if other and int(other.get("user_id", -1)) != int(user_id):
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
if new_password and len(new_password) < 6:
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
ok = es_update_user_by_id(
user_id,
username=new_username if new_username else None,
permission=int(new_permission) if new_permission is not None else None,
password=new_password if new_password else None,
)
if not ok:
return JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
return JsonResponse({"status": "success", "message": "用户更新成功"})
is_admin = int(request.session.get("permission", 1)) == 0
requester = get_user_by_id(uid) or {}
target = get_user_by_id(user_id) or {}
requester_mgr = set(requester.get("manage_key") or [])
target_keys = set(target.get("key") or [])
if is_admin:
if new_username:
other = get_user_by_username(new_username)
if other and int(other.get("user_id", -1)) != int(user_id):
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
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"])
@csrf_protect
@@ -491,13 +521,32 @@ def manage_page(request):
from django.shortcuts import redirect
return redirect("/accounts/login/")
if int(request.session.get("permission", 1)) != 0:
from django.shortcuts import redirect
return redirect("/main/home/")
raw_results = search_all()
# if not is_admin:
# uid = str(session_user_id)
# raw_results = [r for r in raw_results if str(r.get("writer_id", "")) == uid]
is_admin = int(request.session.get("permission", 1)) == 0
if is_admin:
raw_results = search_all()
else:
uid = str(session_user_id)
raw_results = [r for r in search_all() if str(r.get("writer_id", "")) == uid]
results = []
for r in raw_results:
try:
r_data = string_to_json(r.get("data", "{}"))
r_data["_id"] = r["id"]
r_data["_image"] = r.get("image", "")
results.append(r_data)
except Exception:
pass
return render(
request,
"elastic/manage.html",
{
"results": results,
"is_admin": is_admin,
"user_id": session_user_id,
},
)
# 规范化键,避免模板点号访问下划线前缀字段
results = []
for r in raw_results:
@@ -575,6 +624,25 @@ def analytics_recent_view(request):
@require_http_methods(["GET"])
@ensure_csrf_cookie
def user_manage(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
from django.shortcuts import redirect
return redirect("/accounts/login/")
is_admin = int(request.session.get("permission", 1)) == 0
me = get_user_by_id(session_user_id) or {}
has_manage = bool(me.get("manage_key"))
user_id_qs = request.GET.get("user_id")
context = {
"user_id": user_id_qs or session_user_id,
"is_admin": is_admin,
"is_tutor": (not is_admin) and has_manage,
"is_student": (not is_admin) and (not has_manage),
}
return render(request, "elastic/users.html", context)
@require_http_methods(["GET"])
@ensure_csrf_cookie
def registration_code_manage_page(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
from django.shortcuts import redirect
@@ -584,4 +652,82 @@ def user_manage(request):
return redirect("/main/home/")
user_id_qs = request.GET.get("user_id")
context = {"user_id": user_id_qs or session_user_id}
return render(request, "elastic/users.html", context)
return render(request, "elastic/registration_codes.html", context)
@require_http_methods(["GET"])
def get_keys_list_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)
lst = get_keys_list()
return JsonResponse({"status": "success", "data": lst})
@require_http_methods(["POST"])
@csrf_protect
def add_key_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)
key_name = (payload.get("key") or "").strip()
if not key_name:
return JsonResponse({"status": "error", "message": "key不能为空"}, status=400)
ok = ensure_key_in_list(key_name)
if not ok:
return JsonResponse({"status": "error", "message": "key已存在或写入失败"}, status=409)
return JsonResponse({"status": "success"})
@require_http_methods(["POST"])
@csrf_protect
def generate_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)
keys = list(payload.get("keys") or [])
manage_keys = list(payload.get("manage_keys") or [])
try:
days = int(payload.get("expires_in_days", 30))
except Exception:
days = 30
result = generate_registration_code(keys=keys, manage_keys=manage_keys, expires_in_days=days, created_by=request.session.get("user_id"))
if not result:
return JsonResponse({"status": "error", "message": "生成失败"}, status=500)
return JsonResponse({"status": "success", "data": result})
@require_http_methods(["GET"])
def list_registration_codes_view(request):
if request.session.get("user_id") is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
data = list_registration_codes()
return JsonResponse({"status": "success", "data": data})
@require_http_methods(["POST"])
@csrf_protect
def revoke_registration_code_view(request):
if request.session.get("user_id") is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try:
payload = json.loads(request.body.decode("utf-8"))
except Exception:
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
code = (payload.get("code") or "").strip()
if not code:
return JsonResponse({"status": "error", "message": "缺少code"}, status=400)
ok = revoke_registration_code(code)
if not ok:
return JsonResponse({"status": "error", "message": "作废失败"}, status=500)
return JsonResponse({"status": "success"})

View File

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

View File

@@ -9,3 +9,6 @@ httpx==0.27.2
Pillow==10.4.0
gunicorn==21.2.0
whitenoise==6.6.0
django-browser-reload==1.21.0
captcha==0.7.1
cryptography==46.0.3