Compare commits
76 Commits
9dcd353815
...
Django
| Author | SHA1 | Date | |
|---|---|---|---|
| 45005fcc92 | |||
| df18bdfa7e | |||
| 281ade6ac9 | |||
| 835426b133 | |||
| d001fec21e | |||
| 253de3639c | |||
| a0507b8054 | |||
| 9f803880fa | |||
| 71fe964476 | |||
| 0f5c8c08ff | |||
| e032253327 | |||
| 3f108e2138 | |||
| 2d913e397f | |||
| 74bc8aa498 | |||
| 5d747faee1 | |||
| 7bd8eeca77 | |||
| 782b2dd82e | |||
| f9c0abb3a0 | |||
| c5300591e6 | |||
| f96629566f | |||
| 8d581ac638 | |||
| acc80074ea | |||
| 62d28be032 | |||
| 5b956e1365 | |||
| 7485ba16e6 | |||
| ac580599b3 | |||
| faae7032f1 | |||
| 615d9433fe | |||
| d755f4710f | |||
| 3e598fe0a1 | |||
| 5a9d98282a | |||
| 8f9fc9c914 | |||
| b5d76be37b | |||
| 100531ddd1 | |||
| 68bc4b54f5 | |||
| 5153017a80 | |||
| 2c58c1be29 | |||
| 8c14544ca1 | |||
| 42bacbbc81 | |||
| 32ff920921 | |||
| 6e332f248f | |||
| 1392275337 | |||
| f93286a5fe | |||
| dc57d88779 | |||
| 9665e81698 | |||
| 7afc6ba06b | |||
| 4ef3523ea9 | |||
| 49a5e82202 | |||
| df471e6636 | |||
| 8457f24d21 | |||
| cb28d45cd1 | |||
| ec7bc64bfa | |||
| caba4482bc | |||
| c15c29850c | |||
| 259246028e | |||
| 14788fd59d | |||
| a896613726 | |||
| 04b1df2130 | |||
| 0e23fe8266 | |||
| 37f8c442b2 | |||
| 9342f37b45 | |||
| 0f1cfdd803 | |||
| 4efaf7ac55 | |||
| 0564593a84 | |||
| fc34c37763 | |||
| 64219a9a24 | |||
| 31d5ca5a01 | |||
| fb5e8a6588 | |||
| 1e041cad3b | |||
| 046b649aec | |||
| e1152bdc86 | |||
| ca9da9f7aa | |||
| 62ee8399e8 | |||
| 40317b47ec | |||
| 31c0371da3 | |||
| 98056b2515 |
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
db.sqlite3
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
129
.gitea/workflows/ci.yml
Normal file
129
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Required Secrets:
|
||||||
|
# - DJANGO_SECRET_KEY: Django Secret Key
|
||||||
|
# - token: Gitea API token for creating releases
|
||||||
|
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
|
||||||
|
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
|
||||||
|
# - WEBDAV_USER: WebDAV username
|
||||||
|
# - WEBDAV_PASSWORD: WebDAV password
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- Django
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 版本号(如 0.2.2),为空则自动生成
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-ci:
|
||||||
|
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
env:
|
||||||
|
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
|
||||||
|
DJANGO_DEBUG: "False"
|
||||||
|
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
|
||||||
|
IMAGE_NAME: achievement_inputing_ci
|
||||||
|
ARTIFACT_DIR: artifacts
|
||||||
|
# 请在 Secrets 中配置 ALIST_PUBLIC_URL,例如 http://139.224.69.213:8080/d/ci
|
||||||
|
DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
|
||||||
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
|
GITEA_REPO: ${{ github.repository }}
|
||||||
|
RELEASE_TOKEN: ${{ secrets.token }}
|
||||||
|
steps:
|
||||||
|
- name: Ensure source present
|
||||||
|
env:
|
||||||
|
SERVER: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
REF: ${{ github.ref }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -f "$GITHUB_WORKSPACE/Dockerfile" ]; then exit 0; fi
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
git init .
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
git fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||||
|
else
|
||||||
|
git -c http.extraHeader="Authorization: Bearer $TOKEN" fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||||
|
fi
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Derive version
|
||||||
|
run: |
|
||||||
|
msg="${{ github.event.head_commit.message }}"
|
||||||
|
ver_input="${{ github.event.inputs.version }}"
|
||||||
|
ver=""
|
||||||
|
if [ -n "$ver_input" ]; then
|
||||||
|
ver="$ver_input"
|
||||||
|
else
|
||||||
|
ver=$(echo "$msg" | grep -Eo "\[[0-9]+(\.[0-9]+){1,}\]" | head -n1 | tr -d '[]')
|
||||||
|
fi
|
||||||
|
if [ -z "$ver" ]; then
|
||||||
|
ver="$(date +%Y%m%d%H%M)-${GITHUB_SHA:0:7}"
|
||||||
|
fi
|
||||||
|
echo "VERSION=$ver" >> $GITHUB_ENV
|
||||||
|
- name: Build application image
|
||||||
|
run: |
|
||||||
|
docker build -t "$IMAGE_NAME:$VERSION" -f "$GITHUB_WORKSPACE/Dockerfile" "$GITHUB_WORKSPACE"
|
||||||
|
- name: Output image info
|
||||||
|
run: |
|
||||||
|
docker image inspect "$IMAGE_NAME:$VERSION" --format '{{.Id}} {{.Size}}'
|
||||||
|
- name: Export image tar
|
||||||
|
run: |
|
||||||
|
ART="achievement_inputing_ci_${VERSION}.tar"
|
||||||
|
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
|
||||||
|
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
|
||||||
|
- name: Publish artifact locally
|
||||||
|
run: |
|
||||||
|
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
|
||||||
|
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
|
||||||
|
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||||
|
- name: Publish to WebDAV
|
||||||
|
env:
|
||||||
|
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
|
||||||
|
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||||
|
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||||
|
FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||||
|
|
||||||
|
# 检查必要的 secrets 是否存在
|
||||||
|
if [ -z "$WEBDAV_URL" ]; then
|
||||||
|
echo "Error: WEBDAV_URL secret is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 确保 URL 以 / 结尾
|
||||||
|
case "$WEBDAV_URL" in
|
||||||
|
*/) ;;
|
||||||
|
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Uploading $ART to $WEBDAV_URL..."
|
||||||
|
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
|
||||||
|
echo "Upload success."
|
||||||
|
- name: Create release with download link
|
||||||
|
if: env.RELEASE_TOKEN != ''
|
||||||
|
run: |
|
||||||
|
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||||
|
BRANCH=${GITHUB_REF#refs/heads/}
|
||||||
|
TAG="$VERSION"
|
||||||
|
NAME="$VERSION"
|
||||||
|
BASE="${DOWNLOAD_BASE%/}"
|
||||||
|
DL="$BASE/$ART"
|
||||||
|
echo "download: $DL"
|
||||||
|
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
|
||||||
|
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"
|
||||||
@@ -22,17 +22,18 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-p^*6tak7wy1z#bw__#o^s5hsydearm=(-s(km!-61j2(#)*+-t'
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-p^*6tak7wy1z#bw__#o^s5hsydearm=(-s(km!-61j2(#)*+-t')
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() == 'true'
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').split(',')
|
||||||
|
|
||||||
|
|
||||||
# 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',
|
||||||
@@ -47,7 +48,9 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'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',
|
||||||
@@ -121,6 +124,7 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
# Media files (uploaded images)
|
# Media files (uploaded images)
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
@@ -134,6 +138,8 @@ SESSION_COOKIE_SECURE = False if DEBUG else True
|
|||||||
CSRF_COOKIE_SECURE = False if DEBUG else True
|
CSRF_COOKIE_SECURE = False if DEBUG else True
|
||||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS') else []
|
||||||
|
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
@@ -142,9 +148,12 @@ X_FRAME_OPTIONS = 'DENY'
|
|||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
# Elasticsearch configuration
|
# Elasticsearch configuration
|
||||||
|
_ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')
|
||||||
|
if not (_ES_URL.startswith('http://') or _ES_URL.startswith('https://')):
|
||||||
|
_ES_URL = 'http://' + _ES_URL
|
||||||
ELASTICSEARCH_DSL = {
|
ELASTICSEARCH_DSL = {
|
||||||
'default': {
|
'default': {
|
||||||
'hosts': 'localhost:9200'
|
'hosts': _ES_URL
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,3 +166,4 @@ ELASTICSEARCH_INDEX_NAMES = {
|
|||||||
# AI Studio/OpenAI client settings
|
# AI Studio/OpenAI client settings
|
||||||
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
|
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
|
||||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
|
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
|
||||||
|
OPENAI_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& apt-get purge -y --auto-remove build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["sh","-c","python manage.py migrate && python manage.py collectstatic --noinput && gunicorn Achievement_Inputing.wsgi:application --bind 0.0.0.0:8000 --workers 3"]
|
||||||
1
README.md
Normal file
1
README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python manage.py shell -c "from elastic.es_connect import create_index_with_mapping; create_index_with_mapping()"
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
82
accounts/templates/accounts/register.html
Normal file
82
accounts/templates/accounts/register.html
Normal 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>
|
||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
@@ -1,104 +1,131 @@
|
|||||||
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
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.views.decorators.csrf import csrf_protect
|
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"])
|
||||||
|
@ensure_csrf_cookie
|
||||||
def login_page(request):
|
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"]
|
||||||
request.session["permission"] = user["permission"]
|
try:
|
||||||
|
request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1
|
||||||
# Clear challenge to prevent reuse
|
except Exception:
|
||||||
for k in ("challenge_username", "challenge_nonce"):
|
request.session["permission"] = 1
|
||||||
if k in request.session:
|
if "session_enc_key_b64" in request.session:
|
||||||
del request.session[k]
|
del request.session["session_enc_key_b64"]
|
||||||
|
if "rsa_private_pem_b64" in request.session:
|
||||||
return JsonResponse({
|
del request.session["rsa_private_pem_b64"]
|
||||||
"ok": True,
|
if "login_failed_once" in request.session:
|
||||||
"redirect_url": f"/main/home/?user_id={user['user_id']}",
|
del request.session["login_failed_once"]
|
||||||
})
|
if "captcha_code" in request.session:
|
||||||
|
del request.session["captcha_code"]
|
||||||
|
return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -143,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)
|
||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
@@ -17,10 +17,8 @@ class ElasticConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 延迟导入,避免循环导入或过早加载
|
# 延迟导入,避免循环导入或过早加载
|
||||||
from .es_connect import create_index_with_mapping, get_type_list
|
from .es_connect import create_index_with_mapping
|
||||||
try:
|
try:
|
||||||
create_index_with_mapping()
|
create_index_with_mapping()
|
||||||
types = get_type_list()
|
|
||||||
print(f"🔎 启动时 type_list: {types}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ ES 初始化失败: {e}")
|
print(f"❌ ES 初始化失败: {e}")
|
||||||
@@ -15,6 +15,8 @@ GLOBAL_INDEX.settings(number_of_shards=1, number_of_replicas=0)
|
|||||||
class AchievementDocument(Document):
|
class AchievementDocument(Document):
|
||||||
"""获奖数据文档映射"""
|
"""获奖数据文档映射"""
|
||||||
writer_id = fields.TextField(fields={'keyword': {'type': 'keyword'}})
|
writer_id = fields.TextField(fields={'keyword': {'type': 'keyword'}})
|
||||||
|
time = fields.DateField()
|
||||||
|
|
||||||
data = fields.TextField(
|
data = fields.TextField(
|
||||||
analyzer='ik_max_word',
|
analyzer='ik_max_word',
|
||||||
search_analyzer='ik_smart',
|
search_analyzer='ik_smart',
|
||||||
@@ -32,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
|
||||||
@@ -43,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
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ Django版本的ES连接和操作模块
|
|||||||
"""
|
"""
|
||||||
from elasticsearch import Elasticsearch
|
from elasticsearch import Elasticsearch
|
||||||
from elasticsearch_dsl import connections
|
from elasticsearch_dsl import connections
|
||||||
from .documents import AchievementDocument, UserDocument, GlobalDocument
|
import os
|
||||||
|
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, timedelta
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
# 使用Django的ES连接配置
|
# 使用环境变量配置ES连接,默认为本机
|
||||||
connections.create_connection(hosts=['localhost:9200'])
|
_ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')
|
||||||
|
if not (_ES_URL.startswith('http://') or _ES_URL.startswith('https://')):
|
||||||
|
_ES_URL = 'http://' + _ES_URL
|
||||||
|
connections.create_connection(hosts=[_ES_URL])
|
||||||
|
|
||||||
# 获取默认的ES客户端
|
# 获取默认的ES客户端
|
||||||
es = connections.get_connection()
|
es = connections.get_connection()
|
||||||
@@ -57,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)
|
||||||
@@ -106,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(用于去重)
|
||||||
@@ -132,11 +272,11 @@ def insert_data(data):
|
|||||||
bool: 插入成功返回True,失败返回False
|
bool: 插入成功返回True,失败返回False
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 使用Django-elasticsearch-dsl的方式插入数据
|
|
||||||
achievement = AchievementDocument(
|
achievement = AchievementDocument(
|
||||||
writer_id=data.get('writer_id', ''),
|
writer_id=data.get('writer_id', ''),
|
||||||
data=data.get('data', ''),
|
data=data.get('data', ''),
|
||||||
image=data.get('image', '')
|
image=data.get('image', ''),
|
||||||
|
time=datetime.now(timezone.utc).isoformat()
|
||||||
)
|
)
|
||||||
achievement.save()
|
achievement.save()
|
||||||
print(f"文档写入成功,内容: {data}")
|
print(f"文档写入成功,内容: {data}")
|
||||||
@@ -230,7 +370,7 @@ def update_by_id(doc_id, updated_data):
|
|||||||
try:
|
try:
|
||||||
# 获取文档
|
# 获取文档
|
||||||
achievement = AchievementDocument.get(id=doc_id)
|
achievement = AchievementDocument.get(id=doc_id)
|
||||||
|
print(doc_id)
|
||||||
# 更新字段
|
# 更新字段
|
||||||
if 'writer_id' in updated_data:
|
if 'writer_id' in updated_data:
|
||||||
achievement.writer_id = updated_data['writer_id']
|
achievement.writer_id = updated_data['writer_id']
|
||||||
@@ -303,6 +443,194 @@ def search_by_any_field(keyword):
|
|||||||
print(f"模糊搜索失败: {str(e)}")
|
print(f"模糊搜索失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _type_filters_from_list(limit: int = None):
|
||||||
|
try:
|
||||||
|
types = get_type_list()
|
||||||
|
except Exception:
|
||||||
|
types = ['软著', '专利', '奖状']
|
||||||
|
if isinstance(limit, int) and limit > 0:
|
||||||
|
types = types[:limit]
|
||||||
|
filters = {}
|
||||||
|
for t in types:
|
||||||
|
key = str(t)
|
||||||
|
# 精确匹配键与值之间的关系,避免其它字段中的同名值造成误匹配
|
||||||
|
pattern = f'*"数据类型": "{key}"*'
|
||||||
|
filters[key] = {"wildcard": {"data.keyword": {"value": pattern}}}
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def analytics_trend(gte: str = None, lte: str = None, interval: str = "day"):
|
||||||
|
try:
|
||||||
|
search = AchievementDocument.search()
|
||||||
|
body = {
|
||||||
|
"size": 0,
|
||||||
|
"aggs": {
|
||||||
|
"trend": {
|
||||||
|
"date_histogram": {
|
||||||
|
"field": "time",
|
||||||
|
"calendar_interval": interval,
|
||||||
|
"min_doc_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gte or lte:
|
||||||
|
rng = {}
|
||||||
|
if gte:
|
||||||
|
rng["gte"] = gte
|
||||||
|
if lte:
|
||||||
|
rng["lte"] = lte
|
||||||
|
body["query"] = {"range": {"time": rng}}
|
||||||
|
search = search.update_from_dict(body)
|
||||||
|
resp = search.execute()
|
||||||
|
buckets = resp.aggregations.trend.buckets if hasattr(resp, 'aggregations') else []
|
||||||
|
return [{"key_as_string": b.key_as_string, "key": b.key, "doc_count": b.doc_count} for b in buckets]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"分析趋势失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def analytics_types(gte: str = None, lte: str = None, size: int = 10):
|
||||||
|
try:
|
||||||
|
filters = _type_filters_from_list(limit=size)
|
||||||
|
body = {
|
||||||
|
"size": 0,
|
||||||
|
"aggs": {
|
||||||
|
"by_type": {
|
||||||
|
"filters": {
|
||||||
|
"filters": filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gte or lte:
|
||||||
|
rng = {}
|
||||||
|
if gte:
|
||||||
|
rng["gte"] = gte
|
||||||
|
if lte:
|
||||||
|
rng["lte"] = lte
|
||||||
|
body["query"] = {"range": {"time": rng}}
|
||||||
|
resp = es.search(index=DATA_INDEX_NAME, body=body)
|
||||||
|
buckets = resp.get("aggregations", {}).get("by_type", {}).get("buckets", {})
|
||||||
|
out = []
|
||||||
|
for k, v in buckets.items():
|
||||||
|
try:
|
||||||
|
out.append({"key": k, "doc_count": int(v.get("doc_count", 0))})
|
||||||
|
except Exception:
|
||||||
|
out.append({"key": str(k), "doc_count": 0})
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f"分析类型占比失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def analytics_types_trend(gte: str = None, lte: str = None, interval: str = "week", size: int = 8):
|
||||||
|
try:
|
||||||
|
filters = _type_filters_from_list(limit=size)
|
||||||
|
body = {
|
||||||
|
"size": 0,
|
||||||
|
"aggs": {
|
||||||
|
"by_interval": {
|
||||||
|
"date_histogram": {
|
||||||
|
"field": "time",
|
||||||
|
"calendar_interval": interval,
|
||||||
|
"min_doc_count": 0
|
||||||
|
},
|
||||||
|
"aggs": {
|
||||||
|
"by_type": {
|
||||||
|
"filters": {"filters": filters}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gte or lte:
|
||||||
|
rng = {}
|
||||||
|
if gte:
|
||||||
|
rng["gte"] = gte
|
||||||
|
if lte:
|
||||||
|
rng["lte"] = lte
|
||||||
|
body["query"] = {"range": {"time": rng}}
|
||||||
|
resp = es.search(index=DATA_INDEX_NAME, body=body)
|
||||||
|
by_interval = resp.get("aggregations", {}).get("by_interval", {}).get("buckets", [])
|
||||||
|
out = []
|
||||||
|
for ib in by_interval:
|
||||||
|
t_buckets = ib.get("by_type", {}).get("buckets", {})
|
||||||
|
types_arr = []
|
||||||
|
for k, v in t_buckets.items():
|
||||||
|
types_arr.append({"key": k, "doc_count": int(v.get("doc_count", 0))})
|
||||||
|
out.append({
|
||||||
|
"key_as_string": ib.get("key_as_string"),
|
||||||
|
"key": ib.get("key"),
|
||||||
|
"doc_count": ib.get("doc_count", 0),
|
||||||
|
"types": types_arr
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f"分析类型变化失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
|
||||||
|
try:
|
||||||
|
def _extract_type(s: str):
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
obj = json.loads(s)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
v = obj.get("数据类型")
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
m = re.search(r'"数据类型"\s*:\s*"([^"]+)"', s)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
search = AchievementDocument.search()
|
||||||
|
body = {
|
||||||
|
"size": max(1, min(limit, 100)),
|
||||||
|
"sort": [{"time": {"order": "desc"}}]
|
||||||
|
}
|
||||||
|
if gte or lte:
|
||||||
|
rng = {}
|
||||||
|
if gte:
|
||||||
|
rng["gte"] = gte
|
||||||
|
if lte:
|
||||||
|
rng["lte"] = lte
|
||||||
|
body["query"] = {"range": {"time": rng}}
|
||||||
|
search = search.update_from_dict(body)
|
||||||
|
resp = search.execute()
|
||||||
|
results = []
|
||||||
|
for hit in resp:
|
||||||
|
w = getattr(hit, 'writer_id', '')
|
||||||
|
uname = None
|
||||||
|
try:
|
||||||
|
uname_lookup = get_user_by_id(w)
|
||||||
|
uname = (uname_lookup or {}).get("username")
|
||||||
|
except Exception:
|
||||||
|
uname = None
|
||||||
|
if not uname:
|
||||||
|
try:
|
||||||
|
uname_lookup = get_user_by_id(int(w))
|
||||||
|
uname = (uname_lookup or {}).get("username")
|
||||||
|
except Exception:
|
||||||
|
uname = None
|
||||||
|
tval = _extract_type(getattr(hit, 'data', ''))
|
||||||
|
results.append({
|
||||||
|
"_id": hit.meta.id,
|
||||||
|
"writer_id": w,
|
||||||
|
"username": uname or "",
|
||||||
|
"type": tval or "",
|
||||||
|
"time": getattr(hit, 'time', None)
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取最近活动失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
def write_user_data(user_data):
|
def write_user_data(user_data):
|
||||||
"""
|
"""
|
||||||
写入用户数据到 ES
|
写入用户数据到 ES
|
||||||
@@ -314,11 +642,26 @@ def write_user_data(user_data):
|
|||||||
bool: 写入成功返回True,失败返回False
|
bool: 写入成功返回True,失败返回False
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# enforce integer permission
|
||||||
|
try:
|
||||||
|
perm_val = int(user_data.get('permission', 1))
|
||||||
|
except Exception:
|
||||||
|
perm_val = 1
|
||||||
|
pwd = str(user_data.get('password') or '').strip()
|
||||||
|
pwd_hash_b64 = user_data.get('password_hash')
|
||||||
|
pwd_salt_b64 = user_data.get('password_salt')
|
||||||
|
if pwd:
|
||||||
|
salt_b64, hash_b64 = hash_password_random_salt(pwd)
|
||||||
|
pwd_hash_b64, pwd_salt_b64 = hash_b64, salt_b64
|
||||||
user = UserDocument(
|
user = 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=user_data.get('permission', 1)
|
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')}")
|
||||||
@@ -327,6 +670,25 @@ def write_user_data(user_data):
|
|||||||
print(f"用户数据写入失败: {str(e)}")
|
print(f"用户数据写入失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
try:
|
||||||
|
search = UserDocument.search()
|
||||||
|
search = search.query("term", user_id=user_id)
|
||||||
|
response = search.execute()
|
||||||
|
|
||||||
|
if response.hits:
|
||||||
|
hit = response.hits[0]
|
||||||
|
return {
|
||||||
|
"user_id": hit.user_id,
|
||||||
|
"username": hit.username,
|
||||||
|
"permission": hit.permission
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取用户数据失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_user_by_username(username):
|
def get_user_by_username(username):
|
||||||
"""
|
"""
|
||||||
根据用户名获取用户数据
|
根据用户名获取用户数据
|
||||||
@@ -347,8 +709,9 @@ 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),
|
||||||
"permission": hit.permission
|
"password_salt": getattr(hit, 'password_salt', None),
|
||||||
|
"permission": int(hit.permission)
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -367,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": 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
|
||||||
@@ -375,54 +741,60 @@ def get_all_users():
|
|||||||
print(f"获取所有用户失败: {str(e)}")
|
print(f"获取所有用户失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_user_by_username(username):
|
def get_user_by_id(user_id):
|
||||||
"""
|
|
||||||
根据用户名删除用户
|
|
||||||
|
|
||||||
参数:
|
|
||||||
username (str): 用户名
|
|
||||||
|
|
||||||
返回:
|
|
||||||
bool: 删除成功返回True,失败返回False
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
search = UserDocument.search()
|
search = UserDocument.search()
|
||||||
search = search.query("term", username=username)
|
search = search.query("term", user_id=int(user_id))
|
||||||
response = search.execute()
|
response = search.execute()
|
||||||
|
|
||||||
if response.hits:
|
if response.hits:
|
||||||
user = response.hits[0]
|
hit = response.hits[0]
|
||||||
user.delete()
|
return {
|
||||||
print(f"用户 {username} 删除成功")
|
"user_id": hit.user_id,
|
||||||
|
"username": hit.username,
|
||||||
|
"permission": int(hit.permission),
|
||||||
|
"email": getattr(hit, 'email', None),
|
||||||
|
"key": list(getattr(hit, 'key', []) or []),
|
||||||
|
"manage_key": list(getattr(hit, 'manage_key', []) or []),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取用户数据失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_user_by_id(user_id):
|
||||||
|
try:
|
||||||
|
search = UserDocument.search()
|
||||||
|
search = search.query("term", user_id=int(user_id))
|
||||||
|
response = search.execute()
|
||||||
|
if response.hits:
|
||||||
|
hit = response.hits[0]
|
||||||
|
doc = UserDocument.get(id=hit.meta.id)
|
||||||
|
doc.delete()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"删除用户失败: {str(e)}")
|
print(f"删除用户失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_user_permission(username, new_permission):
|
def update_user_by_id(user_id, username=None, permission=None, password=None):
|
||||||
"""
|
|
||||||
更新用户权限
|
|
||||||
|
|
||||||
参数:
|
|
||||||
username (str): 用户名
|
|
||||||
new_permission (int): 新权限级别
|
|
||||||
|
|
||||||
返回:
|
|
||||||
bool: 更新成功返回True,失败返回False
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
search = UserDocument.search()
|
search = UserDocument.search()
|
||||||
search = search.query("term", username=username)
|
search = search.query("term", user_id=int(user_id))
|
||||||
response = search.execute()
|
response = search.execute()
|
||||||
|
|
||||||
if response.hits:
|
if response.hits:
|
||||||
user = response.hits[0]
|
hit = response.hits[0]
|
||||||
user.permission = new_permission
|
doc = UserDocument.get(id=hit.meta.id)
|
||||||
user.save()
|
if username is not None:
|
||||||
print(f"用户 {username} 权限更新为 {new_permission}")
|
doc.username = username
|
||||||
|
if permission is not None:
|
||||||
|
doc.permission = int(permission)
|
||||||
|
if password is not None:
|
||||||
|
salt_b64, hash_b64 = hash_password_random_salt(str(password))
|
||||||
|
doc.password_hash = hash_b64
|
||||||
|
doc.password_salt = salt_b64
|
||||||
|
doc.save()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"更新用户权限失败: {str(e)}")
|
print(f"更新用户失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
INDEX_NAME = "wordsearch266666789"
|
INDEX_NAME = "wordsearch21"
|
||||||
USER_NAME = "users_123"
|
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 = "global11111"
|
GLOBAL_INDEX_NAME = "global11121"
|
||||||
|
|||||||
@@ -4,169 +4,64 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>数据管理</title>
|
<title>数据管理</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body{margin:0;font-family:sans-serif;background:#fafafa}
|
||||||
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,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
|
||||||
background:#fafafa;
|
.user-id{text-align:center;margin-bottom:0}
|
||||||
margin: 0;
|
.sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
|
||||||
padding: 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}
|
||||||
.container {
|
.sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
|
||||||
max-width: 1200px;
|
.main-content{margin-left:200px;padding:20px;color:#333}
|
||||||
margin: 0 auto;
|
.container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
|
||||||
background:#fff;
|
table{width:100%;border-collapse:collapse;margin-top:20px}
|
||||||
border-radius:10px;
|
th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
|
||||||
box-shadow:0 6px 18px rgba(0,0,0,0.06);
|
th{background:#f8f9fa;font-weight:600}
|
||||||
padding:20px;
|
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}
|
||||||
table {
|
.btn-primary{background:#1677ff;color:#fff}
|
||||||
width:100%;
|
.btn-danger{background:#ff4d4f;color:#fff}
|
||||||
border-collapse: collapse;
|
.btn-secondary{background:#f0f0f0;color:#333}
|
||||||
margin-top: 20px;
|
.muted{color:#666;font-size:12px}
|
||||||
}
|
.modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
|
||||||
th, td {
|
.modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
|
||||||
border-bottom:1px solid #eee;
|
textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
|
||||||
padding:12px 8px;
|
#kvForm{border:1px solid #eee;border-radius:6px;padding:8px;max-height:300px;overflow:auto}
|
||||||
text-align:left;
|
.search-container{background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px}
|
||||||
vertical-align: top;
|
.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}
|
||||||
th {
|
.search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
|
||||||
background-color: #f8f9fa;
|
.search-result.empty{background:#fff8e8}
|
||||||
font-weight: 600;
|
.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}
|
||||||
img {
|
@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
|
||||||
max-width: 120px;
|
@media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
|
||||||
border:1px solid #eee;
|
.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}
|
||||||
border-radius:6px;
|
.image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
|
||||||
}
|
.image-modal-content.dragging{cursor:grabbing}
|
||||||
.btn {
|
.image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
|
||||||
padding:6px 10px;
|
.image-modal-close:hover{color:#bbb}
|
||||||
border:none;
|
.zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
|
||||||
border-radius:6px;
|
.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}
|
||||||
cursor:pointer;
|
.zoom-btn:hover{background:rgba(255,255,255,.9)}
|
||||||
font-size: 14px;
|
.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}
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- 左侧固定栏目 -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="user-id">
|
||||||
|
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||||
|
</div>
|
||||||
|
<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-content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>数据管理</h2>
|
<h2>数据管理</h2>
|
||||||
<p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p>
|
<p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p>
|
||||||
@@ -194,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>
|
||||||
@@ -222,7 +117,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片放大模态框 -->
|
||||||
|
<div id="imageModal" class="image-modal">
|
||||||
|
<span class="image-modal-close">×</span>
|
||||||
|
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
|
||||||
|
<img class="image-modal-content" id="expandedImage">
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" id="zoomOutBtn">-</button>
|
||||||
|
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
|
||||||
|
<button class="zoom-btn" id="zoomInBtn">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -246,11 +153,33 @@ const editMsg = document.getElementById('editMsg');
|
|||||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||||
|
|
||||||
|
// 图片放大相关元素
|
||||||
|
const imageModal = document.getElementById('imageModal');
|
||||||
|
const expandedImage = document.getElementById('expandedImage');
|
||||||
|
const imageModalClose = document.querySelector('.image-modal-close');
|
||||||
|
const zoomInBtn = document.getElementById('zoomInBtn');
|
||||||
|
const zoomOutBtn = document.getElementById('zoomOutBtn');
|
||||||
|
const resetZoomBtn = document.getElementById('resetZoomBtn');
|
||||||
|
const zoomValue = document.getElementById('zoomValue');
|
||||||
|
|
||||||
// 全局变量
|
// 全局变量
|
||||||
let currentId = '';
|
let 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) {
|
||||||
@@ -260,6 +189,8 @@ async function performSearch(type) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentSearchQuery = query;
|
||||||
|
isFuzzySearch = type === 'fuzzy';
|
||||||
showSearchLoading();
|
showSearchLoading();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,6 +249,7 @@ function showSearchMessage(message, type = '') {
|
|||||||
|
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
async function loadAllData() {
|
async function loadAllData() {
|
||||||
|
currentSearchQuery = '';
|
||||||
showSearchLoading();
|
showSearchLoading();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -356,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) {
|
||||||
@@ -395,7 +328,7 @@ function renderTable(data) {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
|
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
|
||||||
<td>
|
<td>
|
||||||
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" />` : '无图片'}
|
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
|
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
|
||||||
@@ -562,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();
|
||||||
}
|
}
|
||||||
@@ -580,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',
|
||||||
@@ -593,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');
|
// 如果当前显示的是搜索结果,重新执行搜索
|
||||||
|
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
|
||||||
} 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');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
loadAllData();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message||'删除失败');
|
alert(e.message||'删除失败');
|
||||||
|
} finally {
|
||||||
|
isDeleting = false;
|
||||||
|
if (deleteButton) {
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
deleteButton.textContent = '删除';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,9 +565,169 @@ async function doDelete(id){
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadAllData();
|
loadAllData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 退出登录处理
|
||||||
|
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 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('click', function(e) {
|
||||||
|
if (e.target.classList.contains('clickable-image')) {
|
||||||
|
const imgSrc = e.target.src;
|
||||||
|
expandedImage.src = imgSrc;
|
||||||
|
imageModal.style.display = 'block';
|
||||||
|
|
||||||
|
// 重置缩放状态
|
||||||
|
resetZoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击关闭按钮关闭模态框
|
||||||
|
imageModalClose.onclick = function() {
|
||||||
|
imageModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击模态框外部区域关闭模态框
|
||||||
|
window.onclick = function(event) {
|
||||||
|
if (event.target === imageModal) {
|
||||||
|
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>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
341
elastic/templates/elastic/registration_codes.html
Normal file
341
elastic/templates/elastic/registration_codes.html
Normal 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>
|
||||||
@@ -4,56 +4,126 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>图片上传与识别</title>
|
<title>图片上传与识别</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #fafafa; }
|
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||||
.container { max-width: 900px; margin: 6vh auto; background: #fff; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,0.06); padding: 24px; }
|
/* 导航栏样式 */
|
||||||
.row { display: flex; gap: 16px; }
|
.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;
|
||||||
.col { flex: 1; }
|
flex-direction: column;align-items: center;}
|
||||||
textarea { width: 100%; min-height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
|
.user-id {text-align: center;margin-bottom: 0px;}
|
||||||
img { max-width: 100%; border: 1px solid #eee; border-radius: 6px; }
|
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||||
.btn { padding: 8px 12px; border: none; border-radius: 6px; cursor: pointer; }
|
.navigation-links {width: 100%;margin-top: 60px;}
|
||||||
.btn-primary { background: #1677ff; color: #fff; }
|
.sidebar a,
|
||||||
.btn-secondary { background: #f0f0f0; }
|
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||||
.muted { color: #666; font-size: 12px; }
|
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||||
.error { color: #d14343; }
|
.sidebar a:hover,
|
||||||
.success { color: #179957; }
|
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||||
|
/* 主内容区 - 改进后的样式 */
|
||||||
|
.main-content {margin-left: 200px;padding: 20px;color: #333;}
|
||||||
|
.container { max-width: 1200px;margin: 0 auto;background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||||
|
padding: 24px;}
|
||||||
|
.header {display: flex;align-items: center;justify-content: space-between;margin-bottom: 12px;}
|
||||||
|
.header h2 {margin: 0; color: #1e293b;}
|
||||||
|
.header p {margin: 5px 0 0 0;color: #64748b;font-size: 14px;}
|
||||||
|
.upload-section { background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;padding: 32px; text-align: center;transition: all 0.3s ease;
|
||||||
|
margin-bottom: 24px;}
|
||||||
|
.upload-section:hover {border-color: #4f46e5; background: #f1f5f9; }
|
||||||
|
.upload-section.drag-over {border-color: #4f46e5; background: #e0e7ff; }
|
||||||
|
.upload-section input[type="file"] {margin: 15px 0;}
|
||||||
|
.btn {padding: 10px 16px;border: none;border-radius: 8px;cursor: pointer;margin: 0 4px;font-size: 14px;transition: all 0.2s ease; }
|
||||||
|
.btn-primary { background: #4f46e5; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #4338ca;}
|
||||||
|
.btn-secondary {background: #e2e8f0;color: #334155; }
|
||||||
|
.btn-secondary:hover { background: #cbd5e1;}
|
||||||
|
.btn-danger { background: #ef4444;color: #fff;}
|
||||||
|
.btn-danger:hover { background: #dc2626;}
|
||||||
|
.preview-container {display: flex; gap: 24px; margin: 24px 0;}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.preview-container {flex-direction: column;}
|
||||||
|
}
|
||||||
|
.preview-box {flex: 1; text-align: center; }
|
||||||
|
.preview-box h3 {margin-top: 0;color: #334155; }
|
||||||
|
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
|
||||||
|
.result-box {flex: 1;}
|
||||||
|
.result-box h3 { margin-top: 0; color: #334155;}
|
||||||
|
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
||||||
|
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
|
||||||
|
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
|
||||||
|
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
|
||||||
|
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px; resize: vertical;box-sizing: border-box; }
|
||||||
|
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
|
||||||
|
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||||
|
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
|
||||||
|
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
|
||||||
|
.progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
|
||||||
|
.progress-wrap {display:none; margin-top: 8px;}
|
||||||
|
.progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<!-- 左侧固定栏目 -->
|
||||||
<h2>图片上传与识别</h2>
|
<div class="sidebar">
|
||||||
<p class="muted">选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
<div class="user-id">
|
||||||
|
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||||
|
</div>
|
||||||
|
<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-content">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h2>图片上传与识别</h2>
|
||||||
|
<p>选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-section" id="dropArea">
|
||||||
|
<h3>上传图片</h3>
|
||||||
|
<p>点击下方按钮选择图片,或拖拽图片到此区域</p>
|
||||||
<form id="uploadForm" enctype="multipart/form-data">
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="file" id="fileInput" name="file" accept="image/*" />
|
<input type="file" id="fileInput" name="file" accept="image/*" required />
|
||||||
|
<br>
|
||||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||||
<span id="uploadMsg" class="muted"></span>
|
|
||||||
</form>
|
</form>
|
||||||
|
<div class="status-message" id="uploadMsg"></div>
|
||||||
|
<div class="progress-wrap" id="progressWrap">
|
||||||
|
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
|
||||||
|
<div class="progress-text" id="progressText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top:16px;">
|
<div class="preview-container">
|
||||||
<div class="col">
|
<div class="preview-box">
|
||||||
<h4>图片预览</h4>
|
<h3>图片预览</h3>
|
||||||
<img id="preview" alt="预览" />
|
<img id="preview" alt="预览" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
|
||||||
<h4>识别结果(可编辑)</h4>
|
<div class="result-box">
|
||||||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
<h3>识别结果(可编辑)</h3>
|
||||||
|
<div class="form-controls">
|
||||||
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||||
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="kvForm" style="border:1px solid #eee; border-radius:6px; padding:8px; max-height:300px; overflow:auto;"></div>
|
<div id="kvForm"></div>
|
||||||
<div style="margin-top:8px;">
|
|
||||||
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:16px;">
|
<div class="action-buttons">
|
||||||
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
|
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
|
||||||
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
|
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
|
||||||
<span id="confirmMsg" class="muted"></span>
|
<span id="confirmMsg" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
@@ -73,15 +143,105 @@ const confirmMsg = document.getElementById('confirmMsg');
|
|||||||
const kvForm = document.getElementById('kvForm');
|
const kvForm = document.getElementById('kvForm');
|
||||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||||
|
const dropArea = document.getElementById('dropArea');
|
||||||
|
const progressWrap = document.getElementById('progressWrap');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
let currentImageRel = '';
|
let currentImageRel = '';
|
||||||
|
|
||||||
|
function setProgress(p, text){
|
||||||
|
const v = Math.max(0, Math.min(100, Math.round(p||0)));
|
||||||
|
progressBar.style.width = v + '%';
|
||||||
|
progressText.textContent = (text||'') + (text? ' ' : '') + v + '%';
|
||||||
|
}
|
||||||
|
function showProgress(){
|
||||||
|
progressWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideProgress(){
|
||||||
|
progressWrap.style.display = 'none';
|
||||||
|
setProgress(0, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToJpeg(file){
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
let img;
|
||||||
|
try{
|
||||||
|
const blob = await fetch(url).then(r=>r.blob());
|
||||||
|
img = await createImageBitmap(blob);
|
||||||
|
}catch(e){
|
||||||
|
img = await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=url;});
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const maxDim = 2000;
|
||||||
|
const w = img.width;
|
||||||
|
const h = img.height;
|
||||||
|
const scale = Math.min(1, maxDim/Math.max(w,h));
|
||||||
|
const nw = Math.round(w*scale);
|
||||||
|
const nh = Math.round(h*scale);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = nw;
|
||||||
|
canvas.height = nh;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, nw, nh);
|
||||||
|
const blob = await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.82));
|
||||||
|
const name = (file.name||'image').replace(/\.[^/.]+$/, '') + '.jpg';
|
||||||
|
return new File([blob], name, {type:'image/jpeg'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽上传功能
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropArea.addEventListener(eventName, highlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropArea.addEventListener(eventName, unhighlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlight() {
|
||||||
|
dropArea.classList.add('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhighlight() {
|
||||||
|
dropArea.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
dropArea.addEventListener('drop', handleDrop, false);
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
if (files.length) {
|
||||||
|
fileInput.files = files;
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
fileInput.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件选择后预览
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
preview.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function createRow(k = '', v = '') {
|
function createRow(k = '', v = '') {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.style.display = 'grid';
|
row.className = 'form-row';
|
||||||
row.style.gridTemplateColumns = '1fr 1fr auto';
|
|
||||||
row.style.gap = '8px';
|
|
||||||
row.style.marginBottom = '6px';
|
|
||||||
const keyInput = document.createElement('input');
|
const keyInput = document.createElement('input');
|
||||||
keyInput.type = 'text';
|
keyInput.type = 'text';
|
||||||
keyInput.placeholder = '字段名';
|
keyInput.placeholder = '字段名';
|
||||||
@@ -92,9 +252,17 @@ function createRow(k = '', v = '') {
|
|||||||
valInput.value = typeof v === 'object' ? JSON.stringify(v) : (v ?? '');
|
valInput.value = typeof v === 'object' ? JSON.stringify(v) : (v ?? '');
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.type = 'button';
|
delBtn.type = 'button';
|
||||||
delBtn.className = 'btn btn-secondary';
|
delBtn.className = 'btn btn-danger';
|
||||||
delBtn.textContent = '删除';
|
delBtn.textContent = '删除';
|
||||||
delBtn.onclick = () => { kvForm.removeChild(row); syncTextarea(); };
|
delBtn.onclick = () => {
|
||||||
|
if (kvForm.children.length > 1) {
|
||||||
|
kvForm.removeChild(row);
|
||||||
|
} else {
|
||||||
|
keyInput.value = '';
|
||||||
|
valInput.value = '';
|
||||||
|
}
|
||||||
|
syncTextarea();
|
||||||
|
};
|
||||||
keyInput.oninput = syncTextarea;
|
keyInput.oninput = syncTextarea;
|
||||||
valInput.oninput = syncTextarea;
|
valInput.oninput = syncTextarea;
|
||||||
row.appendChild(keyInput);
|
row.appendChild(keyInput);
|
||||||
@@ -142,9 +310,16 @@ syncFromTextBtn.addEventListener('click', () => {
|
|||||||
try {
|
try {
|
||||||
const obj = JSON.parse(resultBox.value || '{}');
|
const obj = JSON.parse(resultBox.value || '{}');
|
||||||
renderFormFromObject(obj);
|
renderFormFromObject(obj);
|
||||||
|
uploadMsg.textContent = '已从文本区刷新表单';
|
||||||
|
uploadMsg.className = 'status-message success';
|
||||||
|
uploadMsg.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadMsg.style.display = 'none';
|
||||||
|
}, 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uploadMsg.textContent = '文本区不是有效JSON';
|
uploadMsg.textContent = '文本区不是有效JSON';
|
||||||
uploadMsg.className = 'error';
|
uploadMsg.className = 'status-message error';
|
||||||
|
uploadMsg.style.display = 'block';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,33 +334,57 @@ uploadForm.addEventListener('submit', async (e) => {
|
|||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
uploadMsg.textContent = '请选择图片文件';
|
uploadMsg.textContent = '请选择图片文件';
|
||||||
uploadMsg.className = 'error';
|
uploadMsg.className = 'status-message error';
|
||||||
|
uploadMsg.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showProgress();
|
||||||
|
setProgress(5, '转换为JPG');
|
||||||
|
let jpegFile = file;
|
||||||
|
try {
|
||||||
|
jpegFile = await convertToJpeg(file);
|
||||||
|
setProgress(50, '转换为JPG');
|
||||||
|
preview.src = URL.createObjectURL(jpegFile);
|
||||||
|
} catch (_) {
|
||||||
|
jpegFile = file;
|
||||||
|
setProgress(50, '转换为JPG');
|
||||||
|
}
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', jpegFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let prog = 50;
|
||||||
|
setProgress(prog, '识别中');
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
prog = Math.min(95, prog + 1);
|
||||||
|
setProgress(prog, '识别中');
|
||||||
|
}, 120);
|
||||||
const resp = await fetch('/elastic/upload/', {
|
const resp = await fetch('/elastic/upload/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
clearInterval(timer);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok || data.status !== 'success') {
|
if (!resp.ok || data.status !== 'success') {
|
||||||
throw new Error(data.message || '上传识别失败');
|
throw new Error(data.message || '上传识别失败');
|
||||||
}
|
}
|
||||||
|
setProgress(100, '识别完成');
|
||||||
uploadMsg.textContent = data.message || '识别成功';
|
uploadMsg.textContent = data.message || '识别成功';
|
||||||
uploadMsg.className = 'success';
|
uploadMsg.className = 'status-message success';
|
||||||
|
uploadMsg.style.display = 'block';
|
||||||
preview.src = data.image_url;
|
preview.src = data.image_url;
|
||||||
renderFormFromObject(data.data || {});
|
renderFormFromObject(data.data || {});
|
||||||
currentImageRel = data.image;
|
currentImageRel = data.image;
|
||||||
confirmBtn.disabled = false;
|
confirmBtn.disabled = false;
|
||||||
|
setTimeout(hideProgress, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uploadMsg.textContent = e.message || '发生错误';
|
uploadMsg.textContent = e.message || '发生错误';
|
||||||
uploadMsg.className = 'error';
|
uploadMsg.className = 'status-message error';
|
||||||
|
uploadMsg.style.display = 'block';
|
||||||
|
progressText.textContent = '识别失败';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,10 +406,10 @@ confirmBtn.addEventListener('click', async () => {
|
|||||||
throw new Error(data.message || '录入失败');
|
throw new Error(data.message || '录入失败');
|
||||||
}
|
}
|
||||||
confirmMsg.textContent = data.message || '录入成功';
|
confirmMsg.textContent = data.message || '录入成功';
|
||||||
confirmMsg.className = 'success';
|
confirmMsg.style.color = '#179957';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
confirmMsg.textContent = e.message || '发生错误';
|
confirmMsg.textContent = e.message || '发生错误';
|
||||||
confirmMsg.className = 'error';
|
confirmMsg.style.color = '#d14343';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,10 +418,38 @@ clearBtn.addEventListener('click', () => {
|
|||||||
preview.src = '';
|
preview.src = '';
|
||||||
resultBox.value = '';
|
resultBox.value = '';
|
||||||
kvForm.innerHTML = '';
|
kvForm.innerHTML = '';
|
||||||
|
kvForm.appendChild(createRow()); // 保留一个空行
|
||||||
uploadMsg.textContent = '';
|
uploadMsg.textContent = '';
|
||||||
confirmMsg.textContent = '';
|
confirmMsg.textContent = '';
|
||||||
confirmBtn.disabled = true;
|
confirmBtn.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 退出登录处理
|
||||||
|
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 || '发生错误';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
657
elastic/templates/elastic/users.html
Normal file
657
elastic/templates/elastic/users.html
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
<!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: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
|
||||||
|
flex-direction: column;align-items: center;}
|
||||||
|
.user-id {text-align: center;margin-bottom: 0px;}
|
||||||
|
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||||
|
.navigation-links {width: 100%;margin-top: 60px;}
|
||||||
|
.sidebar a,
|
||||||
|
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||||
|
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||||
|
.sidebar a:hover,
|
||||||
|
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||||
|
/* 主内容区 */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 200px;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: #aaa;
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 左侧固定栏目 -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="user-id">
|
||||||
|
<h3>你好,{{ username|default:"访客" }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="navigation-links">
|
||||||
|
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">返回主页</a>
|
||||||
|
<a id="logoutBtn">退出登录</a>
|
||||||
|
<div id="logoutMsg"></div>
|
||||||
|
{% csrf_token %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
{% if is_student %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h2>修改密码</h2></div>
|
||||||
|
<form id="selfPwdForm">
|
||||||
|
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">新密码</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">确认密码</label>
|
||||||
|
<input type="password" id="confirmPassword" name="confirmPassword" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if is_tutor %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h2>修改本人密码</h2></div>
|
||||||
|
<form id="selfPwdForm">
|
||||||
|
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">新密码</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">确认密码</label>
|
||||||
|
<input type="password" id="confirmPassword" name="confirmPassword" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
{% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification success" id="successNotification">操作成功!</div>
|
||||||
|
<div class="notification error" id="errorNotification">操作失败!</div>
|
||||||
|
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="searchInput" placeholder="搜索用户名...">
|
||||||
|
<button id="searchBtn">搜索</button>
|
||||||
|
<button id="resetBtn">重置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="usersTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>用户ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>权限</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加/编辑用户模态框 -->
|
||||||
|
<div id="userModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2 id="modalTitle">添加用户</h2>
|
||||||
|
<form id="userForm">
|
||||||
|
<input type="hidden" id="userId" name="user_id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input type="text" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permission">权限</label>
|
||||||
|
<select id="permission" name="permission" required>
|
||||||
|
<option value="0">管理员</option>
|
||||||
|
<option value="1">普通用户</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 确认删除模态框 -->
|
||||||
|
<div id="deleteModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>确认删除</h2>
|
||||||
|
<p>确定要删除用户 <strong id="deleteUserName"></strong> 吗?此操作不可撤销。</p>
|
||||||
|
<input type="hidden" id="deleteUserId">
|
||||||
|
<button id="confirmDeleteBtn" class="btn btn-danger">确认删除</button>
|
||||||
|
<button class="btn">取消</button>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示通知
|
||||||
|
function showNotification(message, isSuccess = true) {
|
||||||
|
const notification = isSuccess ?
|
||||||
|
document.getElementById('successNotification') :
|
||||||
|
document.getElementById('errorNotification');
|
||||||
|
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.style.display = 'block';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有用户
|
||||||
|
async function loadUsers(searchTerm = '') {
|
||||||
|
try {
|
||||||
|
const url = searchTerm ?
|
||||||
|
`/elastic/users/?search=${encodeURIComponent(searchTerm)}` :
|
||||||
|
'/elastic/users/';
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
// 处理不同格式的API响应
|
||||||
|
const users = result.data || result.users || [];
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// 根据权限值显示权限名称
|
||||||
|
const permissionText = Number(user.permission) === 0 ? '管理员' : '普通用户';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${user.user_id}</td>
|
||||||
|
<td>${user.username}</td>
|
||||||
|
<td>${permissionText}</td>
|
||||||
|
<td class="action-buttons">
|
||||||
|
<button class="btn btn-success edit-btn" data-user='${JSON.stringify(user)}'>编辑</button>
|
||||||
|
<button class="btn btn-danger delete-btn" data-username="${user.username}" data-userid="${user.user_id}">删除</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showNotification('获取用户列表失败', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户列表失败:', error);
|
||||||
|
showNotification('获取用户列表失败', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加用户模态框
|
||||||
|
function openAddModal() {
|
||||||
|
document.getElementById('modalTitle').textContent = '添加用户';
|
||||||
|
document.getElementById('userForm').reset();
|
||||||
|
document.getElementById('userId').value = '';
|
||||||
|
document.getElementById('password').required = true;
|
||||||
|
document.getElementById('confirmPassword').required = true;
|
||||||
|
document.getElementById('userModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑用户模态框
|
||||||
|
function openEditModal(user) {
|
||||||
|
document.getElementById('modalTitle').textContent = '编辑用户';
|
||||||
|
document.getElementById('username').value = user.username;
|
||||||
|
document.getElementById('userId').value = user.user_id;
|
||||||
|
document.getElementById('permission').value = user.permission;
|
||||||
|
document.getElementById('password').required = false;
|
||||||
|
document.getElementById('confirmPassword').required = false;
|
||||||
|
document.getElementById('userModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开删除确认模态框
|
||||||
|
function openDeleteModal(username, userId) {
|
||||||
|
document.getElementById('deleteUserName').textContent = username;
|
||||||
|
document.getElementById('deleteUserId').value = userId;
|
||||||
|
document.getElementById('deleteModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存用户(添加或编辑)
|
||||||
|
async function saveUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(event.target);
|
||||||
|
const userId = formData.get('user_id');
|
||||||
|
const username = formData.get('username');
|
||||||
|
const permission = formData.get('permission');
|
||||||
|
const password = formData.get('password');
|
||||||
|
const confirmPassword = formData.get('confirmPassword');
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
showNotification('密码和确认密码不匹配', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码长度(如果提供了密码)
|
||||||
|
if (password && password.length < 6) {
|
||||||
|
showNotification('密码长度至少为6位', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
username: username,
|
||||||
|
permission: parseInt(permission)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
data.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
response = await fetch(`/elastic/users/${userId}/update/`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrftoken
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch('/elastic/users/add/', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrftoken
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showNotification(userId ? '用户更新成功' : '用户添加成功');
|
||||||
|
document.getElementById('userModal').style.display = 'none';
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || '操作失败', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存用户失败:', error);
|
||||||
|
showNotification('保存用户失败', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
async function deleteUser() {
|
||||||
|
const userId = document.getElementById('deleteUserId').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
const response = await fetch(`/elastic/users/${userId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrftoken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showNotification('用户删除成功');
|
||||||
|
document.getElementById('deleteModal').style.display = 'none';
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || '删除失败', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除用户失败:', error);
|
||||||
|
showNotification('删除用户失败', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
const addBtn = document.getElementById('addUserBtn');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', openAddModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('userForm').addEventListener('submit', saveUser);
|
||||||
|
|
||||||
|
document.getElementById('confirmDeleteBtn').addEventListener('click', deleteUser);
|
||||||
|
|
||||||
|
document.querySelectorAll('.close').forEach(closeBtn => {
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
this.parentElement.parentElement.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
|
if (searchBtn) {
|
||||||
|
searchBtn.addEventListener('click', function() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value;
|
||||||
|
loadUsers(searchTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击模态框外部关闭模态框
|
||||||
|
window.addEventListener('click', function(event) {
|
||||||
|
const modals = document.querySelectorAll('.modal');
|
||||||
|
modals.forEach(modal => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登出功能
|
||||||
|
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 || '发生错误';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 页面加载时获取用户列表
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const selfForm = document.getElementById('selfPwdForm');
|
||||||
|
if (selfForm) {
|
||||||
|
selfForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const uid = document.getElementById('selfUserId').value;
|
||||||
|
const pwd = document.getElementById('password').value;
|
||||||
|
const cpwd = document.getElementById('confirmPassword').value;
|
||||||
|
if (pwd !== cpwd) { showNotification('密码和确认密码不匹配', false); return; }
|
||||||
|
if ((pwd || '').length < 6) { showNotification('密码长度至少为6位', false); return; }
|
||||||
|
try {
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
const resp = await fetch(`/elastic/users/${uid}/update/`, {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
|
||||||
|
body: JSON.stringify({ password: pwd })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.status === 'success') { showNotification('修改成功'); }
|
||||||
|
else { showNotification(result.message || '操作失败', false); }
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('保存失败', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
if (tbody) {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为表格中的编辑和删除按钮添加事件监听器
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.classList.contains('edit-btn')) {
|
||||||
|
const user = JSON.parse(e.target.getAttribute('data-user'));
|
||||||
|
openEditModal(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('delete-btn')) {
|
||||||
|
const username = e.target.getAttribute('data-username');
|
||||||
|
const userId = e.target.getAttribute('data-userid');
|
||||||
|
openDeleteModal(username, userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -21,8 +21,8 @@ urlpatterns = [
|
|||||||
# 用户管理
|
# 用户管理
|
||||||
path('users/', views.get_users, name='get_users'),
|
path('users/', views.get_users, name='get_users'),
|
||||||
path('users/add/', views.add_user, name='add_user'),
|
path('users/add/', views.add_user, name='add_user'),
|
||||||
path('users/<str:username>/delete/', views.delete_user, name='delete_user'),
|
path('users/<int:user_id>/update/', views.update_user_by_id_view, name='update_user_by_id'),
|
||||||
path('users/<str:username>/update/', views.update_user, name='update_user'),
|
path('users/<int:user_id>/delete/', views.delete_user_by_id_view, name='delete_user_by_id'),
|
||||||
|
|
||||||
# 图片上传与确认
|
# 图片上传与确认
|
||||||
path('upload-page/', views.upload_page, name='upload_page'),
|
path('upload-page/', views.upload_page, name='upload_page'),
|
||||||
@@ -31,4 +31,17 @@ 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('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/types/', views.analytics_types_view, name='analytics_types'),
|
||||||
|
path('analytics/types_trend/', views.analytics_types_trend_view, name='analytics_types_trend'),
|
||||||
|
path('analytics/recent/', views.analytics_recent_view, name='analytics_recent'),
|
||||||
]
|
]
|
||||||
|
|||||||
468
elastic/views.py
468
elastic/views.py
@@ -11,9 +11,16 @@ from django.http import JsonResponse
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie, csrf_protect
|
||||||
from .es_connect import *
|
from .es_connect import *
|
||||||
from openai import OpenAI
|
from .es_connect import update_user_by_id as es_update_user_by_id, delete_user_by_id as es_delete_user_by_id
|
||||||
|
from .es_connect import (
|
||||||
|
analytics_trend as es_analytics_trend,
|
||||||
|
analytics_types as es_analytics_types,
|
||||||
|
analytics_types_trend as es_analytics_types_trend,
|
||||||
|
analytics_recent as es_analytics_recent,
|
||||||
|
)
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
@@ -79,26 +86,36 @@ def get_all_data(request):
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def delete_data(request, doc_id):
|
def delete_data(request, doc_id):
|
||||||
"""删除数据(需登录;管理员或作者本人)"""
|
"""删除数据(需登录;管理员或作者本人)"""
|
||||||
if not request.session.get("user_id"):
|
request_user=request.session.get("user_id")
|
||||||
|
# request_admin=request.session.get("permisssion")
|
||||||
|
if request_user is None:
|
||||||
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = get_by_id(doc_id)
|
existing = get_by_id(doc_id)
|
||||||
|
user_existing=get_user_by_id(request_user)
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
||||||
is_admin = (request.session.get("permission", 1) == 0)
|
|
||||||
|
is_admin = int(user_existing.get('permission')) == 0
|
||||||
is_owner = str(existing.get("writer_id", "")) == str(request.session.get("user_id"))
|
is_owner = str(existing.get("writer_id", "")) == str(request.session.get("user_id"))
|
||||||
|
|
||||||
if not (is_admin or is_owner):
|
if not (is_admin or is_owner):
|
||||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||||
success = delete_by_id(doc_id)
|
success = delete_by_id(doc_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return JsonResponse({"status": "success", "message": "数据删除成功"})
|
return JsonResponse({"status": "success", "message": "数据删除成功"})
|
||||||
else:
|
else:
|
||||||
return JsonResponse({"status": "error", "message": "数据删除失败"}, status=500)
|
return JsonResponse({"status": "error", "message": "数据删除失败"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -107,18 +124,24 @@ def delete_data(request, doc_id):
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def update_data(request, doc_id):
|
def update_data(request, doc_id):
|
||||||
"""更新数据(需登录;管理员或作者本人)"""
|
"""更新数据(需登录;管理员或作者本人)"""
|
||||||
# if not request.session.get("user_id"):
|
request_user = request.session.get("user_id")
|
||||||
# return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
if request_user is None:
|
||||||
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(request.body.decode('utf-8'))
|
payload = json.loads(request.body.decode('utf-8'))
|
||||||
except Exception:
|
except Exception:
|
||||||
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
|
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
|
||||||
try:
|
try:
|
||||||
existing = get_by_id(doc_id)
|
existing = get_by_id(doc_id)
|
||||||
|
user_existing = get_user_by_id(request_user)
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
||||||
is_admin = (request.session.get("permission", 1) == 0)
|
|
||||||
|
is_admin = int(user_existing.get('permission')) == 0
|
||||||
is_owner = str(existing.get("writer_id", "")) == str(request.session.get("user_id"))
|
is_owner = str(existing.get("writer_id", "")) == str(request.session.get("user_id"))
|
||||||
|
|
||||||
if not (is_admin or is_owner):
|
if not (is_admin or is_owner):
|
||||||
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||||
|
|
||||||
@@ -132,6 +155,10 @@ def update_data(request, doc_id):
|
|||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
updated["data"] = json.dumps(v, ensure_ascii=False)
|
updated["data"] = json.dumps(v, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
obj = json.loads(str(v))
|
||||||
|
updated["data"] = json.dumps(obj, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
updated["data"] = str(v)
|
updated["data"] = str(v)
|
||||||
|
|
||||||
success = update_by_id(doc_id, updated)
|
success = update_by_id(doc_id, updated)
|
||||||
@@ -157,58 +184,129 @@ def get_data(request, doc_id):
|
|||||||
|
|
||||||
|
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
@csrf_exempt
|
@csrf_protect
|
||||||
def add_user(request):
|
def add_user(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:
|
try:
|
||||||
data = json.loads(request.body.decode('utf-8'))
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
success = write_user_data(data)
|
except Exception:
|
||||||
if success:
|
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
|
||||||
return JsonResponse({"status": "success", "message": "用户添加成功"})
|
username = (payload.get("username") or "").strip()
|
||||||
else:
|
password = (payload.get("password") or "").strip()
|
||||||
|
try:
|
||||||
|
permission = int(payload.get("permission", 1))
|
||||||
|
except Exception:
|
||||||
|
permission = 1
|
||||||
|
if not username:
|
||||||
|
return JsonResponse({"status": "error", "message": "用户名不能为空"}, status=400)
|
||||||
|
if password and len(password) < 6:
|
||||||
|
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
|
||||||
|
existing = get_user_by_username(username)
|
||||||
|
if existing:
|
||||||
|
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
|
||||||
|
users = 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": permission,
|
||||||
|
})
|
||||||
|
if not ok:
|
||||||
return JsonResponse({"status": "error", "message": "用户添加失败"}, status=500)
|
return JsonResponse({"status": "error", "message": "用户添加失败"}, status=500)
|
||||||
except Exception as e:
|
return JsonResponse({"status": "success", "message": "用户添加成功"})
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def get_users(request):
|
def get_users(request):
|
||||||
"""获取所有用户"""
|
uid = request.session.get("user_id")
|
||||||
|
if uid is None:
|
||||||
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
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()
|
||||||
users = get_all_users()
|
users = get_all_users()
|
||||||
return JsonResponse({"status": "success", "data": 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:
|
||||||
|
filtered = [u for u in filtered if q in str(u.get("username", ""))]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["POST"])
|
||||||
@csrf_exempt
|
@csrf_protect
|
||||||
def delete_user(request, username):
|
def update_user_by_id_view(request, user_id):
|
||||||
"""删除用户"""
|
uid = request.session.get("user_id")
|
||||||
|
if uid is None:
|
||||||
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
try:
|
try:
|
||||||
success = delete_user_by_username(username)
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
if success:
|
except Exception:
|
||||||
return JsonResponse({"status": "success", "message": "用户删除成功"})
|
return JsonResponse({"status": "error", "message": "JSON无效"}, status=400)
|
||||||
else:
|
new_username = (payload.get("username") or "").strip()
|
||||||
|
new_permission = payload.get("permission")
|
||||||
|
new_password = (payload.get("password") or "").strip()
|
||||||
|
if new_password and len(new_password) < 6:
|
||||||
|
return JsonResponse({"status": "error", "message": "密码长度至少为6位"}, status=400)
|
||||||
|
|
||||||
|
is_admin = int(request.session.get("permission", 1)) == 0
|
||||||
|
requester = get_user_by_id(uid) or {}
|
||||||
|
target = get_user_by_id(user_id) or {}
|
||||||
|
requester_mgr = set(requester.get("manage_key") or [])
|
||||||
|
target_keys = set(target.get("key") or [])
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
if new_username:
|
||||||
|
other = get_user_by_username(new_username)
|
||||||
|
if other and int(other.get("user_id", -1)) != int(user_id):
|
||||||
|
return JsonResponse({"status": "error", "message": "用户名已存在"}, status=409)
|
||||||
|
ok = es_update_user_by_id(
|
||||||
|
user_id,
|
||||||
|
username=new_username if new_username else None,
|
||||||
|
permission=int(new_permission) if new_permission is not None else None,
|
||||||
|
password=new_password if new_password else None,
|
||||||
|
)
|
||||||
|
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||||
|
|
||||||
|
if str(uid) == str(user_id):
|
||||||
|
if not new_password:
|
||||||
|
return JsonResponse({"status": "error", "message": "仅允许修改密码"}, status=400)
|
||||||
|
ok = es_update_user_by_id(user_id, password=new_password)
|
||||||
|
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||||
|
|
||||||
|
if requester_mgr and (target_keys & requester_mgr):
|
||||||
|
if not new_password or new_username or new_permission is not None:
|
||||||
|
return JsonResponse({"status": "error", "message": "导师仅允许修改密码"}, status=403)
|
||||||
|
ok = es_update_user_by_id(user_id, password=new_password)
|
||||||
|
return JsonResponse({"status": "success"}) if ok else JsonResponse({"status": "error", "message": "用户更新失败"}, status=500)
|
||||||
|
|
||||||
|
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@csrf_protect
|
||||||
|
def delete_user_by_id_view(request, user_id):
|
||||||
|
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)
|
||||||
|
ok = es_delete_user_by_id(user_id)
|
||||||
|
if not ok:
|
||||||
return JsonResponse({"status": "error", "message": "用户删除失败"}, status=500)
|
return JsonResponse({"status": "error", "message": "用户删除失败"}, status=500)
|
||||||
except Exception as e:
|
return JsonResponse({"status": "success", "message": "用户删除成功"})
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["PUT"])
|
|
||||||
@csrf_exempt
|
|
||||||
def update_user(request, username):
|
|
||||||
"""更新用户权限"""
|
|
||||||
try:
|
|
||||||
data = json.loads(request.body.decode('utf-8'))
|
|
||||||
new_permission = data.get('permission', 1)
|
|
||||||
success = update_user_permission(username, new_permission)
|
|
||||||
if success:
|
|
||||||
return JsonResponse({"status": "success", "message": "用户权限更新成功"})
|
|
||||||
else:
|
|
||||||
return JsonResponse({"status": "error", "message": "用户权限更新失败"}, status=500)
|
|
||||||
except Exception as e:
|
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# 辅助:JSON 转换(兼容 a.py 行为)
|
# 辅助:JSON 转换(兼容 a.py 行为)
|
||||||
@@ -228,6 +326,7 @@ def string_to_json(s):
|
|||||||
|
|
||||||
# 移植自 a.py 的核心:调用大模型进行 OCR/信息抽取
|
# 移植自 a.py 的核心:调用大模型进行 OCR/信息抽取
|
||||||
def ocr_and_extract_info(image_path: str):
|
def ocr_and_extract_info(image_path: str):
|
||||||
|
from openai import OpenAI
|
||||||
def encode_image(path: str) -> str:
|
def encode_image(path: str) -> str:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
return base64.b64encode(f.read()).decode("utf-8")
|
return base64.b64encode(f.read()).decode("utf-8")
|
||||||
@@ -240,8 +339,10 @@ def ocr_and_extract_info(image_path: str):
|
|||||||
# raise RuntimeError("缺少 AISTUDIO_API_KEY,请在环境变量或 settings 中配置")
|
# raise RuntimeError("缺少 AISTUDIO_API_KEY,请在环境变量或 settings 中配置")
|
||||||
|
|
||||||
|
|
||||||
api_key="188f57db3766e02ed2c7e18373996d84f4112272"
|
api_key = getattr(settings, "AISTUDIO_API_KEY", "")
|
||||||
base_url="https://aistudio.baidu.com/llm/lmapi/v3"
|
base_url = getattr(settings, "OPENAI_BASE_URL", "")
|
||||||
|
if not api_key or not base_url:
|
||||||
|
raise RuntimeError("缺少模型服务配置,请设置 AISTUDIO_API_KEY 与 OPENAI_BASE_URL")
|
||||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
|
||||||
types = get_type_list()
|
types = get_type_list()
|
||||||
@@ -256,7 +357,7 @@ def ocr_and_extract_info(image_path: str):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
model="ernie-4.5-turbo-vl-32k",
|
model=getattr(settings, "OPENAI_MODEL_NAME", "ernie-4.5-turbo-vl-32k"),
|
||||||
)
|
)
|
||||||
|
|
||||||
response_text = chat_completion.choices[0].message.content
|
response_text = chat_completion.choices[0].message.content
|
||||||
@@ -306,14 +407,14 @@ def upload_page(request):
|
|||||||
# 上传并识别(不入库)
|
# 上传并识别(不入库)
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def upload(request):
|
def upload(request):
|
||||||
# if not request.session.get("user_id"):
|
if request.session.get("user_id") is None:
|
||||||
# fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
|
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
|
||||||
# if fallback_uid:
|
if fallback_uid:
|
||||||
# request.session["user_id"] = fallback_uid
|
request.session["user_id"] = fallback_uid
|
||||||
# request.session.setdefault("permission", 1)
|
request.session.setdefault("permission", 1)
|
||||||
# else:
|
else:
|
||||||
# return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
#
|
|
||||||
file = request.FILES.get("file")
|
file = request.FILES.get("file")
|
||||||
if not file:
|
if not file:
|
||||||
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
|
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
|
||||||
@@ -348,18 +449,18 @@ def upload(request):
|
|||||||
# 确认并入库
|
# 确认并入库
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def confirm(request):
|
def confirm(request):
|
||||||
# if not request.session.get("user_id"):
|
if request.session.get("user_id") is None:
|
||||||
# # 允许从payload中带入user_id作为后备(便于前端已知用户时继续操作)
|
# 允许从payload中带入user_id作为后备(便于前端已知用户时继续操作)
|
||||||
# try:
|
try:
|
||||||
# payload_for_uid = json.loads(request.body.decode("utf-8"))
|
payload_for_uid = json.loads(request.body.decode("utf-8"))
|
||||||
# except Exception:
|
except Exception:
|
||||||
# payload_for_uid = {}
|
payload_for_uid = {}
|
||||||
# fb_uid = (payload_for_uid or {}).get("user_id")
|
fb_uid = (payload_for_uid or {}).get("user_id")
|
||||||
# if fb_uid:
|
if fb_uid:
|
||||||
# request.session["user_id"] = fb_uid
|
request.session["user_id"] = fb_uid
|
||||||
# request.session.setdefault("permission", 1)
|
request.session.setdefault("permission", 1)
|
||||||
# else:
|
else:
|
||||||
# return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(request.body.decode("utf-8"))
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
@@ -372,16 +473,43 @@ def confirm(request):
|
|||||||
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
|
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
|
||||||
|
|
||||||
ensure_type_in_list(edited.get("数据类型"))
|
ensure_type_in_list(edited.get("数据类型"))
|
||||||
|
final_image_rel = image_rel
|
||||||
|
try:
|
||||||
|
if image_rel:
|
||||||
|
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
|
||||||
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
|
src_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
|
||||||
|
base = os.path.splitext(os.path.basename(image_rel))[0]
|
||||||
|
webp_name = base + ".webp"
|
||||||
|
webp_abs = os.path.join(images_dir, webp_name)
|
||||||
|
with Image.open(src_abs) as im:
|
||||||
|
if im.mode in ("RGBA", "LA", "P"):
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
else:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.save(webp_abs, format="WEBP", quality=80)
|
||||||
|
final_image_rel = f"images/{webp_name}"
|
||||||
|
except Exception:
|
||||||
|
final_image_rel = image_rel
|
||||||
|
|
||||||
to_store = {
|
to_store = {
|
||||||
"writer_id": str(request.session.get("user_id")),
|
"writer_id": str(request.session.get("user_id")),
|
||||||
"data": json_to_string(edited),
|
"data": json_to_string(edited),
|
||||||
"image": image_rel,
|
"image": final_image_rel,
|
||||||
}
|
}
|
||||||
|
|
||||||
ok = insert_data(to_store)
|
ok = insert_data(to_store)
|
||||||
if not ok:
|
if not ok:
|
||||||
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
|
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if image_rel and final_image_rel != image_rel:
|
||||||
|
orig_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
|
||||||
|
if os.path.isfile(orig_abs):
|
||||||
|
os.remove(orig_abs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return JsonResponse({"status": "success", "message": "数据录入成功", "data": edited})
|
return JsonResponse({"status": "success", "message": "数据录入成功", "data": edited})
|
||||||
|
|
||||||
|
|
||||||
@@ -392,11 +520,33 @@ def manage_page(request):
|
|||||||
if session_user_id is None:
|
if session_user_id is None:
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
return redirect("/accounts/login/")
|
return redirect("/accounts/login/")
|
||||||
# is_admin = (request.session.get("permission", 1) == 0)
|
|
||||||
|
is_admin = int(request.session.get("permission", 1)) == 0
|
||||||
|
if is_admin:
|
||||||
raw_results = search_all()
|
raw_results = search_all()
|
||||||
# if not is_admin:
|
else:
|
||||||
# uid = str(session_user_id)
|
uid = str(session_user_id)
|
||||||
# raw_results = [r for r in raw_results if str(r.get("writer_id", "")) == uid]
|
raw_results = [r for r in search_all() if str(r.get("writer_id", "")) == uid]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in raw_results:
|
||||||
|
try:
|
||||||
|
r_data = string_to_json(r.get("data", "{}"))
|
||||||
|
r_data["_id"] = r["id"]
|
||||||
|
r_data["_image"] = r.get("image", "")
|
||||||
|
results.append(r_data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"elastic/manage.html",
|
||||||
|
{
|
||||||
|
"results": results,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"user_id": session_user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
# 规范化键,避免模板点号访问下划线前缀字段
|
# 规范化键,避免模板点号访问下划线前缀字段
|
||||||
results = []
|
results = []
|
||||||
for r in raw_results:
|
for r in raw_results:
|
||||||
@@ -409,3 +559,175 @@ def manage_page(request):
|
|||||||
user_id_qs = request.GET.get("user_id")
|
user_id_qs = request.GET.get("user_id")
|
||||||
context = {"items": results, "user_id": user_id_qs or session_user_id}
|
context = {"items": results, "user_id": user_id_qs or session_user_id}
|
||||||
return render(request, "elastic/manage.html", context)
|
return render(request, "elastic/manage.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def analytics_trend_view(request):
|
||||||
|
try:
|
||||||
|
gte = request.GET.get("from")
|
||||||
|
lte = request.GET.get("to")
|
||||||
|
interval = request.GET.get("interval", "day")
|
||||||
|
data = es_analytics_trend(gte=gte, lte=lte, interval=interval)
|
||||||
|
return JsonResponse({"status": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def analytics_types_view(request):
|
||||||
|
try:
|
||||||
|
gte = request.GET.get("from")
|
||||||
|
lte = request.GET.get("to")
|
||||||
|
size = request.GET.get("size")
|
||||||
|
try:
|
||||||
|
size_int = int(size) if size is not None else 10
|
||||||
|
except Exception:
|
||||||
|
size_int = 10
|
||||||
|
data = es_analytics_types(gte=gte, lte=lte, size=size_int)
|
||||||
|
return JsonResponse({"status": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def analytics_types_trend_view(request):
|
||||||
|
try:
|
||||||
|
gte = request.GET.get("from")
|
||||||
|
lte = request.GET.get("to")
|
||||||
|
interval = request.GET.get("interval", "week")
|
||||||
|
size = request.GET.get("size")
|
||||||
|
try:
|
||||||
|
size_int = int(size) if size is not None else 8
|
||||||
|
except Exception:
|
||||||
|
size_int = 8
|
||||||
|
data = es_analytics_types_trend(gte=gte, lte=lte, interval=interval, size=size_int)
|
||||||
|
return JsonResponse({"status": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def analytics_recent_view(request):
|
||||||
|
try:
|
||||||
|
limit = request.GET.get("limit")
|
||||||
|
gte = request.GET.get("from")
|
||||||
|
lte = request.GET.get("to")
|
||||||
|
try:
|
||||||
|
limit_int = int(limit) if limit is not None else 10
|
||||||
|
except Exception:
|
||||||
|
limit_int = 10
|
||||||
|
data = es_analytics_recent(limit=limit_int, gte=gte, lte=lte)
|
||||||
|
return JsonResponse({"status": "success", "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def user_manage(request):
|
||||||
|
session_user_id = request.session.get("user_id")
|
||||||
|
if session_user_id is None:
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect("/accounts/login/")
|
||||||
|
is_admin = int(request.session.get("permission", 1)) == 0
|
||||||
|
me = get_user_by_id(session_user_id) or {}
|
||||||
|
has_manage = bool(me.get("manage_key"))
|
||||||
|
user_id_qs = request.GET.get("user_id")
|
||||||
|
context = {
|
||||||
|
"user_id": user_id_qs or session_user_id,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"is_tutor": (not is_admin) and has_manage,
|
||||||
|
"is_student": (not is_admin) and (not has_manage),
|
||||||
|
}
|
||||||
|
return render(request, "elastic/users.html", context)
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def registration_code_manage_page(request):
|
||||||
|
session_user_id = request.session.get("user_id")
|
||||||
|
if session_user_id is None:
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect("/accounts/login/")
|
||||||
|
if int(request.session.get("permission", 1)) != 0:
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect("/main/home/")
|
||||||
|
user_id_qs = request.GET.get("user_id")
|
||||||
|
context = {"user_id": user_id_qs or session_user_id}
|
||||||
|
return render(request, "elastic/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"})
|
||||||
|
|||||||
45
main/static/vendor/echarts.min.js
vendored
Normal file
45
main/static/vendor/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,96 +1,52 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh">
|
{% load static %}
|
||||||
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>固定左侧栏目</title>
|
<title>数据管理系统</title>
|
||||||
|
<script src="{% static 'vendor/echarts.min.js' %}"></script>
|
||||||
<style>
|
<style>
|
||||||
.sidebar {
|
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||||
position: fixed;
|
/* 导航栏样式 */
|
||||||
top: 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,0.1);z-index: 1000;display: flex;
|
||||||
left: 0;
|
flex-direction: column;align-items: center;}
|
||||||
width: 180px; /* 导航栏变窄 */
|
.user-id {text-align: center;margin-bottom: 0px;}
|
||||||
height: 100vh;
|
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||||
background: #1e1e2e;
|
.navigation-links {width: 100%;margin-top: 60px;}
|
||||||
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: auto; /* 用户ID保持在顶部 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #ff79c6;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px; /* 调整标题与下方内容的距离 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-links {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 60px; /* 空出一个正方形位置(约 60x60px) */
|
|
||||||
}
|
|
||||||
|
|
||||||
.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); /* 适应padding-left/right 20px */
|
|
||||||
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 {margin-left: 200px;padding: 20px;color: #333;}
|
||||||
}
|
.card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
|
||||||
|
.grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
|
||||||
.main-content {
|
.grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
|
||||||
margin-left: 200px; /* 调整主内容区左外边距,与新侧边栏宽度匹配 */
|
.header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
padding: 20px;
|
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
||||||
color: #333;
|
.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;}
|
||||||
body {
|
.btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
|
||||||
margin: 0;
|
.btn-primary {background: #4f46e5;color: #fff;}
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logoutMsg {
|
|
||||||
color: #ff5555;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</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="/">主页</a>
|
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
|
||||||
<a href="{% url 'elastic:upload_page' %}">图片上传与识别</a>
|
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
|
||||||
<a href="{% url 'elastic:manage_page' %}">数据管理(管理员)</a>
|
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a>
|
||||||
<button id="logoutBtn">退出登录</button>
|
<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 %}
|
||||||
|
<a id="logoutBtn">退出登录</a>
|
||||||
<div id="logoutMsg"></div>
|
<div id="logoutMsg"></div>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,18 +54,73 @@
|
|||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<h1>欢迎来到系统</h1>
|
<div class="card">
|
||||||
<p>这里是一大堆内容……</p>
|
<div class="header">
|
||||||
<p style="height: 200vh;">滚动试试看,左边菜单不会消失哦!✨</p>
|
<h2>师生共创系统</h2>
|
||||||
|
<span class="badge">用户:{{ user_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" style="margin-top:16px;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h3>录入量变化(近90天)</h3></div>
|
||||||
|
<div id="chartTrend" style="width:100%;height:320px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h3>类型占比(近30天)</h3></div>
|
||||||
|
<div id="chartTypes" style="width:100%;height:320px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h3>类型变化(近180天,按周)</h3></div>
|
||||||
|
<div id="chartTypesTrend" style="width:100%;height:320px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><h3>最近活动(近7天)</h3></div>
|
||||||
|
<ul id="recentList" style="list-style:none;padding:0;margin:0;"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登出脚本(保持不变) -->
|
|
||||||
<script>
|
<script>
|
||||||
|
// 获取CSRF令牌的函数
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导航点击处理函数,提供备用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 () => {
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||||
const msg = document.getElementById('logoutMsg');
|
const msg = document.getElementById('logoutMsg');
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
@@ -135,6 +146,102 @@
|
|||||||
msg.textContent = e.message || '发生错误';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from elastic.es_connect import get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -11,7 +12,22 @@ def home(request):
|
|||||||
|
|
||||||
# Show user_id (prefer query param if present, but don't trust it)
|
# Show user_id (prefer query param if present, but don't trust it)
|
||||||
user_id_qs = request.GET.get("user_id")
|
user_id_qs = request.GET.get("user_id")
|
||||||
|
uid = user_id_qs or session_user_id
|
||||||
|
perm = request.session.get("permission")
|
||||||
|
if perm is None and uid is not None:
|
||||||
|
u = get_user_by_id(uid)
|
||||||
|
try:
|
||||||
|
perm = int((u or {}).get("permission", 1))
|
||||||
|
except Exception:
|
||||||
|
perm = 1
|
||||||
|
request.session["permission"] = perm
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
perm = int(perm)
|
||||||
|
except Exception:
|
||||||
|
perm = 1
|
||||||
context = {
|
context = {
|
||||||
"user_id": user_id_qs or session_user_id,
|
"user_id": uid,
|
||||||
|
"is_admin": (int(perm) == 0),
|
||||||
}
|
}
|
||||||
return render(request, "main/home.html", context)
|
return render(request, "main/home.html", context)
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
Django==5.2.8
|
Django==5.2.8
|
||||||
elasticsearch==8.17.1
|
elasticsearch==7.17.9
|
||||||
django-elasticsearch-dsl==7.3.0
|
django-elasticsearch-dsl==7.4.0
|
||||||
|
django-elasticsearch-dsl-drf==0.22
|
||||||
|
elasticsearch-dsl==7.4.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
openai==1.52.2
|
openai==1.52.2
|
||||||
|
httpx==0.27.2
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
whitenoise==6.6.0
|
||||||
|
django-browser-reload==1.21.0
|
||||||
|
captcha==0.7.1
|
||||||
|
cryptography==46.0.3
|
||||||
Reference in New Issue
Block a user