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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django_browser_reload',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -49,6 +50,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django_browser_reload.middleware.BrowserReloadMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', '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 from main.views import home as main_home
urlpatterns = [ urlpatterns = [
path("__reload__/", include("django_browser_reload.urls")),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls', namespace='accounts')), path('accounts/', include('accounts.urls', namespace='accounts')),
path('main/', include('main.urls', namespace='main')), 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 hashlib
import hmac 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: def salt_for_username(username: str) -> bytes:
@@ -18,3 +36,80 @@ def derive_password(password_plain: str, salt: bytes, iterations: int = 100_000,
def hmac_sha256(key: bytes, message: bytes) -> bytes: def hmac_sha256(key: bytes, message: bytes) -> bytes:
"""Compute HMAC-SHA256 signature for the given message using key 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 import base64
from elastic.es_connect import get_user_by_username as es_get_user_by_username 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): def get_user_by_username(username: str):
"""
期望ES中存储的是明文密码登录时按用户名盐派生后对nonce做HMAC验证。
"""
es_user = es_get_user_by_username(username) es_user = es_get_user_by_username(username)
if es_user: if es_user:
salt = salt_for_username(username)
derived = derive_password(es_user.get('password', ''), salt)
return { return {
'user_id': es_user.get('user_id', 0), 'user_id': es_user.get('user_id', 0),
'username': es_user.get('username', ''), '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), 'permission': es_user.get('permission', 1),
} }
return None return None

View File

@@ -1,64 +1,53 @@
// Utility: read cookie value
function getCookie(name) { function getCookie(name) {
const value = `; ${document.cookie}`; const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`); const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift(); if (parts.length === 2) return parts.pop().split(';').shift();
} }
// Convert base64 string to ArrayBuffer
function base64ToArrayBuffer(b64) { function base64ToArrayBuffer(b64) {
const binary = atob(b64); const binary = atob(b64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer; return bytes.buffer;
} }
// ArrayBuffer to base64
function arrayBufferToBase64(buffer) { function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ''; let binary = '';
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary); return btoa(binary);
} }
async function deriveKey(password, saltBytes, iterations = 100000, length = 32) { async function importRsaPublicKey(spkiBytes) {
const encoder = new TextEncoder(); return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
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 hmacSha256(keyBytes, messageBytes) { async function rsaOaepEncrypt(publicKey, dataBytes) {
const key = await window.crypto.subtle.importKey( const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, dataBytes);
'raw', return new Uint8Array(encrypted);
keyBytes, }
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false, async function importAesKey(keyBytes) {
['sign'] return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
); }
const signature = await window.crypto.subtle.sign('HMAC', key, messageBytes);
return new Uint8Array(signature); 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) => { 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 username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
if (!username || !password) { if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; }
errorEl.textContent = '请输入账户与密码';
return;
}
const btn = document.getElementById('loginBtn'); const btn = document.getElementById('loginBtn');
btn.disabled = true; btn.disabled = true;
try { try {
// Step 1: get challenge (nonce + salt)
const csrftoken = getCookie('csrftoken'); const csrftoken = getCookie('csrftoken');
const chalResp = await fetch('/accounts/challenge/', { const pkResp = await fetch('/accounts/pubkey/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
method: 'POST', if (!pkResp.ok) throw new Error('获取公钥失败');
credentials: 'same-origin', const pkJson = await pkResp.json();
headers: { const spkiBytes = new Uint8Array(base64ToArrayBuffer(pkJson.public_key_spki));
'Content-Type': 'application/json', const pubKey = await importRsaPublicKey(spkiBytes);
'X-CSRFToken': csrftoken || ''
}, const aesKeyRaw = new Uint8Array(32); window.crypto.getRandomValues(aesKeyRaw);
body: JSON.stringify({ username }) 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) { const setKeySnapshot = await (async () => {
throw new Error('获取挑战失败'); const clone = setKeyResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await setKeyResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!setKeySnapshot.parsed) {
const msg = (setKeySnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
throw new Error(mapped);
} }
const chal = await chalResp.json(); const setKeyJson = setKeySnapshot.parsed;
const nonceBytes = new Uint8Array(base64ToArrayBuffer(chal.nonce)); if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
const saltBytes = new Uint8Array(base64ToArrayBuffer(chal.salt));
// Step 2: derive secret and compute HMAC const aesKey = await importAesKey(aesKeyRaw);
const derived = await deriveKey(password, saltBytes, 100000, 32); const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
const hmac = await hmacSha256(derived, nonceBytes); const obj = { username, password };
const hmacB64 = arrayBufferToBase64(hmac); 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/secure-submit/', {
const submitResp = await fetch('/accounts/login/submit/', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username, hmac: hmacB64 })
}); });
const submitJson = await submitResp.json(); const submitSnapshot = await (async () => {
const clone = submitResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await submitResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!submitSnapshot.parsed) {
const msg = (submitSnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
throw new Error(mapped);
}
const submitJson = submitSnapshot.parsed;
if (!submitResp.ok || !submitJson.ok) { if (!submitResp.ok || !submitJson.ok) {
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
throw new Error(submitJson.message || '登录失败'); throw new Error(submitJson.message || '登录失败');
} }
// Redirect to home with user_id
window.location.href = submitJson.redirect_url; window.location.href = submitJson.redirect_url;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -123,3 +129,8 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
btn.disabled = false; 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); } .container { max-width: 360px; margin: 12vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
h1 { font-size: 20px; margin: 0 0 16px; } h1 { font-size: 20px; margin: 0 0 16px; }
label { display: block; margin: 12px 0 6px; color: #333; } label { display: block; margin: 12px 0 6px; color: #333; }
input { width: 100%; padding: 10px 12px; border: 1px solid #dcdde1; border-radius: 6px; } input { width: 100%; padding: 10px 0px; border: 1px solid #dcdde1; border-radius: 6px; }
button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; } button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
button:disabled { background: #9bbcf0; cursor: not-allowed; } button:disabled { background: #9bbcf0; cursor: not-allowed; }
.error { color: #d93025; margin-top: 10px; min-height: 20px; } .error { color: #d93025; margin-top: 10px; min-height: 20px; }
@@ -30,9 +30,22 @@
<label for="password">密码</label> <label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required /> <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> <button id="loginBtn" type="submit">登录</button>
<div id="error" class="error"></div> <div id="error" class="error"></div>
</form> </form>
<div class="hint" style="text-align:center; margin-top:12px;">
还没有账号?
<a href="/accounts/register/" style="color:#2d8cf0; text-decoration:none;">去注册</a>
</div>
</div> </div>
<script src="{% static 'accounts/login.js' %}"></script> <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 = [ urlpatterns = [
path("login/", views.login_page, name="login"), path("login/", views.login_page, name="login"),
path("challenge/", views.challenge, name="challenge"), path("pubkey/", views.pubkey, name="pubkey"),
path("login/submit/", views.login_submit, name="login_submit"), 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("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 base64
import json import json
import os import os
import hmac import io
import random
import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
@@ -10,7 +14,8 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from django.conf import settings from django.conf import settings
from .es_client import get_user_by_username from .es_client import get_user_by_username
from .crypto import 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"]) @require_http_methods(["GET"])
@@ -19,90 +24,108 @@ def login_page(request):
return render(request, "accounts/login.html") return render(request, "accounts/login.html")
@require_http_methods(["POST"]) @require_http_methods(["GET"])
@csrf_protect @ensure_csrf_cookie
def challenge(request): 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: try:
payload = json.loads(request.body.decode("utf-8")) from captcha.image import ImageCaptcha
except json.JSONDecodeError: except Exception:
return HttpResponseBadRequest("Invalid JSON") return JsonResponse({"ok": False, "message": "captcha unavailable"}, status=500)
code = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(5))
username = payload.get("username", "").strip() request.session["captcha_code"] = code
if not username: img = ImageCaptcha(width=160, height=60)
return HttpResponseBadRequest("Username required") image = img.generate_image(code)
buf = io.BytesIO()
# Generate nonce and compute per-username salt image.save(buf, format="PNG")
nonce = os.urandom(16) b64 = base64.b64encode(buf.getvalue()).decode("ascii")
salt = salt_for_username(username) return JsonResponse({"ok": True, "image_b64": b64})
# 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"),
})
@require_http_methods(["POST"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
def login_submit(request): def set_session_key(request):
try: try:
payload = json.loads(request.body.decode("utf-8")) payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON") return HttpResponseBadRequest("Invalid JSON")
enc_key_b64 = payload.get("encrypted_key", "")
username = payload.get("username", "").strip() if not enc_key_b64:
client_hmac_b64 = payload.get("hmac", "")
if not username or not client_hmac_b64:
return HttpResponseBadRequest("Missing fields") 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 @require_http_methods(["POST"])
session_username = request.session.get("challenge_username") @csrf_protect
nonce_b64 = request.session.get("challenge_nonce") def secure_login_submit(request):
if not session_username or not nonce_b64 or session_username != username: try:
return HttpResponseBadRequest("Challenge not found or mismatched user") payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
# Lookup user in ES (placeholder) 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) user = get_user_by_username(username)
if not user: if not user:
return JsonResponse({"ok": False, "message": "User not found"}, status=401) request.session["login_failed_once"] = True
return JsonResponse({"ok": False, "message": "用户不存在", "captcha_required": True}, status=401)
# Server-side HMAC verification if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""):
try: request.session["login_failed_once"] = True
nonce = base64.b64decode(nonce_b64) return JsonResponse({"ok": False, "message": "账户或密码错误", "captcha_required": True}, status=401)
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
try: try:
request.session.cycle_key() request.session.cycle_key()
except Exception: except Exception:
pass pass
request.session["user_id"] = user["user_id"] request.session["user_id"] = user["user_id"]
request.session["username"] = user["username"] request.session["username"] = user["username"]
try: try:
request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1 request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1
except Exception: except Exception:
request.session["permission"] = 1 request.session["permission"] = 1
if "session_enc_key_b64" in request.session:
# Clear challenge to prevent reuse del request.session["session_enc_key_b64"]
for k in ("challenge_username", "challenge_nonce"): if "rsa_private_pem_b64" in request.session:
if k in request.session: del request.session["rsa_private_pem_b64"]
del request.session[k] if "login_failed_once" in request.session:
del request.session["login_failed_once"]
return JsonResponse({ if "captcha_code" in request.session:
"ok": True, del request.session["captcha_code"]
"redirect_url": f"/main/home/?user_id={user['user_id']}", return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})
})
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -147,3 +170,132 @@ def logout(request):
pass 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,8 +34,13 @@ class UserDocument(Document):
"""用户数据文档映射""" """用户数据文档映射"""
user_id = fields.LongField() user_id = fields.LongField()
username = fields.KeywordField() username = fields.KeywordField()
password = fields.KeywordField() email = fields.KeywordField()
permission = fields.IntegerField() 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: class Django:
model = User model = User
@@ -45,6 +50,18 @@ class UserDocument(Document):
@GLOBAL_INDEX.doc_type @GLOBAL_INDEX.doc_type
class GlobalDocument(Document): class GlobalDocument(Document):
type_list = fields.KeywordField() type_list = fields.KeywordField()
keys_list = fields.KeywordField(multi=True)
class Django: class Django:
model = ElasticNews 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 import Elasticsearch
from elasticsearch_dsl import connections from elasticsearch_dsl import connections
import os 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 from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
import hashlib import hashlib
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
import uuid
import json import json
# 使用环境变量配置ES连接默认为本机 # 使用环境变量配置ES连接默认为本机
@@ -63,10 +65,12 @@ def create_index_with_mapping():
# --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)--- # --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)---
# 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断 # 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断
_salt_b64, _hash_b64 = hash_password_random_salt("admin")
admin_user = { admin_user = {
"user_id": 0, "user_id": 0,
"username": "admin", "username": "admin",
"password": "admin", # ⚠️ 生产环境务必加密! "password_hash": _hash_b64,
"password_salt": _salt_b64,
"permission": 0 "permission": 0
} }
# 可选:检查 admin 是否已存在(根据 user_id 或 username # 可选:检查 admin 是否已存在(根据 user_id 或 username
@@ -112,6 +116,136 @@ def ensure_type_in_list(type_name: str):
except Exception: except Exception:
return False 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): def get_doc_id(data):
""" """
根据数据内容生成唯一ID用于去重 根据数据内容生成唯一ID用于去重
@@ -513,11 +647,21 @@ def write_user_data(user_data):
perm_val = int(user_data.get('permission', 1)) perm_val = int(user_data.get('permission', 1))
except Exception: except Exception:
perm_val = 1 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 = UserDocument(
user_id=user_data.get('user_id'), user_id=user_data.get('user_id'),
username=user_data.get('username'), username=user_data.get('username'),
password=user_data.get('password'), password_hash=pwd_hash_b64,
permission=perm_val 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() user.save()
print(f"用户数据写入成功: {user_data.get('username')}") print(f"用户数据写入成功: {user_data.get('username')}")
@@ -535,11 +679,10 @@ def get_user_by_id(user_id):
if response.hits: if response.hits:
hit = response.hits[0] hit = response.hits[0]
return { return {
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"password": hit.password, "permission": hit.permission
"permission": hit.permission }
}
return None return None
except Exception as e: except Exception as e:
@@ -566,7 +709,8 @@ def get_user_by_username(username):
return { return {
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"password": hit.password, "password_hash": getattr(hit, 'password_hash', None),
"password_salt": getattr(hit, 'password_salt', None),
"permission": int(hit.permission) "permission": int(hit.permission)
} }
return None return None
@@ -586,7 +730,10 @@ def get_all_users():
users.append({ users.append({
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"permission": int(hit.permission) "permission": int(hit.permission),
"email": getattr(hit, 'email', None),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
}) })
return users return users
@@ -605,6 +752,9 @@ def get_user_by_id(user_id):
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"permission": int(hit.permission), "permission": int(hit.permission),
"email": getattr(hit, 'email', None),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
} }
return None return None
except Exception as e: except Exception as e:
@@ -639,7 +789,9 @@ def update_user_by_id(user_id, username=None, permission=None, password=None):
if permission is not None: if permission is not None:
doc.permission = int(permission) doc.permission = int(permission)
if password is not None: 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() doc.save()
return True return True
return False return False

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,12 @@ urlpatterns = [
# 管理页面 # 管理页面
path('manage/', views.manage_page, name='manage_page'), path('manage/', views.manage_page, name='manage_page'),
path('user_manage/', views.user_manage, name='user_manage'), 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'), path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),

View File

@@ -222,16 +222,27 @@ def add_user(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def get_users(request): def get_users(request):
if request.session.get("user_id") is None: uid = request.session.get("user_id")
if uid is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401) return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try: try:
is_admin = int(request.session.get("permission", 1)) == 0
requester = get_user_by_id(uid) or {}
mgr_keys = set(requester.get("manage_key") or [])
q = (request.GET.get("search") or "").strip() q = (request.GET.get("search") or "").strip()
users = get_all_users() users = get_all_users()
if is_admin:
filtered = users
elif mgr_keys:
def match_manage(user):
ukeys = set(user.get("key") or [])
return bool(ukeys & mgr_keys)
filtered = [u for u in users if match_manage(u)]
else:
filtered = [u for u in users if str(u.get("user_id")) == str(uid)]
if q: if q:
users = [u for u in users if q in str(u.get("username", ""))] filtered = [u for u in filtered if q in str(u.get("username", ""))]
return JsonResponse({"status": "success", "data": users}) return JsonResponse({"status": "success", "data": filtered})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -239,10 +250,9 @@ def get_users(request):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
def update_user_by_id_view(request, user_id): def update_user_by_id_view(request, user_id):
if request.session.get("user_id") is None: uid = request.session.get("user_id")
if uid is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401) return JsonResponse({"status": "error", "message": "未登录"}, status=401)
if int(request.session.get("permission", 1)) != 0:
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
try: try:
payload = json.loads(request.body.decode("utf-8")) payload = json.loads(request.body.decode("utf-8"))
except Exception: except Exception:
@@ -250,21 +260,41 @@ def update_user_by_id_view(request, user_id):
new_username = (payload.get("username") or "").strip() new_username = (payload.get("username") or "").strip()
new_permission = payload.get("permission") new_permission = payload.get("permission")
new_password = (payload.get("password") or "").strip() new_password = (payload.get("password") or "").strip()
if new_username:
other = get_user_by_username(new_username)
if other and int(other.get("user_id", -1)) != int(user_id):
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
if new_password and len(new_password) < 6: if new_password and len(new_password) < 6:
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400) return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
ok = es_update_user_by_id(
user_id, is_admin = int(request.session.get("permission", 1)) == 0
username=new_username if new_username else None, requester = get_user_by_id(uid) or {}
permission=int(new_permission) if new_permission is not None else None, target = get_user_by_id(user_id) or {}
password=new_password if new_password else None, requester_mgr = set(requester.get("manage_key") or [])
) target_keys = set(target.get("key") or [])
if not ok:
return JsonResponse({"status": "error", "message": "用户更新失败"}, status=500) if is_admin:
return JsonResponse({"status": "success", "message": "用户更新成功"}) if new_username:
other = get_user_by_username(new_username)
if other and int(other.get("user_id", -1)) != int(user_id):
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
ok = es_update_user_by_id(
user_id,
username=new_username if new_username else None,
permission=int(new_permission) if new_permission is not None else None,
password=new_password if new_password else None,
)
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
if str(uid) == str(user_id):
if not new_password:
return JsonResponse({"status": "error", "message": "仅允许修改密码"}, status=400)
ok = es_update_user_by_id(user_id, password=new_password)
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
if requester_mgr and (target_keys & requester_mgr):
if not new_password or new_username or new_permission is not None:
return JsonResponse({"status": "error", "message": "导师仅允许修改密码"}, status=403)
ok = es_update_user_by_id(user_id, password=new_password)
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
@require_http_methods(["POST"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
@@ -491,13 +521,32 @@ def manage_page(request):
from django.shortcuts import redirect from django.shortcuts import redirect
return redirect("/accounts/login/") return redirect("/accounts/login/")
if int(request.session.get("permission", 1)) != 0: is_admin = int(request.session.get("permission", 1)) == 0
from django.shortcuts import redirect if is_admin:
return redirect("/main/home/") raw_results = search_all()
raw_results = search_all() else:
# if not is_admin: uid = str(session_user_id)
# uid = str(session_user_id) raw_results = [r for r in search_all() if str(r.get("writer_id", "")) == uid]
# raw_results = [r for r in raw_results 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 = [] results = []
for r in raw_results: for r in raw_results:
@@ -575,6 +624,25 @@ def analytics_recent_view(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@ensure_csrf_cookie @ensure_csrf_cookie
def user_manage(request): 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") session_user_id = request.session.get("user_id")
if session_user_id is None: if session_user_id is None:
from django.shortcuts import redirect from django.shortcuts import redirect
@@ -584,4 +652,82 @@ def user_manage(request):
return redirect("/main/home/") return redirect("/main/home/")
user_id_qs = request.GET.get("user_id") user_id_qs = request.GET.get("user_id")
context = {"user_id": user_id_qs or session_user_id} context = {"user_id": user_id_qs or session_user_id}
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> <title>数据管理系统</title>
<script src="{% static 'vendor/echarts.min.js' %}"></script> <script src="{% static 'vendor/echarts.min.js' %}"></script>
<style> <style>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */ /* 导航栏样式 */
.sidebar { .sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
position: fixed; flex-direction: column;align-items: center;}
top: 0; .user-id {text-align: center;margin-bottom: 0px;}
left: 0; .sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
width: 180px; .navigation-links {width: 100%;margin-top: 60px;}
height: 100vh;
background: #1e1e2e;
color: white;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
}
.user-id {
text-align: center;
margin-bottom: 0px;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #add8e6;
text-align: center;
margin-bottom: 20px;
}
.navigation-links {
width: 100%;
margin-top: 60px;
}
.sidebar a, .sidebar a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
color: #8be9fd;
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar a:hover, .sidebar a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */ /* 主内容区 */
.main-content { .main-content {margin-left: 200px;padding: 20px;color: #333;}
margin-left: 200px; .card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
padding: 20px; .grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
color: #333; .grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
} .header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.card { .legend {display: flex;gap: 12px;align-items: center;}
background: #fff; .legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
border-radius: 14px; .muted {color: #6b7280;font-size: 12px;}
box-shadow: 0 10px 24px rgba(31,35,40,0.08); .btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
padding: 20px; .btn-primary {background: #4f46e5;color: #fff;}
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.badge {
background: #eef2ff;
color: #3730a3;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
}
.legend {
display: flex;
gap: 12px;
align-items: center;
}
.legend .dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.muted {
color: #6b7280;
font-size: 12px;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-primary {
background: #4f46e5;
color: #fff;
}
</style> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a> <a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a> <a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a> <a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a>
{% if is_admin %}
<a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a> <a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a>
{% if is_admin %}
<a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
{% endif %} {% endif %}
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -156,7 +56,7 @@
<div class="main-content"> <div class="main-content">
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h2>主页</h2> <h2>师生共创系统</h2>
<span class="badge">用户:{{ user_id }}</span> <span class="badge">用户:{{ user_id }}</span>
</div> </div>
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div> <div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>

View File

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