96 Commits

Author SHA1 Message Date
07d3a4420c 生成镜像[0.2.7.9][ci]
All checks were successful
CI / docker-ci (push) Successful in 25s
2026-03-23 11:28:14 +08:00
2c3c2d6acf Merge branch 'Django' of gitea.spdis.space:Viajero/Achievement_Inputing into Django
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:06:59 +08:00
afc663844b 修复主页的类型分析的500问题[0.2.7.9][ci] 2026-03-23 11:06:45 +08:00
DSQ
9e3fe7150b [0.2.7.8][ci]
All checks were successful
CI / docker-ci (push) Successful in 26s
2026-03-23 11:02:00 +08:00
DSQ
c9611fa622 [0.2.7.7][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-23 10:37:46 +08:00
DSQ
fe7f08ed1c 数据管理添加时间的显示和统计报表的功能[0.2.7.6][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-19 16:38:16 +08:00
DSQ
5e38ebf856 [0.2.7.5][ci]
All checks were successful
CI / docker-ci (push) Successful in 32s
2026-03-18 21:56:39 +08:00
DSQ
71a0723a74 [0.2.7.4][ci]
All checks were successful
CI / docker-ci (push) Successful in 24s
2026-03-17 22:45:56 +08:00
DSQ
85dd7bc991 [0.2.7.3][ci] 2026-03-15 17:49:53 +08:00
DSQ
3596e344e2 [0.2.7.2][ci] 2026-03-15 17:21:35 +08:00
DSQ
b0c3707ccd 需测试[0.2.7.3][ci] 2026-03-15 17:11:31 +08:00
DSQ
f38cb5ec76 Merge remote-tracking branch 'origin/Django' into Django 2026-03-15 16:55:30 +08:00
DSQ
8c4e4e4c0d 完善老师页面,数据管理增加按key筛查 2026-03-15 16:54:08 +08:00
e05791e52f Revert "更新 README.md"
This reverts commit 4d83864e9f.
2026-03-12 21:31:59 +08:00
4d83864e9f 更新 README.md 2026-03-12 21:28:48 +08:00
ebe88d93c9 增加对于权限控制系统的解释 2026-03-12 21:21:30 +08:00
DSQ
6f1abc1681 修上传BUG 2026-03-12 20:27:32 +08:00
DSQ
d69858434f 能上传并识别PDF 2026-03-12 20:05:48 +08:00
DSQ
109c06e1d9 页面( 2026-03-12 19:00:36 +08:00
DSQ
1163110810 注册码管理页面的功能完善 2026-03-12 17:35:02 +08:00
DSQ
462c744d06 数据管理页面的完善 2026-03-12 17:08:49 +08:00
DSQ
b35f603399 [0.2.7.2][ci] 2026-03-11 15:46:21 +08:00
DSQ
b4cea89796 修BUG( 2026-03-08 11:16:11 +08:00
DSQ
ee7987aa23 新增个人中心页面,在注册后填写班级功能 2026-03-08 11:13:33 +08:00
DSQ
193f739693 改了一点前端显示( 2026-03-05 21:00:37 +08:00
418cc798df 增加了图表[0.2.7.2][ci] 2026-03-04 19:54:20 +08:00
14e407d06a 修复zai-sdk版本[0.2.7.1][ci] 2026-03-04 19:18:04 +08:00
bfbf100595 怎加gitignore并对梁的提交进行打包[0.2.7][ci] 2026-03-04 18:37:27 +08:00
abc435afe6 版本更新:
1、已实现多图识别并入库
2、增加图片上传时删除图片功能
3、改用模型glm-4.6v预计5月份到期
4、已对环境txt做更改
2026-02-21 16:35:06 +08:00
6b0be35832 接入minio[ci][0.2.6] 2026-01-16 15:13:57 +08:00
45005fcc92 更新工作流适配新runner[ci][0.2.5] 2025-12-26 00:00:35 -05:00
df18bdfa7e 使用环境变量管理模型名称[ci][0.2.5]
Some checks failed
CI / docker-ci (push) Failing after 39m44s
2025-12-24 15:46:07 +08:00
281ade6ac9 增加了进度条,提升等待感知[ci][0.2.4]
All checks were successful
CI / docker-ci (push) Successful in 34s
2025-11-27 12:21:08 +08:00
835426b133 修复了不支持webp格式的图片上传的问题
All checks were successful
CI / docker-ci (push) Has been skipped
2025-11-27 12:11:58 +08:00
d001fec21e 搞定(应该)😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 35s
2025-11-27 11:39:15 +08:00
253de3639c 😅😅😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:33:49 +08:00
a0507b8054 😅😅😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 28s
2025-11-27 11:31:38 +08:00
9f803880fa 😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:25:13 +08:00
71fe964476 😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 59s
2025-11-27 11:22:19 +08:00
0f5c8c08ff 再试一次[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:18:00 +08:00
e032253327 使用act_runner的服务器以提供下载[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:08:34 +08:00
3f108e2138 调整了一下yml进行构建和发布[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 5m31s
2025-11-26 22:33:12 +08:00
2d913e397f 调整了一下yml进行构建和发布[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 4m45s
2025-11-26 22:24:15 +08:00
74bc8aa498 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 22:11:34 +08:00
5d747faee1 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 30s
2025-11-26 22:07:50 +08:00
7bd8eeca77 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 22:01:14 +08:00
782b2dd82e 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 30s
2025-11-26 21:58:10 +08:00
f9c0abb3a0 调整了一下yml进行构建和发布[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 29s
2025-11-26 21:55:50 +08:00
c5300591e6 调整了一下yml进行构建和发布[ci][0.2.3] 2025-11-26 21:51:52 +08:00
f96629566f 调整了一下yml[ci]
All checks were successful
CI / docker-ci (push) Successful in 13m56s
2025-11-26 18:12:03 +08:00
8d581ac638 不尝试对镜像进行测试[ci]
Some checks failed
CI / docker-ci (push) Failing after 4s
2025-11-26 18:09:13 +08:00
acc80074ea 使用[ci]触发工作流
Some checks failed
CI / docker-ci (push) Failing after 3m0s
2025-11-26 18:00:35 +08:00
DSQ
62d28be032 数据管理页面删除时刷新页面 2025-11-22 15:59:31 +08:00
DSQ
5b956e1365 数据管理页面删除时刷新页面 2025-11-22 13:05:29 +08:00
DSQ
7485ba16e6 修复了数据管理页面删除时不能及时刷新页面的BUG 2025-11-22 12:10:01 +08:00
DSQ
ac580599b3 Merge remote-tracking branch 'origin/Django' into Django 2025-11-22 11:59:48 +08:00
DSQ
faae7032f1 在查看图片时可以进行缩放 2025-11-22 11:59:41 +08:00
615d9433fe 注册码选填 2025-11-22 11:45:09 +08:00
d755f4710f 邮件验证码搞定 2025-11-21 09:53:16 +08:00
3e598fe0a1 Merge remote-tracking branch 'origin/Django' into Django 2025-11-18 15:20:39 +08:00
5a9d98282a 更新用户管理,现在能通过班导师,管理员,学生进入对应的页面进行密码修改 2025-11-18 15:20:30 +08:00
DSQ
8f9fc9c914 UI微调 2025-11-18 14:46:18 +08:00
DSQ
b5d76be37b Merge remote-tracking branch 'origin/Django' into Django 2025-11-18 14:04:22 +08:00
DSQ
100531ddd1 修复了图片放大比例问题 2025-11-18 14:04:14 +08:00
68bc4b54f5 修复了在实际部署环境中,请求可能命中不同进程导致的登录报错 2025-11-18 13:36:53 +08:00
5153017a80 更新注册码管理及页面动画 2025-11-17 23:59:16 +08:00
DSQ
2c58c1be29 Merge remote-tracking branch 'origin/Django' into Django 2025-11-17 19:23:59 +08:00
DSQ
8c14544ca1 UI微调 2025-11-17 19:23:51 +08:00
42bacbbc81 添加 README.md 2025-11-17 18:23:51 +08:00
32ff920921 注册码生成以及用户注册 2025-11-17 18:03:13 +08:00
6e332f248f 更新依赖列表 2025-11-17 17:03:30 +08:00
1392275337 补充漏推送的东西 2025-11-17 16:22:47 +08:00
f93286a5fe 修改登录逻辑,使用RSA-OAEP 包裹每会话独立 AES-GCM 密钥 + 加密提交凭据 2025-11-17 15:33:40 +08:00
dc57d88779 确定用户的数据结构修改 2025-11-17 15:05:35 +08:00
9665e81698 Merge remote-tracking branch 'origin/Django' into Django 2025-11-17 14:16:30 +08:00
7afc6ba06b 修复普通用户无法进入数据管理的问题 2025-11-17 14:12:36 +08:00
DSQ
4ef3523ea9 改了下按键位置,数据管理添加了图片放大查看 2025-11-17 13:18:43 +08:00
49a5e82202 删除 main/__pycache__/views.cpython-312.pyc 2025-11-17 01:37:08 +08:00
df471e6636 传错一个文件 2025-11-17 01:36:00 +08:00
8457f24d21 将一个外部js放到本地以解决部署后极其卡顿的问题 2025-11-17 01:35:13 +08:00
cb28d45cd1 爆改了数据可视化 2025-11-17 01:07:52 +08:00
ec7bc64bfa revert caba4482bc
revert 修改一下进程管理相关问题
2025-11-15 23:37:10 +08:00
caba4482bc 修改一下进程管理相关问题 2025-11-15 22:35:25 +08:00
DSQ
c15c29850c Merge remote-tracking branch 'origin/Django' into Django 2025-11-15 21:47:58 +08:00
DSQ
259246028e 改了下上传页面UI 2025-11-15 21:47:25 +08:00
14788fd59d 新增“用户管理” 2025-11-15 21:24:57 +08:00
a896613726 Merge remote-tracking branch 'origin/Django' into Django
# Conflicts:
#	elastic/views.py
2025-11-15 20:22:57 +08:00
04b1df2130 新增“用户管理” 2025-11-15 20:21:25 +08:00
0e23fe8266 合并完莫名其妙少了个东西开始报错,补一下 2025-11-15 18:43:32 +08:00
37f8c442b2 Merge remote-tracking branch 'origin/Django' into Django 2025-11-15 18:33:13 +08:00
9342f37b45 修复了数据可视化字段匹配错误导致饼图生成错误的bug 2025-11-15 18:32:47 +08:00
DSQ
0f1cfdd803 Merge remote-tracking branch 'origin/Django' into Django 2025-11-15 18:14:06 +08:00
DSQ
4efaf7ac55 统一了一下UI,用户管理那一页还是使 2025-11-15 18:13:58 +08:00
0564593a84 补充两个漏传的文件 2025-11-15 18:06:48 +08:00
fc34c37763 Merge remote-tracking branch 'origin/Django' into Django 2025-11-15 17:56:59 +08:00
64219a9a24 修复了转换为webp后不删除源文件的bug 2025-11-15 17:56:26 +08:00
35 changed files with 6644 additions and 1762 deletions

15
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,129 @@
name: CI
# Required Secrets:
# - DJANGO_SECRET_KEY: Django Secret Key
# - token: Gitea API token for creating releases
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
# - WEBDAV_USER: WebDAV username
# - WEBDAV_PASSWORD: WebDAV password
on:
push:
branches:
- Django
workflow_dispatch:
inputs:
version:
description: 版本号(如 0.2.2),为空则自动生成
required: false
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
docker-ci:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
timeout-minutes: 40
env:
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
DJANGO_DEBUG: "False"
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
IMAGE_NAME: achievement_inputing_ci
ARTIFACT_DIR: artifacts
# 请在 Secrets 中配置 ALIST_PUBLIC_URL例如 http://139.224.69.213:8080/d/ci
DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
GITEA_SERVER: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }}
RELEASE_TOKEN: ${{ secrets.token }}
steps:
- name: Ensure source present
env:
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -f "$GITHUB_WORKSPACE/Dockerfile" ]; then exit 0; fi
mkdir -p "$GITHUB_WORKSPACE"
cd "$GITHUB_WORKSPACE"
git init .
if [ -z "$TOKEN" ]; then
git fetch --depth=1 "$SERVER/$REPO.git" "$REF"
else
git -c http.extraHeader="Authorization: Bearer $TOKEN" fetch --depth=1 "$SERVER/$REPO.git" "$REF"
fi
git checkout FETCH_HEAD
- name: Derive version
run: |
msg="${{ github.event.head_commit.message }}"
ver_input="${{ github.event.inputs.version }}"
ver=""
if [ -n "$ver_input" ]; then
ver="$ver_input"
else
ver=$(echo "$msg" | grep -Eo "\[[0-9]+(\.[0-9]+){1,}\]" | head -n1 | tr -d '[]')
fi
if [ -z "$ver" ]; then
ver="$(date +%Y%m%d%H%M)-${GITHUB_SHA:0:7}"
fi
echo "VERSION=$ver" >> $GITHUB_ENV
- name: Build application image
run: |
docker build -t "$IMAGE_NAME:$VERSION" -f "$GITHUB_WORKSPACE/Dockerfile" "$GITHUB_WORKSPACE"
- name: Output image info
run: |
docker image inspect "$IMAGE_NAME:$VERSION" --format '{{.Id}} {{.Size}}'
- name: Export image tar
run: |
ART="achievement_inputing_ci_${VERSION}.tar"
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
- name: Publish artifact locally
run: |
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
- name: Publish to WebDAV
env:
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
run: |
set -e
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
# 检查必要的 secrets 是否存在
if [ -z "$WEBDAV_URL" ]; then
echo "Error: WEBDAV_URL secret is not set."
exit 1
fi
# 确保 URL 以 / 结尾
case "$WEBDAV_URL" in
*/) ;;
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
esac
echo "Uploading $ART to $WEBDAV_URL..."
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
echo "Upload success."
- name: Create release with download link
if: env.RELEASE_TOKEN != ''
run: |
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
BRANCH=${GITHUB_REF#refs/heads/}
TAG="$VERSION"
NAME="$VERSION"
BASE="${DOWNLOAD_BASE%/}"
DL="$BASE/$ART"
echo "download: $DL"
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
/.idea/
.idea/
/media/
media/
*.tar

View File

@@ -33,6 +33,7 @@ ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').sp
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django_browser_reload',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
'accounts', 'accounts',
'main', 'main',
'elastic', 'elastic',
'minio_storage',
'django_elasticsearch_dsl', 'django_elasticsearch_dsl',
] ]
@@ -49,6 +51,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django_browser_reload.middleware.BrowserReloadMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -164,3 +167,4 @@ ELASTICSEARCH_INDEX_NAMES = {
# AI Studio/OpenAI client settings # AI Studio/OpenAI client settings
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '') AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3') OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
OPENAI_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')

View File

@@ -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')),

View File

@@ -12,7 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 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 COPY . /app

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# 多级权限控制数据结构说明
## 核心概念
该设计通过 **关键字匹配Keyword Matching** 实现数据行级权限控制,适用于学校、企业等层级组织架构场景。
### 字段定义
| 字段 | 类型 | 说明 |
|------|------|------|
| `key` | `KeywordField(multi=True)` | **身份标识关键字** - 表示用户所属的层级/组织,用于匹配"自己的数据" |
| `manage_key` | `KeywordField(multi=True)` | **管理范围关键字** - 表示用户能管理的数据范围,用于匹配"管辖范围内的数据" |
---
## 权限模型图解
```
数据权限 = (数据.key ∩ 用户.key) (数据.key ∩ 用户.manage_key)
解释:
- 用户能看到的数据 = 自己的数据 OR 管辖范围内的数据
- 两者都满足"用户权限"(非管理员),只是数据范围不同
```
---
## 具体场景示例
### 场景1学生视角
**用户学生A2024届人工智能1班**
```json
{
"name": "张三",
"role": "学生",
"key": [
"2024届人工智能1班", // 班级(最细粒度)
"2024届", // 年级
"计算机与人工智能学院" // 学院
],
"manage_key": [] // 学生没有管理权限
}
```
**数据匹配逻辑:**
- 查询获奖数据时,系统查找 `key` 包含 `"2024届人工智能1班"` 的数据
- 结果:只能看到自己的获奖记录
---
### 场景2班导师视角
**用户班导师B负责2024届人工智能1班**
```json
{
"name": "李老师",
"role": "班导师",
"key": [
"计算机与人工智能学院" // 所属学院
],
"manage_key": [
"2024届人工智能1班" // 管理的班级
]
}
```
**数据匹配逻辑:**
- 查询时匹配:`key` 包含 `"计算机与人工智能学院"` **OR** `key` 包含 `"2024届人工智能1班"`
- 结果:可以看到
1. 学院层级的公共数据(通过 `key` 匹配)
2. 人工智能1班所有学生的获奖数据通过 `manage_key` 匹配)
---
### 场景3扩展案例 - 多级管理员
**用户学院教务C管理学院所有班级**
```json
{
"name": "王教务",
"role": "教务",
"key": [
"计算机与人工智能学院"
],
"manage_key": [
"2024届人工智能1班",
"2024届人工智能2班",
"2023届软件工程1班",
"计算机与人工智能学院" // 管理整个学院
]
}
```
**权限效果:**
- 可以查看学院内所有班级的获奖数据
- 仍然只是"用户权限",只是管理范围更大
---
### 场景4跨角色对比
| 角色 | key | manage_key | 可见数据范围 |
|------|-----|------------|-------------|
| **学生A** | 班级、年级、学院 | - | 仅自己的记录 |
| **班导师B** | 学院 | 班级 | 所带班级的全部记录 |
| **辅导员** | 学院 | 年级 | 整个年级的全部记录 |
| **院领导** | 学院 | 学院 | 整个学院的全部记录 |
| **校管理员** | 学校 | 学校 | 全校数据真正的admin |
---
## 数据结构存储示例
### 用户表User Index
```json
{
"user_id": "stu_2024001",
"name": "张三",
"key": ["2024届人工智能1班", "2024届", "计算机与人工智能学院"],
"manage_key": [],
"role": "student"
}
```
```json
{
"user_id": "tch_10086",
"name": "李老师",
"key": ["计算机与人工智能学院"],
"manage_key": ["2024届人工智能1班"],
"role": "advisor"
}
```
### 数据表Award Index
```json
{
"award_id": "awd_001",
"title": "校级编程大赛一等奖",
"student_name": "张三",
"key": ["2024届人工智能1班", "2024届", "计算机与人工智能学院"], // 所属层级
"created_by": "stu_2024001"
}
```
---
## 查询逻辑伪代码
```python
def get_visible_data(current_user):
"""
获取当前用户可见的数据
"""
query = {
"bool": {
"should": [
# 条件1数据的关键字与用户的key有交集自己的数据
{
"terms": {
"key": current_user.key
}
},
# 条件2数据的关键字与用户的manage_key有交集管辖的数据
{
"terms": {
"key": current_user.manage_key
}
}
],
"minimum_should_match": 1
}
}
return es.search(index="awards", body=query)
```
---
## 设计优势
1. **扁平化权限**不需要复杂的角色表RBAC通过关键字即可控制权限
2. **灵活扩展**:新增班级/年级只需添加关键字,无需修改权限架构
3. **层级继承**:数据自带完整层级路径(班级→年级→学院),支持多级查询
4. **细粒度控制**:可以精确到班级级别,也可以放宽到学院级别
生产环境用于创建数据库结构的临时命令:
python manage.py shell -c "from elastic.es_connect import create_index_with_mapping; create_index_with_mapping()"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,316 @@
<!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; margin: 0; }
/* 侧边栏样式 */
.sidebar { position: fixed; top: 0; left: 0; width: 180px; height: 100vh; background: #1e1e2e; color: white; padding: 20px; box-shadow: 2px 0 5px rgba(0,0,0,0.1); z-index: 1000; display: flex; flex-direction: column; align-items: center; }
.user-id-sidebar { 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 { display: block; color: #8be9fd; text-decoration: none; margin: 10px 0; font-size: 16px; padding: 15px; border-radius: 4px; transition: all 0.2s ease; }
.sidebar a:hover { color: #ff79c6; background-color: rgba(139, 233, 253, 0.2); }
/* 主内容区 */
.main-content { margin-left: 220px; padding: 40px; }
.profile-card { background: #fff; border-radius: 14px; box-shadow: 0 10px 24px rgba(31,35,40,0.08); padding: 30px; margin-bottom: 40px; }
.rc-card { margin-top: 18px; }
.profile-header { display: flex; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #eee; padding-bottom: 20px; }
.profile-info h2 { margin: 0; color: #1e1e2e; }
.profile-info p { margin: 5px 0; color: #666; }
.label { font-weight: bold; color: #333; margin-right: 10px; }
.section-title { font-size: 20px; font-weight: bold; margin: 34px 0 24px; color: #1e1e2e; }
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
.image-item { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05); transition: transform 0.2s; }
.image-item:hover { transform: translateY(-5px); }
.image-item img { width: 100%; height: 150px; object-fit: cover; cursor: pointer; }
.image-item .info { padding: 10px; font-size: 12px; color: #888; text-align: center; }
.no-data { text-align: center; color: #999; padding: 40px; }
.form-group { margin-bottom: 14px; }
.form-group label { display:block; margin-bottom: 6px; font-weight: 600; color: #333; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; box-sizing: border-box; }
.btn { padding: 10px 14px; border: none; border-radius: 10px; cursor: pointer; background: #4f46e5; color: #fff; }
.msg { margin-top: 10px; font-size: 13px; }
.msg.error { color: #b91c1c; }
.msg.success { color: #166534; }
/* 图片放大模态框 */
.image-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 2000; }
.image-modal-content { max-width: 90%; max-height: 90%; border-radius: 8px; }
.image-modal-close { position: absolute; top: 20px; right: 30px; color: white; font-size: 40px; font-weight: bold; cursor: pointer; }
</style>
</head>
<body>
<!-- 侧边栏 -->
<div class="sidebar">
<div class="user-id-sidebar">
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn" style="cursor:pointer;">退出登录</a>
{% csrf_token %}
</div>
</div>
<div class="main-content">
<div class="profile-card">
<div class="profile-header">
<div class="profile-info">
<h2>个人信息</h2>
</div>
</div>
<div class="profile-details">
<p><span class="label">用户名:</span> {{ profile_user.username }}</p>
<p><span class="label">用户ID:</span> {{ profile_user.user_id }}</p>
<p><span class="label">注册码:</span> {{ profile_user.registration_code|default:"无" }}</p>
<p><span class="label">所属:</span> {{ profile_user.key|join:"、"|default:"未填写" }}</p>
<p><span class="label">可管理级别:</span> {{ profile_user.manage_key|join:"、"|default:"无" }}</p>
<p><span class="label">权限级别:</span> {{ permission_name }}</p>
</div>
</div>
<div class="section-title">我的提交</div>
{% if achievements %}
<div class="image-grid">
{% for item in achievements %}
<div class="image-item">
{% if item.image_url %}
<img src="{{ item.image_url }}" alt="提交的图片" onclick="openModal(this.src)">
{% else %}
<div style="height: 150px; background: #eee; display: flex; align-items: center; justify-content: center; color: #ccc;">无图片</div>
{% endif %}
<div style="padding: 8px; text-align: center;">
<a href="{% url 'elastic:manage_page' %}?id={{ item.id }}" style="display: inline-block; padding: 4px 12px; background: #eef2ff; color: #4f46e5; text-decoration: none; border-radius: 4px; font-size: 12px; transition: background 0.2s;">管理此条</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="profile-card no-data">
<p>你还没有提交过任何图片。</p>
<a href="{% url 'elastic:upload_page' %}" style="color: #2d8cf0; text-decoration: none;">去上传第一张图片吧!</a>
</div>
{% endif %}
<div class="profile-card rc-card">
<div class="profile-header">
<div class="profile-info">
<h2>替换注册码</h2>
</div>
</div>
<form id="rcForm">
<div class="form-group">
<label for="newRegCode">新注册码</label>
<input type="text" id="newRegCode" placeholder="输入新注册码后替换原有 key" required>
</div>
<div class="form-group">
<label>预览</label>
<div id="rcPreview" style="background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; padding:10px 12px; font-size:13px; color:#334155;">
<div style="color:#64748b;">输入注册码后自动显示 key 预览</div>
</div>
</div>
<button type="submit" class="btn">替换</button>
<div id="rcMsg" class="msg"></div>
</form>
</div>
{% if permission_name != "管理员" and not profile_user.manage_key %}
<div class="profile-card">
<div class="profile-header">
<div class="profile-info">
<h2>修改密码</h2>
</div>
</div>
<form id="pwdForm">
<div class="form-group">
<label for="newPassword">新密码</label>
<input type="password" id="newPassword" autocomplete="new-password" required>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" autocomplete="new-password" required>
</div>
<button type="submit" class="btn">保存</button>
<div id="pwdMsg" class="msg"></div>
</form>
</div>
{% endif %}
</div>
<!-- 图片放大模态框 -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close" onclick="closeModal()">&times;</span>
<img id="modalImg" class="image-modal-content">
</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('logoutBtn').addEventListener('click', async () => {
if(!confirm('确定要退出登录吗?')) return;
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
headers: { 'X-CSRFToken': csrftoken || '' }
});
const data = await resp.json();
if (data.ok) window.location.href = data.redirect_url;
} catch (e) { alert('登出失败'); }
});
// 图片放大功能
function openModal(src) {
const modal = document.getElementById('imageModal');
const modalImg = document.getElementById('modalImg');
modal.style.display = "flex";
modalImg.src = src;
}
function closeModal() {
document.getElementById('imageModal').style.display = "none";
}
window.onclick = function(event) {
const modal = document.getElementById('imageModal');
if (event.target == modal) closeModal();
}
const pwdForm = document.getElementById('pwdForm');
if (pwdForm) {
pwdForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('pwdMsg');
msg.textContent = '';
msg.className = 'msg';
const pwd = (document.getElementById('newPassword').value || '').trim();
const cpwd = (document.getElementById('confirmPassword').value || '').trim();
if (pwd !== cpwd) {
msg.textContent = '密码和确认密码不匹配';
msg.className = 'msg error';
return;
}
if (pwd.length < 6) {
msg.textContent = '密码长度至少为6位';
msg.className = 'msg error';
return;
}
try {
const csrftoken = getCookie('csrftoken');
const resp = await fetch(`/elastic/users/{{ profile_user.user_id }}/update/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ password: pwd })
});
const data = await resp.json();
if (resp.ok && data.status === 'success') {
msg.textContent = '修改成功';
msg.className = 'msg success';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
} else {
msg.textContent = data.message || '操作失败';
msg.className = 'msg error';
}
} catch (err) {
msg.textContent = '操作失败';
msg.className = 'msg error';
}
});
}
const rcForm = document.getElementById('rcForm');
if (rcForm) {
let rcPreviewTimer = null;
let rcPreviewSeq = 0;
const rcInput = document.getElementById('newRegCode');
const rcPreview = document.getElementById('rcPreview');
async function refreshRcPreview(code) {
const seq = ++rcPreviewSeq;
if (!code) {
rcPreview.innerHTML = '<div style="color:#64748b;">输入注册码后自动显示 key 预览</div>';
return;
}
rcPreview.innerHTML = '<div style="color:#64748b;">正在查询...</div>';
try {
const resp = await fetch(`/accounts/profile/registration-code/preview/?code=${encodeURIComponent(code)}`, { method: 'GET', credentials: 'same-origin' });
const data = await resp.json();
if (seq !== rcPreviewSeq) return;
if (!(resp.ok && data && data.ok)) {
const msg = (data && data.message) ? data.message : '查询失败';
rcPreview.innerHTML = `<div style="color:#b91c1c;">${msg}</div>`;
return;
}
const keys = ((data.data || {}).keys || []).map(String).filter(Boolean);
const manageKeys = ((data.data || {}).manage_keys || []).map(String).filter(Boolean);
const keysText = keys.length ? keys.join('、') : '无';
const manageText = manageKeys.length ? manageKeys.join('、') : '无';
rcPreview.innerHTML = `<div><span style="font-weight:700;">key</span>${keysText}</div><div style="margin-top:6px;"><span style="font-weight:700;">manage_key</span>${manageText}</div>`;
} catch (e) {
if (seq !== rcPreviewSeq) return;
rcPreview.innerHTML = '<div style="color:#b91c1c;">查询失败</div>';
}
}
if (rcInput) {
rcInput.addEventListener('input', () => {
const code = (rcInput.value || '').trim();
if (rcPreviewTimer) window.clearTimeout(rcPreviewTimer);
rcPreviewTimer = window.setTimeout(() => refreshRcPreview(code), 300);
});
refreshRcPreview((rcInput.value || '').trim());
}
rcForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('rcMsg');
msg.textContent = '';
msg.className = 'msg';
const code = (document.getElementById('newRegCode').value || '').trim();
if (!code) {
msg.textContent = '请输入注册码';
msg.className = 'msg error';
return;
}
if (!confirm('确定要替换注册码吗?该操作会替换你当前的 key。')) return;
try {
const csrftoken = getCookie('csrftoken');
const resp = await fetch('/accounts/profile/registration-code/replace/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ code })
});
const data = await resp.json();
if (resp.ok && data.ok) {
msg.textContent = '替换成功';
msg.className = 'msg success';
window.location.reload();
} else {
msg.textContent = (data && data.message) ? data.message : '替换失败';
msg.className = 'msg error';
}
} catch (err) {
msg.textContent = '替换失败';
msg.className = 'msg error';
}
});
}
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,186 @@
<!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: #f5f6fa; }
.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; }
.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 { display: block; color: #8be9fd; text-decoration: none; margin: 10px 0; font-size: 16px; padding: 15px; border-radius: 4px; transition: all 0.2s ease; }
.sidebar a:hover { color: #ff79c6; background-color: rgba(139, 233, 253, 0.2); }
.main-content { margin-left: 220px; padding: 40px; }
.card { 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: 14px; }
.btn { padding: 8px 12px; border: none; border-radius: 10px; cursor: pointer; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-secondary { background: #64748b; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.muted { color: #6b7280; font-size: 12px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; border-bottom: 1px solid #e5e7eb; padding: 10px 8px; vertical-align: top; font-size: 13px; }
tr:hover { background: #f8fafc; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #eef2ff; color: #3730a3; }
.tag.pending { background: #fff7ed; color: #9a3412; }
.tag.approved { background: #dcfce7; color: #166534; }
.tag.rejected { background: #fee2e2; color: #991b1b; }
</style>
{% csrf_token %}
</head>
<body>
<div class="sidebar">
<h3>你好,{{ username|default:"管理员" }}</h3>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn" style="cursor:pointer;">退出登录</a>
<div id="logoutMsg" class="muted" style="margin-top:6px;"></div>
{% csrf_token %}
</div>
</div>
<div class="main-content">
<div class="card">
<div class="header">
<h2 style="margin:0;">注册码申请管理</h2>
<div style="display:flex; gap:10px; align-items:center;">
<select id="statusFilter" style="padding:8px 10px; border:1px solid #d1d5db; border-radius:10px;">
<option value="pending">待审核</option>
<option value="">全部</option>
<option value="approved">已同意</option>
<option value="rejected">已拒绝</option>
</select>
<button id="refreshBtn" class="btn btn-secondary" type="button">刷新</button>
</div>
</div>
<div class="muted">同意后,用户会获得“注册码管理”入口,且仅能使用自己新增的 key。</div>
<table>
<thead>
<tr>
<th style="width:120px;">用户</th>
<th>申请理由</th>
<th style="width:170px;">时间</th>
<th style="width:110px;">状态</th>
<th style="width:220px;">操作</th>
</tr>
</thead>
<tbody id="reqBody"></tbody>
</table>
<div id="pageMsg" class="muted" style="margin-top:12px;"></div>
</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('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 (data.ok) window.location.href = data.redirect_url;
} catch (e) { msg.textContent = '登出失败'; }
});
function fmtTime(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 || '';
}
function renderStatus(s){
const v = String(s || 'pending');
const cls = (v === 'approved' || v === 'rejected') ? v : 'pending';
const text = v === 'approved' ? '已同意' : (v === 'rejected' ? '已拒绝' : '待审核');
return `<span class="tag ${cls}">${text}</span>`;
}
async function loadRequests(){
const status = document.getElementById('statusFilter').value;
const msg = document.getElementById('pageMsg');
msg.textContent = '加载中...';
const url = status ? `/accounts/registration-code/requests/list/?status=${encodeURIComponent(status)}` : '/accounts/registration-code/requests/list/';
try{
const resp = await fetch(url, { credentials: 'same-origin' });
const data = await resp.json();
if(!(resp.ok && data && data.ok)){
msg.textContent = (data && data.message) ? data.message : '加载失败';
return;
}
const body = document.getElementById('reqBody');
body.innerHTML = '';
const rows = data.data || [];
if(!rows.length){
msg.textContent = '暂无数据';
return;
}
msg.textContent = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
const uname = (r.username || '') + (r.user_id !== undefined ? `${r.user_id}` : '');
const reason = String(r.reason || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const created = fmtTime(r.created_at);
const statusHtml = renderStatus(r.status);
const id = r.request_id || r._id || '';
const ops = (String(r.status || 'pending') === 'pending')
? `<button class="btn btn-primary" data-act="approve" data-id="${id}">同意</button>
<button class="btn btn-danger" data-act="reject" data-id="${id}">拒绝</button>`
: `<button class="btn btn-secondary" data-act="view" data-id="${id}">查看</button>`;
tr.innerHTML = `<td>${uname}</td><td style="white-space:pre-wrap;">${reason}</td><td>${created}</td><td>${statusHtml}</td><td>${ops}</td>`;
body.appendChild(tr);
});
}catch(e){
msg.textContent = '加载失败';
}
}
async function decide(id, action){
const csrftoken = getCookie('csrftoken');
const note = '';
const resp = await fetch('/accounts/registration-code/requests/decide/', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
body: JSON.stringify({ request_id: id, action, note })
});
const data = await resp.json();
if(!(resp.ok && data && data.ok)){
alert((data && data.message) ? data.message : '操作失败');
return;
}
loadRequests();
}
document.getElementById('refreshBtn').addEventListener('click', loadRequests);
document.getElementById('statusFilter').addEventListener('change', loadRequests);
document.addEventListener('click', (e)=>{
const t = e.target;
if(!(t && t.dataset && t.dataset.id && t.dataset.act)) return;
const id = t.dataset.id;
const act = t.dataset.act;
if(act === 'approve'){
if(confirm('确定同意该申请吗?')) decide(id, 'approve');
}else if(act === 'reject'){
if(confirm('确定拒绝该申请吗?')) decide(id, 'reject');
}else if(act === 'view'){
return;
}
});
loadRequests();
</script>
</body>
</html>

View File

@@ -4,7 +4,19 @@ 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"),
path("profile/", views.profile_page, name="profile"),
path("profile/registration-code/replace/", views.replace_registration_code_view, name="replace_registration_code"),
path("profile/registration-code/preview/", views.registration_code_preview_view, name="registration_code_preview"),
path("registration-code/request/submit/", views.submit_registration_code_request_view, name="submit_registration_code_request"),
path("registration-code/requests/", views.registration_code_requests_page, name="registration_code_requests_page"),
path("registration-code/requests/list/", views.list_registration_code_requests_view, name="list_registration_code_requests"),
path("registration-code/requests/decide/", views.decide_registration_code_request_view, name="decide_registration_code_request"),
] ]

View File

@@ -1,7 +1,11 @@
import base64 import base64
import json import json
import os import os
import hmac import io
import random
import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
@@ -10,7 +14,8 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from django.conf import settings from django.conf import settings
from .es_client import get_user_by_username from .es_client import get_user_by_username
from .crypto import salt_for_username, hmac_sha256 from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password, generate_rsa_private_pem_b64, public_spki_b64_from_private_pem_b64, rsa_oaep_decrypt_b64_with_private_pem
from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data, update_user_by_id, get_user_by_id, create_registration_code_manage_request, find_pending_registration_code_manage_request, list_registration_code_manage_requests, decide_registration_code_manage_request, get_registration_code_manage_request
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -19,87 +24,137 @@ 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(["GET"])
session_username = request.session.get("challenge_username") @ensure_csrf_cookie
nonce_b64 = request.session.get("challenge_nonce") def profile_page(request):
if not session_username or not nonce_b64 or session_username != username: session_user_id = request.session.get("user_id")
return HttpResponseBadRequest("Challenge not found or mismatched user") if session_user_id is None:
return redirect("/accounts/login/")
# Lookup user in ES (placeholder) # 获取用户信息
user = get_user_by_id(session_user_id)
if not user:
return redirect("/accounts/login/")
# 获取个人提交的成就(图片)
from elastic.es_connect import search_all
from elastic.views import _attach_image_urls
raw_results = [r for r in search_all() if str(r.get("writer_id", "")) == str(session_user_id)]
achievements = _attach_image_urls(request, raw_results)
permission_name = "管理员" if int(user.get("permission", 1)) == 0 else "普通用户"
context = {
"username": request.session.get("username"),
"profile_user": user,
"permission_name": permission_name,
"achievements": achievements,
}
return render(request, "accounts/profile.html", context)
@require_http_methods(["POST"])
@csrf_protect
def secure_login_submit(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
iv_b64 = payload.get("iv", "")
ct_b64 = payload.get("ciphertext", "")
if not iv_b64 or not ct_b64:
return HttpResponseBadRequest("Missing fields")
key_b64 = request.session.get("session_enc_key_b64")
if not key_b64:
return HttpResponseBadRequest("Session key missing")
try:
key_bytes = base64.b64decode(key_b64)
pt = aes_gcm_decrypt_b64(key_bytes, iv_b64, ct_b64)
obj = json.loads(pt.decode("utf-8"))
except Exception:
return HttpResponseBadRequest("Decrypt error")
username = (obj.get("username") or "").strip()
password = (obj.get("password") or "")
if not username or not password:
return HttpResponseBadRequest("Missing credentials")
if bool(request.session.get("login_failed_once")):
ans = (obj.get("captcha") or "").strip()
code = request.session.get("captcha_code")
if not ans or not code or ans.lower() != str(code).lower():
return JsonResponse({"ok": False, "message": "验证码错误", "captcha_required": True}, status=401)
user = get_user_by_username(username) 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"])
@@ -144,3 +199,296 @@ 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 [],
"registration_code": (rc.get("code") if rc else None),
})
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 replace_registration_code_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip()
if not code:
return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400)
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
keys = list(rc.get("keys") or [])
manage_keys = list(rc.get("manage_keys") or [])
ok = update_user_by_id(session_user_id, key=keys, manage_key=manage_keys, registration_code=code)
if not ok:
return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
return JsonResponse({"ok": True})
@require_http_methods(["GET"])
def registration_code_preview_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
code = (request.GET.get("code") or "").strip()
if not code:
return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400)
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
return JsonResponse(
{
"ok": True,
"data": {
"code": rc.get("code"),
"keys": list(rc.get("keys") or []),
"manage_keys": list(rc.get("manage_keys") or []),
"expires_at": rc.get("expires_at"),
},
}
)
@require_http_methods(["POST"])
@csrf_protect
def submit_registration_code_request_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
try:
perm = int(request.session.get("permission", 1))
except Exception:
perm = 1
if perm == 0:
return JsonResponse({"ok": False, "message": "无权限"}, status=403)
me = get_user_by_id(session_user_id) or {}
if (me.get("manage_key") or []) or int(me.get("can_manage_registration_codes") or 0) == 1:
return JsonResponse({"ok": False, "message": "无需申请"}, status=400)
if str(me.get("registration_code") or "").strip():
return JsonResponse({"ok": False, "message": "已有注册码,无法申请"}, status=400)
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
reason = (payload.get("reason") or "").strip()
if not reason:
return JsonResponse({"ok": False, "message": "请填写申请理由"}, status=400)
pending = find_pending_registration_code_manage_request(session_user_id)
if pending:
return JsonResponse({"ok": True, "message": "已提交申请"})
rid = create_registration_code_manage_request(session_user_id, me.get("username"), reason)
if not rid:
return JsonResponse({"ok": False, "message": "提交失败"}, status=500)
return JsonResponse({"ok": True})
@require_http_methods(["GET"])
@ensure_csrf_cookie
def registration_code_requests_page(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return redirect("/accounts/login/")
try:
perm = int(request.session.get("permission", 1))
except Exception:
perm = 1
if perm != 0:
return redirect("/main/home/")
me = get_user_by_id(session_user_id) or {}
return render(request, "accounts/registration_code_requests.html", {"username": me.get("username")})
@require_http_methods(["GET"])
def list_registration_code_requests_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
try:
perm = int(request.session.get("permission", 1))
except Exception:
perm = 1
if perm != 0:
return JsonResponse({"ok": False, "message": "无权限"}, status=403)
status = (request.GET.get("status") or "").strip() or None
data = list_registration_code_manage_requests(status=status)
return JsonResponse({"ok": True, "data": data})
@require_http_methods(["POST"])
@csrf_protect
def decide_registration_code_request_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
try:
perm = int(request.session.get("permission", 1))
except Exception:
perm = 1
if perm != 0:
return JsonResponse({"ok": False, "message": "无权限"}, status=403)
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
request_id = (payload.get("request_id") or "").strip()
action = (payload.get("action") or "").strip().lower()
note = (payload.get("note") or "").strip()
if not request_id or action not in ("approve", "reject"):
return JsonResponse({"ok": False, "message": "参数错误"}, status=400)
req = get_registration_code_manage_request(request_id)
if not req:
return JsonResponse({"ok": False, "message": "申请不存在"}, status=404)
status = "approved" if action == "approve" else "rejected"
ok = decide_registration_code_manage_request(request_id, status=status, reviewed_by=session_user_id, reviewer_note=note)
if not ok:
return JsonResponse({"ok": False, "message": "操作失败"}, status=500)
if status == "approved":
uid = req.get("user_id")
update_user_by_id(uid, can_manage_registration_codes=1, registration_manage_keys=[])
return JsonResponse({"ok": True})
@require_http_methods(["POST"])
@csrf_protect
def send_email_code(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
email = (payload.get("email") or "").strip()
if not email:
return HttpResponseBadRequest("Missing email")
if "@" not in email:
return JsonResponse({"ok": False, "message": "邮箱格式不正确"}, status=400)
verify_code = "".join(random.choice(string.digits) for _ in range(6))
ttl = int(os.environ.get("SMTP_CODE_TTL", "600") or 600)
request.session["email_verify"] = {"email": email, "code": verify_code, "expires_at": int(time.time()) + max(60, ttl)}
ok, msg = _send_smtp_email(email, verify_code)
if not ok:
return JsonResponse({"ok": False, "message": msg or "验证码发送失败"}, status=500)
return JsonResponse({"ok": True})
def _send_smtp_email(to_email: str, code: str):
host = os.environ.get("SMTP_HOST", "")
port_raw = os.environ.get("SMTP_PORT", "")
try:
port = int(port_raw) if port_raw else 0
except Exception:
port = 0
user = os.environ.get("SMTP_USERNAME") or os.environ.get("SMTP_USER") or ""
password = os.environ.get("SMTP_PASSWORD", "")
use_tls = str(os.environ.get("SMTP_USE_TLS", "")).lower() in ("1", "true", "yes")
use_ssl = str(os.environ.get("SMTP_USE_SSL", "")).lower() in ("1", "true", "yes")
sender = os.environ.get("SMTP_FROM_EMAIL") or os.environ.get("SMTP_FROM") or user or ""
subject = os.environ.get("SMTP_SUBJECT") or "邮箱验证码"
if not host or not port or not sender:
return False, "缺少SMTP配置"
body = f"您的验证码是:{code}10分钟内有效。"
msg = f"From: {sender}\r\nTo: {to_email}\r\nSubject: {subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{body}"
try:
if use_ssl:
server = smtplib.SMTP_SSL(host, port)
else:
server = smtplib.SMTP(host, port)
server.ehlo()
if use_tls and not use_ssl:
server.starttls()
server.ehlo()
if user and password:
server.login(user, password)
server.sendmail(sender, [to_email], msg.encode("utf-8"))
try:
server.quit()
except Exception:
try:
server.close()
except Exception:
pass
return True, ""
except Exception as e:
return False, str(e)

Binary file not shown.

View File

@@ -17,9 +17,8 @@ class ElasticConfig(AppConfig):
return return
# 延迟导入,避免循环导入或过早加载 # 延迟导入,避免循环导入或过早加载
from .es_connect import create_index_with_mapping, start_daily_analytics_scheduler from .es_connect import create_index_with_mapping
try: try:
create_index_with_mapping() create_index_with_mapping()
start_daily_analytics_scheduler()
except Exception as e: except Exception as e:
print(f"❌ ES 初始化失败: {e}") print(f"❌ ES 初始化失败: {e}")

View File

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

View File

@@ -5,12 +5,14 @@ Django版本的ES连接和操作模块
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from elasticsearch_dsl import connections from elasticsearch_dsl import connections
import os import os
from .documents import AchievementDocument, UserDocument, GlobalDocument from .documents import AchievementDocument, UserDocument, GlobalDocument, RegistrationCodeDocument
from accounts.crypto import hash_password_random_salt
from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
import hashlib import hashlib
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import threading import uuid
import json
# 使用环境变量配置ES连接默认为本机 # 使用环境变量配置ES连接默认为本机
_ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200') _ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')
@@ -63,10 +65,12 @@ def create_index_with_mapping():
# --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)--- # --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)---
# 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断 # 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断
_salt_b64, _hash_b64 = hash_password_random_salt("admin")
admin_user = { admin_user = {
"user_id": 0, "user_id": 0,
"username": "admin", "username": "admin",
"password": "admin", # ⚠️ 生产环境务必加密! "password_hash": _hash_b64,
"password_salt": _salt_b64,
"permission": 0 "permission": 0
} }
# 可选:检查 admin 是否已存在(根据 user_id 或 username # 可选:检查 admin 是否已存在(根据 user_id 或 username
@@ -112,6 +116,136 @@ def ensure_type_in_list(type_name: str):
except Exception: except Exception:
return False return False
def get_keys_list():
try:
try:
doc = GlobalDocument.get(id='keys')
cur = list(doc.keys_list or [])
except Exception:
cur = []
doc = GlobalDocument(keys_list=cur)
doc.meta.id = 'keys'
doc.save()
return [str(t).strip().strip(';') for t in cur]
except Exception:
return []
def ensure_key_in_list(key_name: str):
if not key_name:
return False
norm = str(key_name).strip().strip(';')
try:
try:
doc = GlobalDocument.get(id='keys')
cur = list(doc.keys_list or [])
except Exception:
cur = []
doc = GlobalDocument(keys_list=cur)
doc.meta.id = 'keys'
cur_sanitized = {str(t).strip().strip(';') for t in cur}
if norm not in cur_sanitized:
cur.append(norm)
doc.keys_list = cur
doc.save()
return True
return False
except Exception:
return False
def generate_registration_code(keys=None, manage_keys=None, expires_in_days: int = 30, created_by: int = None):
try:
keys = list(keys or [])
manage_keys = list(manage_keys or [])
for k in list(keys):
ensure_key_in_list(k)
for mk in list(manage_keys):
ensure_key_in_list(mk)
code = uuid.uuid4().hex + str(int(time.time()))[-6:]
now = datetime.now(timezone.utc)
expires = now + timedelta(days=max(1, int(expires_in_days or 30)))
doc = RegistrationCodeDocument(
code=code,
keys=keys,
manage_keys=manage_keys,
created_at=now.isoformat(),
expires_at=expires.isoformat(),
created_by=created_by,
)
doc.meta.id = code
doc.save()
return {
"code": code,
"keys": keys,
"manage_keys": manage_keys,
"created_at": now.isoformat(),
"expires_at": expires.isoformat(),
}
except Exception as e:
return None
def get_registration_code(code: str):
try:
doc = RegistrationCodeDocument.get(id=str(code))
return {
"code": getattr(doc, 'code', str(code)),
"keys": list(getattr(doc, 'keys', []) or []),
"manage_keys": list(getattr(doc, 'manage_keys', []) or []),
"created_at": getattr(doc, 'created_at', None),
"expires_at": getattr(doc, 'expires_at', None),
"created_by": getattr(doc, 'created_by', None),
}
except Exception:
return None
def list_registration_codes():
try:
search = RegistrationCodeDocument.search()
body = {
"sort": [{"created_at": {"order": "desc"}}],
"query": {"exists": {"field": "code"}}
}
search = search.update_from_dict(body)
resp = search.execute()
out = []
now = datetime.now(timezone.utc)
for hit in resp:
try:
if not getattr(hit, 'code', None):
continue
except Exception:
continue
exp = getattr(hit, 'expires_at', None)
try:
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = datetime.fromisoformat(str(exp))
except Exception:
exp_dt = None
active = bool(exp_dt and exp_dt > now)
out.append({
"code": getattr(hit, 'code', ''),
"keys": list(getattr(hit, 'keys', []) or []),
"manage_keys": list(getattr(hit, 'manage_keys', []) or []),
"created_at": getattr(hit, 'created_at', None),
"expires_at": getattr(hit, 'expires_at', None),
"created_by": getattr(hit, 'created_by', None),
"active": active,
})
return out
except Exception:
return []
def revoke_registration_code(code: str):
try:
doc = RegistrationCodeDocument.get(id=str(code))
now = datetime.now(timezone.utc).isoformat()
doc.expires_at = now
doc.save()
return True
except Exception:
return False
def get_doc_id(data): def get_doc_id(data):
""" """
根据数据内容生成唯一ID用于去重 根据数据内容生成唯一ID用于去重
@@ -173,7 +307,8 @@ def search_data(query):
"_id": hit.meta.id, "_id": hit.meta.id,
"writer_id": hit.writer_id, "writer_id": hit.writer_id,
"data": hit.data, "data": hit.data,
"image": hit.image "image": hit.image,
"time": getattr(hit, "time", None),
}) })
return results return results
@@ -194,7 +329,8 @@ def search_all():
"_id": hit.meta.id, "_id": hit.meta.id,
"writer_id": hit.writer_id, "writer_id": hit.writer_id,
"data": hit.data, "data": hit.data,
"image": hit.image "image": hit.image,
"time": getattr(hit, "time", None),
}) })
return results return results
@@ -301,7 +437,8 @@ def search_by_any_field(keyword):
"_id": hit.meta.id, "_id": hit.meta.id,
"writer_id": hit.writer_id, "writer_id": hit.writer_id,
"data": hit.data, "data": hit.data,
"image": hit.image "image": hit.image,
"time": getattr(hit, "time", None),
}) })
return results return results
@@ -309,67 +446,316 @@ def search_by_any_field(keyword):
print(f"模糊搜索失败: {str(e)}") print(f"模糊搜索失败: {str(e)}")
return [] return []
ANALYTICS_CACHE = {"data": None, "ts": 0}
def _compute_hist(range_gte: str, interval: str, fmt: str): def _type_filters_from_list(limit: int = None):
from elasticsearch_dsl import Search try:
s = AchievementDocument.search()
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
s = s.extra(size=0)
s.aggs.bucket('b', 'date_histogram', field='time', calendar_interval=interval, format=fmt, min_doc_count=0)
resp = s.execute()
buckets = getattr(resp.aggs, 'b').buckets
return [{"label": b.key_as_string, "count": b.doc_count} for b in buckets]
def _compute_type_counts(range_gte: str, types: list):
counts = []
for t in types:
s = AchievementDocument.search()
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
s = s.query('match_phrase', data=str(t))
total = s.count()
counts.append({"type": str(t), "count": int(total)})
return counts
def compute_analytics():
types = get_type_list() types = get_type_list()
days = _compute_hist('now-10d/d', 'day', 'yyyy-MM-dd') except Exception:
weeks = _compute_hist('now-10w/w', 'week', 'yyyy-ww') types = ['软著', '专利', '奖状']
months = _compute_hist('now-10M/M', 'month', 'yyyy-MM') if isinstance(limit, int) and limit > 0:
pie_1m = _compute_type_counts('now-1M/M', types) types = types[:limit]
pie_12m = _compute_type_counts('now-12M/M', types) filters = {}
return { for t in types:
"last_10_days": days[-10:], key = str(t)
"last_10_weeks": weeks[-10:], # 精确匹配键与值之间的关系,避免其它字段中的同名值造成误匹配
"last_10_months": months[-10:], pattern = f'*"数据类型": "{key}"*'
"type_pie_1m": pie_1m, filters[key] = {"wildcard": {"data.keyword": {"value": pattern}}}
"type_pie_12m": pie_12m, 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 delete_key_globally(key_to_remove: str):
try:
# 1. 从 GlobalDocument (id='keys') 中彻底移除
try:
doc = GlobalDocument.get(id='keys')
current_keys = list(doc.keys_list or [])
# 使用列表推导式进行彻底删除,处理可能的重复项
new_keys = [k.strip().strip(';') for k in current_keys if k.strip().strip(';') != key_to_remove]
if len(new_keys) != len(current_keys):
doc.keys_list = new_keys
doc.save()
print(f"已从全局列表移除 Key: {key_to_remove}")
except Exception as e:
print(f"从全局列表移除 Key 失败: {str(e)}")
# 2. 同步清理所有注册码中的该 key (无论是 keys 还是 manage_keys 字段)
from elasticsearch.helpers import scan
query = {
"query": {
"bool": {
"should": [
{"term": {"keys": key_to_remove}},
{"term": {"manage_keys": key_to_remove}}
],
"must": [
{"exists": {"field": "code"}} # 确保是注册码文档
]
}
}
} }
def get_analytics_overview(force: bool = False): updated_count = 0
now_ts = time.time() for hit in scan(es, query=query, index=GLOBAL_INDEX_NAME):
if force or ANALYTICS_CACHE["data"] is None or (now_ts - ANALYTICS_CACHE["ts"]) > 3600:
ANALYTICS_CACHE["data"] = compute_analytics()
ANALYTICS_CACHE["ts"] = now_ts
return ANALYTICS_CACHE["data"]
def _seconds_until_hour(h: int):
now = datetime.now()
tgt = now.replace(hour=h, minute=0, second=0, microsecond=0)
if tgt <= now:
tgt = tgt + timedelta(days=1)
return max(0, int((tgt - now).total_seconds()))
def start_daily_analytics_scheduler():
def _run_and_reschedule():
try: try:
get_analytics_overview(force=True) # 重新获取文档对象进行操作
doc = RegistrationCodeDocument.get(id=hit['_id'])
modified = False
if doc.keys:
old_keys = list(doc.keys)
new_ks = [k for k in old_keys if k != key_to_remove]
if len(new_ks) != len(old_keys):
doc.keys = new_ks
modified = True
if doc.manage_keys:
old_mks = list(doc.manage_keys)
new_mks = [k for k in old_mks if k != key_to_remove]
if len(new_mks) != len(old_mks):
doc.manage_keys = new_mks
modified = True
if modified:
doc.save()
updated_count += 1
except Exception as e: except Exception as e:
print(f"分析任务失败: {e}") print(f"同步清理注册码 {hit['_id']} 失败: {str(e)}")
finally:
threading.Timer(24 * 3600, _run_and_reschedule).start() # 3. 同步清理所有用户中的该 key (无论是 key 还是 manage_key 字段)
delay = _seconds_until_hour(3) try:
threading.Timer(delay, _run_and_reschedule).start() user_query = {
"query": {
"bool": {
"should": [
{"term": {"key": key_to_remove}},
{"term": {"manage_key": key_to_remove}}
]
}
}
}
for user_hit in scan(es, query=user_query, index=USER_INDEX_NAME):
try:
user_doc = UserDocument.get(id=user_hit['_id'])
user_modified = False
if user_doc.key:
old_uk = list(user_doc.key)
new_uks = [k for k in old_uk if k != key_to_remove]
if len(new_uks) != len(old_uk):
user_doc.key = new_uks
user_modified = True
if user_doc.manage_key:
old_umk = list(user_doc.manage_key)
new_umks = [k for k in old_umk if k != key_to_remove]
if len(new_umks) != len(old_umk):
user_doc.manage_key = new_umks
user_modified = True
if user_modified:
user_doc.save()
except Exception as e:
print(f"同步清理用户 {user_hit['_id']} 失败: {str(e)}")
except Exception as e:
print(f"扫描用户失败: {str(e)}")
return True, updated_count
except Exception as e:
print(f"全局删除 Key 失败: {str(e)}")
return False, 0
def analytics_types(gte: str = None, lte: str = None, limit: int = 12):
try:
filters = _type_filters_from_list(limit=limit)
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 ""
def _extract_detail(s: str):
if not s:
return ""
try:
obj = json.loads(s)
if isinstance(obj, dict):
# 尝试获取常见的标题字段
for key in ["标题", "名称", "项目名称", "成果名称", "软件名称", "专利名称", "获奖名称", "证书名称", "姓名"]:
v = obj.get(key)
if isinstance(v, str) and v:
return v
# 如果没有找到常见标题,尝试获取第一个非"数据类型"的字符串值
for k, v in obj.items():
if k != "数据类型" and isinstance(v, str) and v and len(v) < 50:
return v
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', ''))
dval = _extract_detail(getattr(hit, 'data', ''))
results.append({
"_id": hit.meta.id,
"writer_id": w,
"username": uname or "",
"type": tval or "",
"detail": dval 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):
""" """
@@ -382,11 +768,29 @@ 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'),
registration_code=(user_data.get('registration_code') or None),
can_manage_registration_codes=int(user_data.get('can_manage_registration_codes') or 0),
registration_manage_keys=list(user_data.get('registration_manage_keys') or []),
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')}")
@@ -395,26 +799,6 @@ 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,
"password": hit.password,
"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):
""" """
根据用户名获取用户数据 根据用户名获取用户数据
@@ -435,8 +819,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:
@@ -455,7 +840,13 @@ 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),
"registration_code": getattr(hit, 'registration_code', None),
"can_manage_registration_codes": int(getattr(hit, 'can_manage_registration_codes', 0) or 0),
"registration_manage_keys": list(getattr(hit, 'registration_manage_keys', []) or []),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
}) })
return users return users
@@ -473,61 +864,164 @@ def get_user_by_id(user_id):
return { return {
"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),
"registration_code": getattr(hit, 'registration_code', None),
"can_manage_registration_codes": int(getattr(hit, 'can_manage_registration_codes', 0) or 0),
"registration_manage_keys": list(getattr(hit, 'registration_manage_keys', []) or []),
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
} }
return None return None
except Exception as e: except Exception as e:
print(f"获取用户数据失败: {str(e)}") print(f"获取用户数据失败: {str(e)}")
return None return None
def delete_user_by_username(username): def delete_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() doc = UserDocument.get(id=hit.meta.id)
print(f"用户 {username} 删除成功") 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, key=None, manage_key=None, registration_code=None, can_manage_registration_codes=None, registration_manage_keys=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
if key is not None:
doc.key = list(key)
if manage_key is not None:
doc.manage_key = list(manage_key)
if registration_code is not None:
doc.registration_code = str(registration_code) if str(registration_code).strip() else None
if can_manage_registration_codes is not None:
try:
doc.can_manage_registration_codes = int(can_manage_registration_codes)
except Exception:
doc.can_manage_registration_codes = 0
if registration_manage_keys is not None:
doc.registration_manage_keys = list(registration_manage_keys)
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
def _rc_request_now_iso():
return datetime.now(timezone.utc).isoformat()
def create_registration_code_manage_request(user_id: int, username: str, reason: str):
try:
rid = uuid.uuid4().hex
doc = {
"kind": "registration_code_manage_request",
"request_id": rid,
"user_id": int(user_id),
"username": str(username or ""),
"reason": str(reason or ""),
"status": "pending",
"created_at": _rc_request_now_iso(),
}
es.index(index=GLOBAL_INDEX_NAME, id=rid, body=doc)
return rid
except Exception as e:
print(f"创建注册码管理申请失败: {str(e)}")
return None
def find_pending_registration_code_manage_request(user_id: int):
try:
body = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"kind": "registration_code_manage_request"}},
{"term": {"user_id": int(user_id)}},
{"term": {"status": "pending"}},
]
}
},
"sort": [{"created_at": {"order": "desc"}}],
}
resp = es.search(index=GLOBAL_INDEX_NAME, body=body)
hits = (resp.get("hits") or {}).get("hits") or []
if not hits:
return None
h = hits[0]
src = h.get("_source") or {}
src["_id"] = h.get("_id")
return src
except Exception as e:
print(f"查询注册码管理申请失败: {str(e)}")
return None
def get_registration_code_manage_request(request_id: str):
try:
resp = es.get(index=GLOBAL_INDEX_NAME, id=str(request_id))
src = resp.get("_source") or {}
if (src.get("kind") or "") != "registration_code_manage_request":
return None
src["_id"] = resp.get("_id")
return src
except Exception:
return None
def list_registration_code_manage_requests(status: str = None, limit: int = 200):
try:
must = [{"term": {"kind": "registration_code_manage_request"}}]
if status:
must.append({"term": {"status": str(status)}})
body = {
"size": max(1, min(int(limit or 200), 500)),
"query": {"bool": {"must": must}},
"sort": [{"created_at": {"order": "desc"}}],
}
resp = es.search(index=GLOBAL_INDEX_NAME, body=body)
hits = (resp.get("hits") or {}).get("hits") or []
out = []
for h in hits:
src = h.get("_source") or {}
src["_id"] = h.get("_id")
out.append(src)
return out
except Exception as e:
print(f"列出注册码管理申请失败: {str(e)}")
return []
def decide_registration_code_manage_request(request_id: str, status: str, reviewed_by: int, reviewer_note: str = None):
try:
sid = str(status or "").strip().lower()
if sid not in ("approved", "rejected"):
return False
doc = {
"status": sid,
"reviewed_at": _rc_request_now_iso(),
"reviewed_by": int(reviewed_by),
"reviewer_note": str(reviewer_note or ""),
}
es.update(index=GLOBAL_INDEX_NAME, id=str(request_id), body={"doc": doc})
return True
except Exception as e:
print(f"审批注册码管理申请失败: {str(e)}")
return False return False

View File

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

View File

@@ -1,644 +0,0 @@
<!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: auto;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #ff79c6;
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>用户ID{{ user_id }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
<button id="logoutBtn">退出登录</button>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<div class="card">
<div class="header">
<h2>用户管理</h2>
<button id="addUserBtn" class="btn btn-primary">添加用户</button>
</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">
<!-- 用户数据将通过JavaScript加载 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 添加/编辑用户模态框 -->
<div id="userModal" class="modal">
<div class="modal-content">
<span class="close">&times;</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">&times;</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 = user.permission === 1 ? '管理员' : '普通用户';
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',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(data)
});
} else {
// 添加用户
response = await fetch('/elastic/users/add/', {
method: 'POST',
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',
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);
}
}
// 事件监听器
document.getElementById('addUserBtn').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';
});
});
document.getElementById('searchBtn').addEventListener('click', function() {
const searchTerm = document.getElementById('searchInput').value;
loadUsers(searchTerm);
});
document.getElementById('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() {
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>

View File

@@ -4,243 +4,62 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>数据管理</title> <title>数据管理</title>
<style> <style>
body { body{margin:0;font-family:sans-serif;background:#fafafa}
margin: 0; .sidebar{position:fixed;top:0;left:0;width:180px;height:100vh;background:#1e1e2e;color:white;padding:20px;box-shadow:2px 0 5px rgba(0,0,0,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; .user-id{text-align:center;margin-bottom:0}
background: #fafafa; .sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
} .navigation-links{width:100%;margin-top:60px}
.sidebar a,.sidebar button{display:block;color:#8be9fd;text-decoration:none;margin:10px 0;font-size:16px;padding:15px;border-radius:4px;background:transparent;border:none;cursor:pointer;width:calc(100% - 40px);text-align:left;transition:.2s}
/* 导航栏样式 */ .sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
.sidebar { .main-content{margin-left:200px;padding:20px;color:#333}
position: fixed; .container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
top: 0; table{width:100%;border-collapse:collapse;margin-top:20px}
left: 0; th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
width: 180px; th{background:#f8f9fa;font-weight:600}
height: 100vh; .inner-table { width: 100%; margin: 0; border: 1px solid #e0e0e0; border-collapse: collapse; table-layout: fixed; }
background: #1e1e2e; .inner-table th, .inner-table td { border: 1px solid #e0e0e0; padding: 8px; font-size: 13px; word-break: break-all; }
color: white; .inner-table td:first-child { width: 30%; background-color: #f8fafc; font-weight: 600; color: #475569; }
padding: 20px; .inner-table td:last-child { width: 70%; background-color: #fff; }
box-shadow: 2px 0 5px rgba(0,0,0,0.1); .inner-table th { background-color: #f9f9f9; }
z-index: 1000; img{max-width:120px;border:1px solid #eee;border-radius:6px;cursor:pointer}
display: flex; .btn{padding:6px 10px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin:2px}
flex-direction: column; .btn-primary{background:#1677ff;color:#fff}
align-items: center; .btn-danger{background:#ff4d4f;color:#fff}
} .btn-secondary{background:#f0f0f0;color:#333}
.muted{color:#666;font-size:12px}
.user-id { .modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
text-align: center; .modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
margin-bottom: auto; textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
} #kvForm{border:1px solid #eee;border-radius:6px;padding:8px;max-height:300px;overflow:auto}
.search-container{background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px}
.sidebar h3 { .search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
margin-top: 0; .search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
font-size: 18px; .search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
color: #ff79c6; .search-result.empty{background:#fff8e8}
text-align: center; .search-result.error{background:#ffe8e8}
margin-bottom: 20px; .loading{display:inline-block;width:20px;height:20px;border:3px solid #f3f3f3;border-top:3px solid #1677ff;border-radius:50%;animation:spin 1s linear infinite}
} @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
@media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
.navigation-links { .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}
width: 100%; .image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
margin-top: 60px; .image-modal-content.dragging{cursor:grabbing}
} .image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
.image-modal-close:hover{color:#bbb}
.sidebar a, .zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
.sidebar button { .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}
display: block; .zoom-btn:hover{background:rgba(255,255,255,.9)}
color: #8be9fd; .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}
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar a:hover,
.sidebar button:hover {
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */
.main-content {
margin-left: 200px;
padding: 20px;
color: #333;
}
/* 原有样式保持不变 */
.container {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0,0,0,0.06);
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
border-bottom: 1px solid #eee;
padding: 12px 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #f8f9fa;
font-weight: 600;
}
img {
max-width: 120px;
border: 1px solid #eee;
border-radius: 6px;
}
.btn {
padding: 6px 10px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin: 2px;
}
.btn-primary {
background: #1677ff;
color: #fff;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.muted {
color: #666;
font-size: 12px;
}
.modal {
position: fixed;
inset: 0;
display: none;
background: rgba(0,0,0,0.4);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal .dialog {
width: 720px;
max-width: 92vw;
background: #fff;
border-radius: 10px;
padding: 20px;
max-height: 80vh;
overflow-y: auto;
}
textarea {
width: 100%;
min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
}
#kvForm {
border: 1px solid #eee;
border-radius: 6px;
padding: 8px;
max-height: 300px;
overflow: auto;
}
/* 搜索区域样式 */
.search-container {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.search-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-result {
margin-top: 10px;
padding: 10px;
background: #e8f4ff;
border-radius: 4px;
font-size: 14px;
}
.search-result.empty {
background: #fff8e8;
}
.search-result.error {
background: #ffe8e8;
}
/* 加载动画 */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1677ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式调整 */
@media (max-width: 768px) {
.search-controls {
flex-direction: column;
align-items: stretch;
}
.search-input {
min-width: auto;
}
.btn {
width: 100%;
margin: 2px 0;
}
}
</style> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a> <a href="{% url 'main:home' %}">返回主页</a>
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -250,12 +69,20 @@
<div class="main-content"> <div class="main-content">
<div class="container"> <div class="container">
<h2>数据管理</h2> <h2>数据管理</h2>
{% if is_admin %}
<p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p> <p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p>
{% else %}
<p class="muted">可查看本人及所管理 Key 的上传数据。</p>
{% endif %}
<!-- 搜索功能区域 --> <!-- 搜索功能区域 -->
<div class="search-container"> <div class="search-container">
<div class="search-controls"> <div class="search-controls">
<input type="text" id="searchQuery" class="search-input" placeholder="请输入搜索关键词..."> <input type="text" id="searchQuery" class="search-input" placeholder="请输入搜索关键词...">
<select id="keyFilter" class="search-input"></select>
<button class="btn" onclick="clearKeyFilter()">清空Key筛查</button>
<select id="typeFilter" class="search-input"></select>
<button class="btn" onclick="clearTypeFilter()">清空类型筛查</button>
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button> <button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button> <button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
<button class="btn" onclick="loadAllData()">显示全部</button> <button class="btn" onclick="loadAllData()">显示全部</button>
@@ -268,14 +95,32 @@
</div> </div>
</div> </div>
{% if is_admin or has_manage_key %}
<div class="search-container" style="margin-top: 12px;">
<div style="font-weight: 600; margin-bottom: 8px;">统计报表</div>
<div class="search-controls" style="flex-wrap: wrap;">
<input type="datetime-local" id="reportFrom" class="search-input" placeholder="开始时间">
<input type="datetime-local" id="reportTo" class="search-input" placeholder="结束时间">
<select id="reportInterval" class="search-input">
<option value="day">按天</option>
<option value="week">按周</option>
<option value="month">按月</option>
</select>
<button class="btn btn-primary" onclick="generateReport()">生成报表</button>
<button class="btn" onclick="downloadReportCsv()">下载CSV</button>
</div>
<div id="reportBox" class="search-result" style="display: none; margin-top: 10px;"></div>
</div>
{% endif %}
<!-- 数据表格 --> <!-- 数据表格 -->
<table id="dataTable"> <table id="dataTable">
<thead> <thead>
<tr> <tr>
<th>ID</th>
<th>图片</th> <th>图片</th>
<th>数据</th> <th>数据</th>
<th>作者</th> <th>时间</th>
<th>录入人</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
@@ -306,6 +151,18 @@
</div> </div>
</div> </div>
<!-- 图片放大模态框 -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span>
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
<img class="image-modal-content" id="expandedImage">
<div class="zoom-controls">
<button class="zoom-btn" id="zoomOutBtn">-</button>
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
<button class="zoom-btn" id="zoomInBtn">+</button>
</div>
</div>
<script> <script>
// 获取CSRF token的函数 // 获取CSRF token的函数
function getCookie(name) { function getCookie(name) {
@@ -316,6 +173,12 @@ function getCookie(name) {
// DOM元素引用 // DOM元素引用
const searchQueryInput = document.getElementById('searchQuery'); const searchQueryInput = document.getElementById('searchQuery');
const keyFilterSelect = document.getElementById('keyFilter');
const typeFilterSelect = document.getElementById('typeFilter');
const reportFromInput = document.getElementById('reportFrom');
const reportToInput = document.getElementById('reportTo');
const reportIntervalSelect = document.getElementById('reportInterval');
const reportBox = document.getElementById('reportBox');
const searchResultDiv = document.getElementById('searchResult'); const searchResultDiv = document.getElementById('searchResult');
const searchStatus = document.getElementById('searchStatus'); const searchStatus = document.getElementById('searchStatus');
const searchCount = document.getElementById('searchCount'); const searchCount = document.getElementById('searchCount');
@@ -327,11 +190,38 @@ 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');
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
// 全局变量 // 全局变量
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 currentKeyFilter = '';
let currentTypeFilter = '';
// 图片缩放相关变量
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) {
@@ -341,6 +231,17 @@ async function performSearch(type) {
return; return;
} }
if (currentKeyFilter) {
currentKeyFilter = '';
if (keyFilterSelect) keyFilterSelect.value = '';
}
if (currentTypeFilter) {
currentTypeFilter = '';
if (typeFilterSelect) typeFilterSelect.value = '';
}
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
showSearchLoading(); showSearchLoading();
try { try {
@@ -399,9 +300,21 @@ function showSearchMessage(message, type = '') {
// 加载所有数据 // 加载所有数据
async function loadAllData() { async function loadAllData() {
currentSearchQuery = '';
showSearchLoading(); showSearchLoading();
try { try {
if (currentKeyFilter || currentTypeFilter) {
const response = await fetch(`/elastic/filter/?key=${encodeURIComponent(currentKeyFilter)}&type=${encodeURIComponent(currentTypeFilter)}`);
const data = await response.json();
if (data.status === 'success') {
displayAllData(data.data || [], currentKeyFilter, currentTypeFilter);
} else {
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
}
return;
}
// 如果已有缓存,直接使用 // 如果已有缓存,直接使用
if (allDataCache.length > 0) { if (allDataCache.length > 0) {
displayAllData(allDataCache); displayAllData(allDataCache);
@@ -424,10 +337,13 @@ async function loadAllData() {
} }
// 显示所有数据 // 显示所有数据
function displayAllData(data) { function displayAllData(data, key) {
searchResultDiv.style.display = 'block'; searchResultDiv.style.display = 'block';
searchResultDiv.className = 'search-result'; searchResultDiv.className = 'search-result';
searchStatus.textContent = '显示全部数据'; const labels = [];
if (key) labels.push(`Key${key}`);
if (currentTypeFilter) labels.push(`类型:${currentTypeFilter}`);
searchStatus.textContent = labels.length ? `筛查:${labels.join('')}` : '显示全部数据';
searchCount.textContent = `${data.length} 条记录`; searchCount.textContent = `${data.length} 条记录`;
renderTable(data); renderTable(data);
@@ -437,8 +353,13 @@ function displayAllData(data) {
function clearSearch() { function clearSearch() {
searchQueryInput.value = ''; searchQueryInput.value = '';
searchResultDiv.style.display = 'none'; searchResultDiv.style.display = 'none';
currentSearchQuery = '';
if (currentKeyFilter || currentTypeFilter) {
loadAllData();
return;
}
// 如果有缓存数据,显示全部
if (allDataCache.length > 0) { if (allDataCache.length > 0) {
renderTable(allDataCache); renderTable(allDataCache);
} else { } else {
@@ -447,6 +368,145 @@ function clearSearch() {
} }
} }
async function initKeyFilter() {
if (!keyFilterSelect) return;
keyFilterSelect.innerHTML = '<option value="">全部Key</option>';
try {
const resp = await fetch('/elastic/keys-for-filter/', { credentials: 'same-origin' });
const data = await resp.json();
if (data.status !== 'success') return;
const keys = data.data || [];
keys.forEach(k => {
const opt = document.createElement('option');
opt.value = String(k || '');
opt.textContent = String(k || '');
keyFilterSelect.appendChild(opt);
});
} catch (e) {
}
keyFilterSelect.addEventListener('change', () => {
currentKeyFilter = (keyFilterSelect.value || '').trim();
loadAllData();
});
}
function clearKeyFilter() {
currentKeyFilter = '';
if (keyFilterSelect) keyFilterSelect.value = '';
loadAllData();
}
async function initTypeFilter() {
if (!typeFilterSelect) return;
typeFilterSelect.innerHTML = '<option value="">全部类型</option>';
try {
const resp = await fetch('/elastic/types-for-filter/', { credentials: 'same-origin' });
const data = await resp.json();
if (data.status !== 'success') return;
const types = data.data || [];
types.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t || '');
opt.textContent = String(t || '');
typeFilterSelect.appendChild(opt);
});
} catch (e) {
}
typeFilterSelect.addEventListener('change', () => {
currentTypeFilter = (typeFilterSelect.value || '').trim();
loadAllData();
});
}
function clearTypeFilter() {
currentTypeFilter = '';
if (typeFilterSelect) typeFilterSelect.value = '';
loadAllData();
}
function buildReportParams() {
const params = new URLSearchParams();
if (currentKeyFilter) params.set('key', currentKeyFilter);
if (currentTypeFilter) params.set('type', currentTypeFilter);
const iv = (reportIntervalSelect && reportIntervalSelect.value) ? reportIntervalSelect.value : 'day';
params.set('interval', iv);
const fromVal = reportFromInput ? (reportFromInput.value || '').trim() : '';
const toVal = reportToInput ? (reportToInput.value || '').trim() : '';
if (fromVal) params.set('from', fromVal);
if (toVal) params.set('to', toVal);
return params;
}
async function generateReport() {
if (!reportBox) return;
reportBox.style.display = 'block';
reportBox.className = 'search-result';
reportBox.innerHTML = '<div>正在生成报表...</div>';
try {
const params = buildReportParams();
const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' });
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
const text = await resp.text();
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.status !== 'success') {
reportBox.className = 'search-result error';
reportBox.innerHTML = `<div>生成失败:${data.message || '未知错误'}</div>`;
return;
}
const r = data.data || {};
const total = r.total || 0;
const byType = r.by_type || [];
const byTime = r.by_time || [];
const rng = r.range || {};
const flt = r.filters || {};
const lines = [];
const filterParts = [];
if (flt.key) filterParts.push(`Key${flt.key}`);
if (flt.type) filterParts.push(`类型:${flt.type}`);
if (flt.interval) filterParts.push(`粒度:${flt.interval}`);
lines.push(`<div style="font-weight: 600;">总数:${total}</div>`);
if (rng.from || rng.to) {
lines.push(`<div class="muted" style="margin-top: 4px;">时间范围:${(rng.from || '')} ~ ${(rng.to || '')}</div>`);
}
if (filterParts.length) {
lines.push(`<div class="muted" style="margin-top: 4px;">筛查:${filterParts.join('')}</div>`);
}
const typeRows = byType.map(it => `<tr><td style="padding:4px 8px;">${it.type || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
const timeRows = byTime.map(it => `<tr><td style="padding:4px 8px;">${it.bucket || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
lines.push(`<div style="display:flex; gap:12px; flex-wrap: wrap; margin-top: 10px;">
<div style="min-width: 260px; flex: 1;">
<div style="font-weight: 600; margin-bottom: 6px;">按成果类型</div>
<table style="width:100%; border-collapse: collapse;">
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">类型</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
<tbody>${typeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
</table>
</div>
<div style="min-width: 260px; flex: 1;">
<div style="font-weight: 600; margin-bottom: 6px;">按时间</div>
<table style="width:100%; border-collapse: collapse;">
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">时间</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
<tbody>${timeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
</table>
</div>
</div>`);
reportBox.innerHTML = lines.join('');
} catch (e) {
reportBox.className = 'search-result error';
reportBox.innerHTML = `<div>生成失败:${e.message || '未知错误'}</div>`;
}
}
function downloadReportCsv() {
const params = buildReportParams();
window.location.href = `/elastic/report/csv/?${params.toString()}`;
}
// 渲染表格 // 渲染表格
function renderTable(data) { function renderTable(data) {
tableBody.innerHTML = ''; tableBody.innerHTML = '';
@@ -466,22 +526,52 @@ function renderTable(data) {
// 解析data字段如果是JSON字符串则格式化显示 // 解析data字段如果是JSON字符串则格式化显示
let displayData = item.data || ''; let displayData = item.data || '';
let parsed = null;
try { try {
const parsed = JSON.parse(item.data); if (typeof displayData === 'object' && displayData !== null) {
displayData = JSON.stringify(parsed, null, 2); parsed = displayData;
} else if (typeof displayData === 'string') {
parsed = JSON.parse(displayData);
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
displayData = `
<table class="inner-table">
<tbody>
${Object.entries(parsed).map(([key, value]) => `
<tr>
<td>${escapeHtml(key)}</td>
<td>${escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : value)}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} else {
throw new Error('Not a valid JSON object');
}
} catch (e) { } catch (e) {
// 如果不是JSON直接显示原字符串 displayData = `
<table class="inner-table">
<tbody>
<tr>
<td>原始数据</td>
<td>${escapeHtml(typeof displayData === 'object' ? JSON.stringify(displayData) : displayData)}</td>
</tr>
</tbody>
</table>
`;
} }
row.innerHTML = ` row.innerHTML = `
<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='图片加载失败'" />` : '无图片'} <div style="display:flex;gap:6px;flex-wrap:wrap;">${buildImageCell(item)}</div>
</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> ${displayData}
</td> </td>
<td style="font-size: 12px;">${item.writer_id || ''}</td> <td style="font-size: 12px; white-space: nowrap;">${formatDateTime(item.time)}</td>
<td style="font-size: 12px;">${item.writer_name || item.writer_id || ''}</td>
<td> <td>
<button class="btn btn-primary" onclick="openEdit('${item._id || item.id}')">编辑</button> <button class="btn btn-primary" onclick="openEdit('${item._id || item.id}')">编辑</button>
<button class="btn btn-danger" onclick="doDelete('${item._id || item.id}')">删除</button> <button class="btn btn-danger" onclick="doDelete('${item._id || item.id}')">删除</button>
@@ -491,6 +581,24 @@ function renderTable(data) {
}); });
} }
function formatDateTime(t) {
if (!t) return '';
try {
const d = new Date(t);
if (String(d) === 'Invalid Date') return String(t);
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 String(t);
}
}
function buildImageCell(item) {
const urls = Array.isArray(item.image_urls) ? item.image_urls : (item.image_url ? [item.image_url] : []);
if (!urls.length) return '无图片';
return urls.map(u => `<img src="${u}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="${u}" />`).join('');
}
// 转义HTML以防止XSS // 转义HTML以防止XSS
function escapeHtml(unsafe) { function escapeHtml(unsafe) {
return unsafe return unsafe
@@ -544,16 +652,24 @@ function createRow(k = '', v = '') {
} }
function renderForm(obj){ function renderForm(obj){
kvForm.innerHTML=''; kvForm.innerHTML=`
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 8px; font-weight: 600; color: #475569; font-size: 14px;">
<div>字段名</div>
<div>字段值</div>
<div>操作</div>
</div>
`;
Object.keys(obj||{}).forEach(k=> kvForm.appendChild(createRow(k, obj[k]))); Object.keys(obj||{}).forEach(k=> kvForm.appendChild(createRow(k, obj[k])));
if (!kvForm.children.length) kvForm.appendChild(createRow()); if (kvForm.querySelectorAll('div[style*="grid"]').length <= 1) kvForm.appendChild(createRow());
syncTextarea(); syncTextarea();
} }
function formToObject(){ function formToObject(){
const o={}; const o={};
Array.from(kvForm.children).forEach(row=>{ Array.from(kvForm.children).forEach((row, index)=>{
if (index === 0) return; // 跳过表头
const [kI,vI] = row.querySelectorAll('input'); const [kI,vI] = row.querySelectorAll('input');
if (!kI || !vI) return;
const k=(kI.value||'').trim(); if(!k) return; const k=(kI.value||'').trim(); if(!k) return;
const raw=vI.value; const raw=vI.value;
try{ try{
@@ -643,15 +759,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();
} }
@@ -661,8 +771,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',
@@ -674,25 +796,50 @@ 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 = '删除';
}
} }
} }
// 页面加载时自动加载所有数据 // 页面加载时自动加载所有数据
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initKeyFilter();
initTypeFilter();
if (reportFromInput && reportToInput && reportIntervalSelect) {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const toLocal = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (!reportToInput.value) reportToInput.value = toLocal(now);
if (!reportFromInput.value) {
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
reportFromInput.value = toLocal(from);
}
if (!reportIntervalSelect.value) reportIntervalSelect.value = 'day';
}
loadAllData(); loadAllData();
}); });
@@ -722,6 +869,142 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
msg.textContent = e.message || '发生错误'; 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>

View File

@@ -0,0 +1,465 @@
<!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; }
.btn-danger { background:#ff4d4f; color:#fff; }
.btn-danger:hover { background:#ff7875 !important; }
.btn-primary:hover { background:#6366f1 !important; }
.btn-secondary:hover { background:#94a3b8 !important; }
.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>
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
const CAN_MANAGE_REG = {{ can_manage_registration_codes|yesno:"true,false" }};
const MY_KEYS_RAW = JSON.parse('{{ my_keys_json|default:"[]"|escapejs }}');
const MY_KEYS_SET = new Set((Array.isArray(MY_KEYS_RAW) ? MY_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
const MY_MANAGE_KEYS_RAW = JSON.parse('{{ manage_keys_json|default:"[]"|escapejs }}');
const MY_MANAGE_KEYS_SET = new Set((Array.isArray(MY_MANAGE_KEYS_RAW) ? MY_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
const ALLOWED_MANAGE_KEYS_RAW = JSON.parse('{{ allowed_manage_keys_json|default:"[]"|escapejs }}');
const ALLOWED_MANAGE_KEYS_SET = new Set((Array.isArray(ALLOWED_MANAGE_KEYS_RAW) ? ALLOWED_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
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;
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const v = String(k || '').trim();
if (v && !ALLOWED_MANAGE_KEYS_SET.has(v)) o2.disabled = true;
}
mkeySel.appendChild(o2);
});
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
Array.from(keySel.options).forEach(o => { if (MY_KEYS_SET.has(String(o.value || '').trim())) o.selected = true; });
Array.from(mkeySel.options).forEach(o => { o.selected = false; });
}
}
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'){
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
ALLOWED_MANAGE_KEYS_SET.add(keyName);
}
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';}
}
async function deleteSelectedKey(){
const keySel = document.getElementById('keys');
const mkeySel = document.getElementById('manageKeys');
// 优先获取左侧选中的,如果没有则获取右侧选中的
const selectedKey = keySel.value || mkeySel.value;
if(!selectedKey){
alert('请先在下方列表中选择一个要删除的Key');
return;
}
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const v = String(selectedKey || '').trim();
if (!v || !ALLOWED_MANAGE_KEYS_SET.has(v)) {
const msg=document.getElementById('msg');
msg.textContent='只能删除自己新增的 key';
msg.className='notice error';
msg.style.display='block';
return;
}
}
if(!confirm(`确定要全局删除Key \"${selectedKey}\" 吗?\n该操作将:\n1. 从全局可选Key列表中移除\n2. 从所有包含此Key的注册码中同步清除\n此操作不可恢复!`)) return;
const ov=document.getElementById('overlay'); ov.style.display='flex';
const csrftoken=getCookie('csrftoken');
const url = '/elastic/registration-codes/keys/remove/';
const resp=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({key:selectedKey})});
const data=await resp.json();
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
ALLOWED_MANAGE_KEYS_SET.delete(String(selectedKey||'').trim());
}
msg.textContent = data.message || '删除成功';
msg.className='notice success';
msg.style.display='block';
loadKeys(); // 重新加载keys列表
loadCodes(); // 重新加载注册码列表
} else {
msg.textContent=data.message||'删除失败';
msg.className='notice error';
msg.style.display='block';
}
ov.style.display='none';
}
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;
if (op.disabled) return;
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 keySel = document.getElementById('keys');
let keys=selectedValues(keySel);
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const selected = new Set(keys.map(k=>String(k||'').trim()).filter(Boolean));
const missing = Array.from(MY_KEYS_SET).filter(k => !selected.has(k));
if (missing.length) {
const msg=document.getElementById('msg');
msg.textContent = `必须选择导师原有的 key${missing.join('、')}`;
msg.className='notice error';
msg.style.display='block';
ov.style.display='none';
return;
}
}
let manageKeys=selectedValues(document.getElementById('manageKeys'));
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const hasForbidden = manageKeys.some(k => !ALLOWED_MANAGE_KEYS_SET.has(String(k || '').trim()));
if (hasForbidden) {
const msg=document.getElementById('msg');
msg.textContent='manage_key 只能选择本页新增的 key';
msg.className='notice error';
msg.style.display='block';
ov.style.display='none';
return;
}
}
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>
{% if is_admin or has_manage_key or can_manage_registration_codes %}
<div class="row">
<div class="col">
<label>管理 Key</label>
<div style="display:flex; gap:8px;">
<input id="newKey" type="text" placeholder="输入新的key进行新增或在下方选择后删除" style="flex: 1;" />
<button class="btn btn-secondary" onclick="addKey()">新增 Key</button>
{% if is_admin or has_manage_key %}
<button class="btn btn-danger" onclick="deleteSelectedKey()">删除选中 Key</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
<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" style="width: 100%;" onclick="clearSelection('keys')">清空 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" style="width: 100%;" onclick="clearSelection('manageKeys')">清空 manage_keys 选择</button>
</div>
</div>
</div>
<div class="row" style="margin-top:12px;">
<div class="col">
<label>有效期</label>
<select id="expireMode">
<option value="month">一个月</option>
<option value="fouryears">四年</option>
<option value="custom">自定义天数</option>
</select>
<input id="customDays" type="number" min="1" placeholder="自定义天数" />
</div>
<div class="col" style="display:flex; align-items:flex-end;">
<button class="btn btn-primary" onclick="generateCode()">生成注册码</button>
</div>
</div>
<div id="msg" class="notice"></div>
<div class="code-box" id="codeOut"></div>
<div class="row" style="margin-top:12px;">
<div class="col">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3>已生成的注册码</h3>
<div>
<button class="btn btn-secondary" onclick="loadCodes()">刷新列表</button>
</div>
</div>
<table style="width:100%; border-collapse:collapse; margin-top:10px;">
<thead>
<tr>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">code</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">manage_keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">创建时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">过期时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">状态</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">操作</th>
</tr>
</thead>
<tbody id="codesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// 获取CSRF令牌的函数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 导航点击处理函数提供备用URL
function handleNavClick(element, fallbackUrl) {
// 尝试使用Django模板生成的URL如果失败则使用备用URL
try {
// 如果模板渲染正常直接返回true让默认行为处理
return true;
} catch (e) {
// 如果模板渲染有问题使用备用URL
window.location.href = fallbackUrl;
return false;
}
}
// 修复用户管理链接跳转问题
document.addEventListener('DOMContentLoaded', function() {
// 为用户管理链接添加事件监听器,确保正确跳转
const userManagementLink = document.querySelector('a[href*="get_users"]');
if (userManagementLink) {
userManagementLink.addEventListener('click', function(e) {
// 阻止默认行为
e.preventDefault();
// 获取备用URL
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
// 直接跳转到用户管理页面
window.location.href = fallbackUrl;
});
}
});
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
const trendChart = echarts.init(document.getElementById('chartTrend'));
const typesChart = echarts.init(document.getElementById('chartTypes'));
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
async function loadTrend(){
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const x = buckets.map(b=>b.key_as_string||'');
const y = buckets.map(b=>b.doc_count||0);
trendChart.setOption({
tooltip:{trigger:'axis'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
});
}
async function loadTypes(){
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
typesChart.setOption({
tooltip:{trigger:'item'},
legend:{type:'scroll'},
series:[{ type:'pie', radius:['40%','70%'], data }]
});
}
async function loadTypesTrend(){
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const rows = res.data || [];
const x = rows.map(r=>r.key_as_string||'');
const typeSet = new Set();
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
const types = Array.from(typeSet);
const series = types.map(tp=>({
name: tp,
type:'line',
smooth:true,
data: rows.map(r=>{
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
return b? b.doc_count||0 : 0;
})
}));
typesTrendChart.setOption({
tooltip:{trigger:'axis'},
legend:{type:'scroll'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series
});
}
function formatTime(t){
try{
const d = new Date(t);
if(String(d) !== 'Invalid Date'){
const pad = n=> String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
}catch(e){}
return t||'';
}
async function loadRecent(){
const listEl = document.getElementById('recentList');
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const items = res.data || [];
listEl.innerHTML = '';
items.forEach(it=>{
const li = document.createElement('li');
const t = formatTime(it.time);
const u = it.username || '';
const ty = it.type || '未知';
li.textContent = `${t}${u}${ty}`;
listEl.appendChild(li);
});
}
loadTrend();
loadTypes();
loadTypesTrend();
loadRecent();
</script>
</body>
</html>

View File

@@ -4,117 +4,87 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>图片上传与识别</title> <title>图片上传与识别</title>
<style> <style>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */ /* 导航栏样式 */
.sidebar { .sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
position: fixed; flex-direction: column;align-items: center;}
top: 0; .user-id {text-align: center;margin-bottom: 0px;}
left: 0; .sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
width: 180px; .navigation-links {width: 100%;margin-top: 60px;}
height: 100vh;
background: #1e1e2e;
color: white;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
}
.user-id {
text-align: center;
margin-bottom: auto;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #ff79c6;
text-align: center;
margin-bottom: 20px;
}
.navigation-links {
width: 100%;
margin-top: 60px;
}
.sidebar a, .sidebar a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
color: #8be9fd;
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar a:hover, .sidebar a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6; /* 主内容区 - 改进后的样式 */
background-color: rgba(139, 233, 253, 0.2); .main-content {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; }
.main-content { .preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
margin-left: 200px; .preview-list {display: grid;grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));gap: 12px; margin-top: 20px;}
padding: 20px; .preview-item {position: relative;}
color: #333; .preview-item img {width: 100%;max-height: 220px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
.preview-remove {position: absolute;top: 6px;right: 6px;border: none;border-radius: 999px;background: rgba(15,23,42,0.8);color: #fff;width: 24px;height: 24px;cursor: pointer;display: flex;align-items: center;justify-content: center;font-size: 14px;line-height: 1;}
.result-box {flex: 1;}
.result-box h3 { margin-top: 0; color: #334155;}
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
.pending-item { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.pending-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; padding-bottom: 12px; }
.pending-item-title { font-weight: 600; color: #1e293b; font-size: 16px; }
.pending-item-body { display: flex; gap: 20px; }
.pending-item-preview { flex: 0 0 240px; }
.pending-item-preview img { width: 100%; border-radius: 8px; border: 1px solid #f1f5f9; }
.pending-item-edit { flex: 1; }
.pending-item-footer { margin-top: 16px; text-align: right; }
@media (max-width: 992px) {
.pending-item-body { flex-direction: column; }
.pending-item-preview { flex: 0 0 auto; }
} }
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; align-items: center;}
/* 原有样式保持不变 */ .form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px; width: 100%; box-sizing: border-box;}
.container { .kv-form-container {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 400px; overflow: auto; margin-bottom: 12px; background: #f8fafc;}
max-width: 900px; .form-header { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 8px; padding: 0 4px; font-weight: 600; color: #475569; font-size: 14px;}
margin: 6vh auto; .result-textarea { width: 100%; min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; padding: 10px; border: 1px solid #e2e8f0; border-radius: 8px; resize: vertical; box-sizing: border-box; }
background: #fff; .status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
border-radius: 10px; .status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
box-shadow: 0 6px 18px rgba(0,0,0,0.06); .status-message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
padding: 24px; .action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
} .progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
.row { display: flex; gap: 16px; } .progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
.col { flex: 1; } .progress-wrap {display:none; margin-top: 8px;}
textarea { .progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
width: 100%;
min-height: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
}
img { max-width: 100%; border: 1px solid #eee; border-radius: 6px; }
.btn {
padding: 8px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-primary { background: #1677ff; color: #fff; }
.btn-secondary { background: #f0f0f0; }
.muted { color: #666; font-size: 12px; }
.error { color: #d14343; }
.success { color: #179957; }
</style> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}">主页</a> <a href="{% url 'main:home' %}">返回主页</a>
<button id="logoutBtn">退出登录</button> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -123,35 +93,41 @@
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div class="main-content"> <div class="main-content">
<div class="container"> <div class="container">
<h2>图片上传与识别</h2> <div class="header">
<p class="muted">选择图片后上传,服务端调用大模型解析为可编辑的 JSON再确认入库。</p> <div>
<h2>图片与PDF上传识别</h2>
<p>选择图片或PDF文件后上传服务端调用大模型解析为可编辑的 JSON再确认入库。</p>
</div>
</div>
<div class="upload-section" id="dropArea">
<h3>上传文件</h3>
<p>点击下方按钮选择图片或PDF文件或拖拽文件到此区域</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/*,.pdf" multiple />
<span id="fileHint" class="muted"></span>
<div id="previewList" class="preview-list"></div>
<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="result-box">
<h4>图片预览</h4> <h3>待处理文件列表</h3>
<img id="preview" alt="预览" /> <div id="pendingItems" class="pending-list">
</div> <!-- 这里将动态生成每个文件的预览和编辑区域 -->
<div class="col">
<h4>识别结果(可编辑)</h4>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
</div>
<div id="kvForm" style="border:1px solid #eee; border-radius:6px; padding:8px; max-height:300px; overflow:auto;"></div>
<div style="margin-top:8px;">
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
</div> </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>
@@ -168,24 +144,165 @@ function getCookie(name) {
const uploadForm = document.getElementById('uploadForm'); const uploadForm = document.getElementById('uploadForm');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview'); const fileHint = document.getElementById('fileHint');
const resultBox = document.getElementById('resultBox'); const previewList = document.getElementById('previewList');
const pendingItems = document.getElementById('pendingItems');
const uploadMsg = document.getElementById('uploadMsg'); const uploadMsg = document.getElementById('uploadMsg');
const confirmBtn = document.getElementById('confirmBtn'); const confirmBtn = document.getElementById('confirmBtn');
const clearBtn = document.getElementById('clearBtn'); const clearBtn = document.getElementById('clearBtn');
const confirmMsg = document.getElementById('confirmMsg'); const confirmMsg = document.getElementById('confirmMsg');
const kvForm = document.getElementById('kvForm'); const dropArea = document.getElementById('dropArea');
const addFieldBtn = document.getElementById('addFieldBtn'); const progressWrap = document.getElementById('progressWrap');
const syncFromTextBtn = document.getElementById('syncFromTextBtn'); const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let currentImageRel = ''; let currentItems = []; // 存储当前待处理的所有文件结果
let selectedFiles = [];
function createRow(k = '', v = '') { 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) {
addFiles(files);
}
}
function setPreviewList(urls) {
previewList.innerHTML = '';
(urls || []).forEach((url, index) => {
if (!url) return;
const item = document.createElement('div');
item.className = 'preview-item';
item.dataset.index = String(index);
const img = document.createElement('img');
img.src = url;
img.alt = '预览';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'preview-remove';
btn.textContent = '×';
btn.onclick = () => {
const idx = Number(item.dataset.index);
if (!Number.isNaN(idx)) {
selectedFiles.splice(idx, 1);
const urls = selectedFiles.map(f => {
if (f.name.toLowerCase().endsWith('.pdf')) {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZjQ0NDQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQgMmgyYTIgMiAwIDAgMSAyIDJ2MTZhMiAyIDAgMCAxLTIgMmgtMTJhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJoMiIvPjxwYXRoIGQ9Ik0xNCAydjRjMCAxLjEgLjkgMiAyIDJoNCIvPjxwYXRoIGQ9Ik03IDloNSIvPjxwYXRoIGQ9Ik03IDEzaDUiLz48cGF0aCBkPSJNNyAxN2g4Ii8+PC9zdmc+';
}
return URL.createObjectURL(f);
});
setPreviewList(urls);
updateFileHint();
setTimeout(() => urls.forEach(u => { if (u.startsWith('blob:')) URL.revokeObjectURL(u); }), 0);
}
};
item.appendChild(img);
item.appendChild(btn);
previewList.appendChild(item);
});
}
function updateFileHint() {
const count = selectedFiles.length;
fileHint.textContent = count ? `已选择 ${count} 个文件` : '未选择文件';
}
function addFiles(files) {
const incoming = Array.from(files || []).filter(f => f && (f.type.startsWith('image/') || f.name.toLowerCase().endsWith('.pdf')));
const existingKeys = new Set(selectedFiles.map(f => `${f.name}|${f.size}|${f.lastModified}`));
incoming.forEach(f => {
const key = `${f.name}|${f.size}|${f.lastModified}`;
if (!existingKeys.has(key)) {
existingKeys.add(key);
selectedFiles.push(f);
}
});
const urls = selectedFiles.map(f => {
if (f.name.toLowerCase().endsWith('.pdf')) {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZjQ0NDQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQgMmgyYTIgMiAwIDAgMSAyIDJ2MTZhMiAyIDAgMCAxLTIgMmgtMTJhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJoMiIvPjxwYXRoIGQ9Ik0xNCAydjRjMCAxLjEgLjkgMiAyIDJoNCIvPjxwYXRoIGQ9Ik03IDloNSIvPjxwYXRoIGQ9Ik03IDEzaDUiLz48cGF0aCBkPSJNNyAxN2g4Ii8+PC9zdmc+';
}
return URL.createObjectURL(f);
});
setPreviewList(urls);
updateFileHint();
setTimeout(() => urls.forEach(u => { if (u.startsWith('blob:')) URL.revokeObjectURL(u); }), 0);
}
fileInput.addEventListener('change', function(e) {
addFiles(e.target.files || []);
fileInput.value = '';
});
function createKvRow(k = '', v = '', onInput) {
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 = '字段名';
@@ -196,107 +313,220 @@ 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(); };
keyInput.oninput = syncTextarea; delBtn.onclick = () => {
valInput.oninput = syncTextarea; const container = row.parentElement;
if (container.querySelectorAll('.form-row').length > 1) {
container.removeChild(row);
} else {
keyInput.value = '';
valInput.value = '';
}
if (onInput) onInput();
};
keyInput.oninput = onInput;
valInput.oninput = onInput;
row.appendChild(keyInput); row.appendChild(keyInput);
row.appendChild(valInput); row.appendChild(valInput);
row.appendChild(delBtn); row.appendChild(delBtn);
return row; return row;
} }
function renderFormFromObject(obj) { function renderPendingItems(items) {
kvForm.innerHTML = ''; pendingItems.innerHTML = '';
Object.keys(obj || {}).forEach(k => { currentItems = items;
kvForm.appendChild(createRow(k, obj[k]));
});
if (!kvForm.children.length) kvForm.appendChild(createRow());
syncTextarea();
}
function objectFromForm() { items.forEach((item, index) => {
const itemEl = document.createElement('div');
itemEl.className = 'pending-item';
const header = document.createElement('div');
header.className = 'pending-item-header';
header.innerHTML = `<span class="pending-item-title">${index + 1}. ${item.name}</span>`;
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-danger';
removeBtn.textContent = '忽略此项';
removeBtn.onclick = () => {
currentItems.splice(index, 1);
renderPendingItems(currentItems);
};
header.appendChild(removeBtn);
const body = document.createElement('div');
body.className = 'pending-item-body';
const preview = document.createElement('div');
preview.className = 'pending-item-preview';
const mainImg = document.createElement('img');
mainImg.src = item.image_urls[0];
preview.appendChild(mainImg);
if (item.image_urls.length > 1) {
const hint = document.createElement('p');
hint.className = 'muted';
hint.style.textAlign = 'center';
hint.textContent = `${item.image_urls.length}`;
preview.appendChild(hint);
}
const edit = document.createElement('div');
edit.className = 'pending-item-edit';
const controls = document.createElement('div');
controls.className = 'form-controls';
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-secondary';
addBtn.textContent = '添加字段';
const syncBtn = document.createElement('button');
syncBtn.className = 'btn btn-secondary';
syncBtn.textContent = '刷新表单';
controls.appendChild(addBtn);
controls.appendChild(syncBtn);
const kvForm = document.createElement('div');
kvForm.className = 'kv-form-container';
kvForm.innerHTML = '<div class="form-header"><div>字段名</div><div>字段值</div><div>操作</div></div>';
const textarea = document.createElement('textarea');
textarea.className = 'result-textarea';
const syncData = () => {
const obj = {}; const obj = {};
Array.from(kvForm.children).forEach(row => { kvForm.querySelectorAll('.form-row').forEach(row => {
const [kInput, vInput] = row.querySelectorAll('input'); const inputs = row.querySelectorAll('input');
const k = (kInput.value || '').trim(); const k = inputs[0].value.trim();
if (!k) return; if (!k) return;
const raw = vInput.value; try { obj[k] = JSON.parse(inputs[1].value); } catch(e) { obj[k] = inputs[1].value; }
try {
obj[k] = JSON.parse(raw);
} catch (_) {
obj[k] = raw;
}
}); });
return obj; item.data = obj;
} textarea.value = JSON.stringify(obj, null, 2);
};
function syncTextarea() { Object.entries(item.data).forEach(([k, v]) => {
const obj = objectFromForm(); kvForm.appendChild(createKvRow(k, v, syncData));
resultBox.value = JSON.stringify(obj, null, 2); });
} if (kvForm.querySelectorAll('.form-row').length === 0) {
kvForm.appendChild(createKvRow('', '', syncData));
addFieldBtn.addEventListener('click', () => {
kvForm.appendChild(createRow());
syncTextarea();
});
syncFromTextBtn.addEventListener('click', () => {
try {
const obj = JSON.parse(resultBox.value || '{}');
renderFormFromObject(obj);
} catch (e) {
uploadMsg.textContent = '文本区不是有效JSON';
uploadMsg.className = 'error';
} }
});
addBtn.onclick = () => {
kvForm.appendChild(createKvRow('', '', syncData));
syncData();
};
syncBtn.onclick = () => {
try {
const obj = JSON.parse(textarea.value);
kvForm.innerHTML = '<div class="form-header"><div>字段名</div><div>字段值</div><div>操作</div></div>';
Object.entries(obj).forEach(([k, v]) => kvForm.appendChild(createKvRow(k, v, syncData)));
item.data = obj;
} catch(e) { alert('JSON格式错误'); }
};
textarea.value = JSON.stringify(item.data, null, 2);
textarea.oninput = () => { item.data = JSON.parse(textarea.value); };
edit.appendChild(controls);
edit.appendChild(kvForm);
edit.appendChild(textarea);
body.appendChild(preview);
body.appendChild(edit);
itemEl.appendChild(header);
itemEl.appendChild(body);
pendingItems.appendChild(itemEl);
});
confirmBtn.disabled = items.length === 0;
}
uploadForm.addEventListener('submit', async (e) => { uploadForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
uploadMsg.textContent = ''; uploadMsg.textContent = '';
confirmMsg.textContent = ''; confirmMsg.textContent = '';
confirmBtn.disabled = true; confirmBtn.disabled = true;
resultBox.value = ''; previewList.innerHTML = '';
currentImageRel = ''; pendingItems.innerHTML = '';
currentItems = [];
const file = fileInput.files[0]; if (!selectedFiles.length) {
if (!file) { uploadMsg.textContent = '请选择文件';
uploadMsg.textContent = '请选择图片文件'; uploadMsg.className = 'status-message error';
uploadMsg.className = 'error'; uploadMsg.style.display = 'block';
return; return;
} }
showProgress();
setProgress(5, '预处理中');
const formData = new FormData(); const formData = new FormData();
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
if (file.type.startsWith('image/')) {
setProgress(5 + Math.round((i/selectedFiles.length)*45), '转换图片');
try {
const jpegFile = await convertToJpeg(file);
formData.append('file', jpegFile);
} catch (_) {
formData.append('file', file); formData.append('file', file);
}
} else {
formData.append('file', file);
}
}
try { try {
let prog = 50;
setProgress(prog, '识别中');
const timer = setInterval(() => {
prog = Math.min(95, prog + 1);
setProgress(prog, '识别中');
}, 200);
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 ct = (resp.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
const text = await resp.text();
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
}
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';
preview.src = data.image_url; uploadMsg.style.display = 'block';
renderFormFromObject(data.data || {});
currentImageRel = data.image; renderPendingItems(data.items || []);
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 = '识别失败';
} }
}); });
confirmBtn.addEventListener('click', async () => { confirmBtn.addEventListener('click', async () => {
confirmMsg.textContent = ''; confirmMsg.textContent = '正在录入...';
try { try {
const edited = objectFromForm(); const payload = {
items: currentItems.map(it => ({
data: it.data,
image: it.images
}))
};
const resp = await fetch('/elastic/confirm/', { const resp = await fetch('/elastic/confirm/', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
@@ -304,30 +534,40 @@ confirmBtn.addEventListener('click', async () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') || '' 'X-CSRFToken': getCookie('csrftoken') || ''
}, },
body: JSON.stringify({ data: edited, image: currentImageRel }) body: JSON.stringify(payload)
}); });
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 || '录入失败');
} }
confirmMsg.textContent = data.message || '录入成功'; confirmMsg.textContent = data.message || '录入成功';
confirmMsg.className = 'success'; confirmMsg.style.color = '#179957';
// 录入成功后清空待处理列表
pendingItems.innerHTML = '';
currentItems = [];
selectedFiles = [];
updateFileHint();
confirmBtn.disabled = true;
} catch (e) { } catch (e) {
confirmMsg.textContent = e.message || '发生错误'; confirmMsg.textContent = e.message || '发生错误';
confirmMsg.className = 'error'; confirmMsg.style.color = '#d14343';
} }
}); });
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
fileInput.value = ''; fileInput.value = '';
preview.src = ''; previewList.innerHTML = '';
resultBox.value = ''; pendingItems.innerHTML = '';
kvForm.innerHTML = '';
uploadMsg.textContent = ''; uploadMsg.textContent = '';
confirmMsg.textContent = ''; confirmMsg.textContent = '';
confirmBtn.disabled = true; confirmBtn.disabled = true;
currentItems = [];
selectedFiles = [];
updateFileHint();
}); });
updateFileHint();
// 退出登录处理 // 退出登录处理
document.getElementById('logoutBtn').addEventListener('click', async () => { document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg'); const msg = document.getElementById('logoutMsg');

File diff suppressed because it is too large Load Diff

View File

@@ -17,13 +17,18 @@ urlpatterns = [
path('search/', views.search, name='search'), path('search/', views.search, name='search'),
path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'), path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'),
path('all-data/', views.get_all_data, name='get_all_data'), path('all-data/', views.get_all_data, name='get_all_data'),
path('analytics/overview/', views.analytics_overview, name='analytics_overview'), path('filter-by-key/', views.filter_by_key, name='filter_by_key'),
path('keys-for-filter/', views.keys_for_filter_view, name='keys_for_filter'),
path('types-for-filter/', views.types_for_filter_view, name='types_for_filter'),
path('filter/', views.filter_view, name='filter'),
path('report/', views.report_view, name='report'),
path('report/csv/', views.report_csv_view, name='report_csv'),
# 用户管理 # 用户管理
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'),
@@ -32,4 +37,19 @@ 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/keys/remove/', views.remove_key_view, name='remove_key'),
path('registration-codes/keys/unallow/', views.unallow_tutor_added_key_view, name='unallow_tutor_added_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'),
] ]

File diff suppressed because it is too large Load Diff

45
main/static/vendor/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,148 +1,63 @@
<!DOCTYPE html> <!DOCTYPE html>
{% load static %}
<html lang="zh-CN"> <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>
body { body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #fafafa;
}
/* 导航栏样式 */ /* 导航栏样式 */
.sidebar { .sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
position: fixed; flex-direction: column;align-items: center;}
top: 0; .user-id {text-align: center;margin-bottom: 0px;}
left: 0; .sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
width: 180px; .navigation-links {width: 100%;margin-top: 60px;}
height: 100vh;
background: #1e1e2e;
color: white;
padding: 20px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
}
.user-id {
text-align: center;
margin-bottom: auto;
}
.sidebar h3 {
margin-top: 0;
font-size: 18px;
color: #ff79c6;
text-align: center;
margin-bottom: 20px;
}
.navigation-links {
width: 100%;
margin-top: 60px;
}
.sidebar a, .sidebar a,
.sidebar button { .sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
display: block; border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
color: #8be9fd;
text-decoration: none;
margin: 10px 0;
font-size: 16px;
padding: 15px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
width: calc(100% - 40px);
text-align: left;
transition: all 0.2s ease;
}
.sidebar a:hover, .sidebar a:hover,
.sidebar button:hover { .sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
color: #ff79c6;
background-color: rgba(139, 233, 253, 0.2);
}
/* 主内容区 */ /* 主内容区 */
.main-content { .main-content {margin-left: 200px;padding: 20px;color: #333;}
margin-left: 200px; .card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
padding: 20px; .grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
color: #333; .grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
} .header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.card { .legend {display: flex;gap: 12px;align-items: center;}
background: #fff; .legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
border-radius: 14px; .muted {color: #6b7280;font-size: 12px;}
box-shadow: 0 10px 24px rgba(31,35,40,0.08); .btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
padding: 20px; .btn-primary {background: #4f46e5;color: #fff;}
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.badge {
background: #eef2ff;
color: #3730a3;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
}
.legend {
display: flex;
gap: 12px;
align-items: center;
}
.legend .dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.muted {
color: #6b7280;
font-size: 12px;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-primary {
background: #4f46e5;
color: #fff;
}
</style> </style>
</head> </head>
<body> <body>
<!-- 左侧固定栏目 --> <!-- 左侧固定栏目 -->
<div class="sidebar"> <div class="sidebar">
<div class="user-id"> <div class="user-id">
<h3>用户ID{{ user_id }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
</div> </div>
<div class="navigation-links"> <div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a> <a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a> <a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
{% if is_admin or has_manage_key %}
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a> <a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a>
<a href="/elastic/users/" onclick="return handleNavClick(this, '/elastic/users/');">用户管理</a> {% endif %}
<button id="logoutBtn">退出登录</button> {% if is_admin or has_manage_key %}
<a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a>
{% endif %}
<a href="/accounts/profile/">个人中心</a>
{% if is_admin or has_manage_key or can_manage_registration_codes %}
<a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
{% endif %}
{% if is_admin %}
<a href="{% url 'accounts:registration_code_requests_page' %}">注册码申请管理</a>
{% endif %}
{% if not is_admin and not has_manage_key and not can_manage_registration_codes and not has_registration_code %}
<a id="applyRegBtn" href="javascript:void(0)">申请注册码管理</a>
{% endif %}
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
</div> </div>
@@ -152,42 +67,52 @@
<div class="main-content"> <div class="main-content">
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h2>数据概览</h2> <h2>师生共创系统</h2>
<div style="display:flex; gap:8px; align-items:center;">
<span class="badge">用户:{{ user_id }}</span> <span class="badge">用户:{{ user_id }}</span>
{% if is_admin %}
<button id="triggerAnalyze" class="btn btn-primary">手动开始分析</button>
{% endif %}
</div>
</div>
<div class="grid-3">
<div>
<div class="legend"><span class="dot" style="background:#4f46e5;"></span><span class="muted">最近十天录入</span></div>
<canvas id="chartDays" height="140"></canvas>
</div>
<div>
<div class="legend"><span class="dot" style="background:#16a34a;"></span><span class="muted">最近十周录入</span></div>
<canvas id="chartWeeks" height="140"></canvas>
</div>
<div>
<div class="legend"><span class="dot" style="background:#ea580c;"></span><span class="muted">最近十个月录入</span></div>
<canvas id="chartMonths" height="140"></canvas>
</div>
</div> </div>
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
</div> </div>
<div class="grid" style="margin-top:16px;"> <div class="grid" style="margin-top:16px;">
<div class="card"> <div class="card">
<div class="header"><h2>近1个月成果类型</h2></div> <div class="header"><h3>录入量变化近90天</h3></div>
<canvas id="pie1m" height="200"></canvas> <div id="chartTrend" style="width:100%;height:320px;"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="header"><h2>近12个月成果类型</h2></div> <div class="header">
<canvas id="pie12m" height="200"></canvas> <h3>类型占比近30天</h3>
<button id="toggleTypesChartBtn" class="btn btn-primary" style="font-size: 12px; padding: 4px 8px;">切换图表</button>
</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 id="applyRegModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:3000; align-items:center; justify-content:center;">
<div class="card" style="width:min(560px, calc(100vw - 40px));">
<div class="header">
<h3 style="margin:0;">申请注册码管理权限</h3>
<button id="applyRegClose" class="btn" type="button" style="background:#e5e7eb;">关闭</button>
</div>
<div class="muted" style="margin-bottom:10px;">填写申请理由,管理员同意后可进入“注册码管理”页面。</div>
<div style="margin-top:10px;">
<label for="applyReason" style="display:block; margin-bottom:6px; font-weight:600;">申请理由</label>
<textarea id="applyReason" rows="5" style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px; box-sizing:border-box; resize: vertical;"></textarea>
</div>
<div id="applyRegMsg" class="muted" style="margin-top:10px;"></div>
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:14px;">
<button id="applyRegSubmit" class="btn btn-primary" type="button">提交申请</button>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
// 获取CSRF令牌的函数 // 获取CSRF令牌的函数
function getCookie(name) { function getCookie(name) {
@@ -254,88 +179,219 @@
} }
}); });
async function loadAnalytics() { const applyRegBtn = document.getElementById('applyRegBtn');
const resp = await fetch('/elastic/analytics/overview/'); const applyRegModal = document.getElementById('applyRegModal');
const d = await resp.json(); const applyRegClose = document.getElementById('applyRegClose');
if (!resp.ok || d.status !== 'success') return; const applyRegSubmit = document.getElementById('applyRegSubmit');
const data = d.data || {}; const applyRegMsg = document.getElementById('applyRegMsg');
renderLine('chartDays', data.last_10_days || [], '#4f46e5'); const applyReason = document.getElementById('applyReason');
renderLine('chartWeeks', data.last_10_weeks || [], '#16a34a');
renderLine('chartMonths', data.last_10_months || [], '#ea580c');
renderPie('pie1m', data.type_pie_1m || []);
renderPie('pie12m', data.type_pie_12m || []);
}
const btn = document.getElementById('triggerAnalyze'); function openApplyRegModal() {
if (btn) { if (!applyRegModal) return;
btn.addEventListener('click', async () => { applyRegMsg.textContent = '';
btn.disabled = true; applyReason.value = '';
btn.textContent = '分析中…'; applyRegModal.style.display = 'flex';
}
function closeApplyRegModal() {
if (!applyRegModal) return;
applyRegModal.style.display = 'none';
}
if (applyRegBtn) applyRegBtn.addEventListener('click', openApplyRegModal);
if (applyRegClose) applyRegClose.addEventListener('click', closeApplyRegModal);
if (applyRegModal) {
applyRegModal.addEventListener('click', (e) => {
if (e.target === applyRegModal) closeApplyRegModal();
});
}
if (applyRegSubmit) {
applyRegSubmit.addEventListener('click', async () => {
const reason = (applyReason.value || '').trim();
if (!reason) {
applyRegMsg.textContent = '请填写申请理由';
return;
}
applyRegMsg.textContent = '提交中...';
const csrftoken = getCookie('csrftoken');
try { try {
const resp = await fetch('/elastic/analytics/overview/?force=1'); const resp = await fetch('/accounts/registration-code/request/submit/', {
const d = await resp.json(); method: 'POST',
if (!resp.ok || d.status !== 'success') throw new Error('分析失败'); credentials: 'same-origin',
window.location.reload(); headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ reason })
});
const data = await resp.json();
if (resp.ok && data.ok) {
applyRegMsg.textContent = '已提交申请,请等待管理员审核';
if (applyRegBtn) {
applyRegBtn.textContent = '已提交申请';
applyRegBtn.disabled = true;
applyRegBtn.style.opacity = '0.6';
applyRegBtn.style.cursor = 'not-allowed';
}
setTimeout(() => closeApplyRegModal(), 800);
} else {
applyRegMsg.textContent = (data && data.message) ? data.message : '提交失败';
}
} catch (e) { } catch (e) {
btn.textContent = '重试'; applyRegMsg.textContent = '提交失败';
btn.disabled = false;
} }
}); });
} }
function hexWithAlpha(hex, alphaHex) {
if (!hex || !hex.startsWith('#')) return hex; function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
if (hex.length === 7) return hex + alphaHex; function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
return hex;
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' }]
});
} }
function renderLine(id, items, color) {
const ctx = document.getElementById(id); let typesChartData = [];
const labels = items.map(x => x.label); let currentChartType = 'pie';
const values = items.map(x => x.count); let typesChartInterval = null;
new Chart(ctx, {
type: 'line', async function loadTypes(){
data: { const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
labels, const res = await fetchJSON(url);
datasets: [{ if(res.status!=='success') return;
data: values, const buckets = res.data || [];
borderColor: color, typesChartData = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
backgroundColor: hexWithAlpha(color, '26'), renderTypesChart();
tension: 0.25, startTypesChartRotation();
fill: true, }
pointRadius: 3,
function renderTypesChart() {
if (currentChartType === 'pie') {
typesChart.setOption({
tooltip:{trigger:'item'},
legend:{type:'scroll', top:'bottom'},
grid: { top: 0, bottom: 0, left: 0, right: 0 },
xAxis: { show: false },
yAxis: { show: false },
series:[{
type:'pie',
radius:['40%','70%'],
center: ['50%', '50%'],
data: typesChartData,
label: { show: false },
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 }
}] }]
}, }, true);
options: { } else {
responsive: true, const names = typesChartData.map(d => d.name);
plugins: { legend: { display: false } }, const values = typesChartData.map(d => d.value);
animation: { duration: 800, easing: 'easeOutQuart' }, typesChart.setOption({
scales: { tooltip:{trigger:'axis', axisPointer:{type:'shadow'}},
x: { grid: { display: false } }, legend:{show: false},
y: { grid: { color: 'rgba(31,35,40,0.06)' }, beginAtZero: true } grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: names, show: true },
yAxis: { type: 'value', show: true },
series: [{
type: 'bar',
data: values,
itemStyle: { color: '#5470c6' },
barWidth: '60%'
}]
}, true);
} }
} }
function toggleChartType() {
currentChartType = currentChartType === 'pie' ? 'bar' : 'pie';
renderTypesChart();
}
function startTypesChartRotation() {
if (typesChartInterval) clearInterval(typesChartInterval);
typesChartInterval = setInterval(() => {
toggleChartType();
}, 5000);
}
document.getElementById('toggleTypesChartBtn').addEventListener('click', () => {
toggleChartType();
// Reset timer on manual interaction
startTypesChartRotation();
}); });
}
function renderPie(id, items) { async function loadTypesTrend(){
const ctx = document.getElementById(id); const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
const labels = items.map(x => x.type); const res = await fetchJSON(url);
const values = items.map(x => x.count); if(res.status!=='success') return;
const colors = ['#2563eb','#22c55e','#f59e0b','#ef4444','#a855f7','#06b6d4','#84cc16','#ec4899','#475569','#d946ef']; const rows = res.data || [];
new Chart(ctx, { const x = rows.map(r=>r.key_as_string||'');
type: 'doughnut', const typeSet = new Set();
data: { rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
labels, const types = Array.from(typeSet);
datasets: [{ data: values, backgroundColor: colors.slice(0, labels.length) }] const series = types.map(tp=>({
}, name: tp,
options: { type:'line',
responsive: true, smooth:true,
animation: { duration: 900, easing: 'easeOutQuart' }, data: rows.map(r=>{
plugins: { legend: { position: 'bottom' } } 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
}); });
} }
loadAnalytics(); 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 || '未知';
const de = it.detail ? `${it.detail}` : '';
li.textContent = `${t}${u}${ty}${de}`;
listEl.appendChild(li);
});
}
loadTrend();
loadTypes();
loadTypesTrend();
loadRecent();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -10,16 +10,29 @@ def home(request):
if session_user_id is None: if session_user_id is None:
return redirect("/accounts/login/") return redirect("/accounts/login/")
# Show user_id (prefer query param if present, but don't trust it) uid = session_user_id
user_id_qs = request.GET.get("user_id")
uid = user_id_qs or session_user_id
perm = request.session.get("permission") perm = request.session.get("permission")
u = get_user_by_id(uid) if uid is not None else None
if perm is None and uid is not None: if perm is None and uid is not None:
u = get_user_by_id(uid) try:
perm = (u or {}).get("permission", 1) perm = int((u or {}).get("permission", 1))
except Exception:
perm = 1
request.session["permission"] = perm request.session["permission"] = perm
else:
try:
perm = int(perm)
except Exception:
perm = 1
has_manage_key = bool((u or {}).get("manage_key") or [])
can_manage_registration_codes = bool(int((u or {}).get("can_manage_registration_codes") or 0) == 1)
has_registration_code = bool(str((u or {}).get("registration_code") or "").strip())
context = { context = {
"user_id": uid, "user_id": uid,
"is_admin": (perm == 0), "username": (u or {}).get("username"),
"is_admin": (int(perm) == 0),
"has_manage_key": has_manage_key,
"can_manage_registration_codes": can_manage_registration_codes,
"has_registration_code": has_registration_code,
} }
return render(request, "main/home.html", context) return render(request, "main/home.html", context)

View File

@@ -0,0 +1 @@

22
minio_storage/apps.py Normal file
View File

@@ -0,0 +1,22 @@
from django.apps import AppConfig
import os
import sys
class MinioStorageConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'minio_storage'
def ready(self):
if os.path.basename(sys.argv[0]) == 'manage.py':
if os.environ.get('RUN_MAIN') != 'true':
return
if 'runserver' not in sys.argv:
return
from .minio_connect import ensure_bucket_exists
try:
ensure_bucket_exists()
except Exception as e:
print(f"❌ MinIO 初始化失败: {e}")

View File

@@ -0,0 +1,133 @@
import os
from datetime import timedelta
import mimetypes
from urllib.parse import urlparse
from minio import Minio
from minio.error import S3Error
def _env_bool(name: str, default: bool = False) -> bool:
v = os.environ.get(name)
if v is None:
return default
return str(v).strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
def _normalize_endpoint(minio_url: str):
if not minio_url:
return None, None
u = str(minio_url).strip()
parsed = urlparse(u)
if parsed.scheme in {'http', 'https'}:
endpoint = parsed.netloc
secure = parsed.scheme == 'https'
else:
endpoint = u
secure = None
endpoint = endpoint.strip().rstrip('/')
return endpoint, secure
def _get_env(*names: str, default: str | None = None) -> str | None:
for n in names:
v = os.environ.get(n)
if v is not None and str(v).strip() != '':
return str(v).strip()
return default
def get_minio_client() -> Minio | None:
minio_url = _get_env('MINIO_URL', 'MINIO_ENDPOINT')
access_key = _get_env('MINIO_ACCESS_KEY')
secret_key = _get_env('MINIO_SECRET_KEY')
if not minio_url or not access_key or not secret_key:
return None
endpoint, secure_from_url = _normalize_endpoint(minio_url)
if not endpoint:
return None
secure = _env_bool('MINIO_SECURE', default=secure_from_url if secure_from_url is not None else False)
region = _get_env('MINIO_REGION', default=None)
return Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure,
region=region,
)
def is_minio_configured() -> bool:
return get_minio_client() is not None
def get_bucket_name() -> str:
return _get_env('MINIO_BUCKET', default='achievement') or 'achievement'
def ensure_bucket_exists() -> bool:
client = get_minio_client()
bucket = get_bucket_name()
if client is None:
print(' MinIO 环境变量未配置,跳过桶检查')
return False
if not bucket:
print(' MINIO_BUCKET 为空,跳过桶检查')
return False
try:
exists = client.bucket_exists(bucket)
except S3Error as e:
print(f'❌ MinIO 连接失败: {e}')
return False
if exists:
print(f' MinIO 桶已存在: {bucket}')
return True
try:
region = _get_env('MINIO_REGION', default=None)
if region:
client.make_bucket(bucket, location=region)
else:
client.make_bucket(bucket)
print(f'✅ MinIO 桶已创建: {bucket}')
return True
except S3Error as e:
print(f'❌ MinIO 创建桶失败: {e}')
return False
def upload_file(file_path: str, object_name: str, content_type: str | None = None) -> str:
client = get_minio_client()
if client is None:
raise RuntimeError('MinIO 未配置')
bucket = get_bucket_name()
ensure_bucket_exists()
ct = content_type
if not ct:
guessed, _ = mimetypes.guess_type(object_name)
ct = guessed or 'application/octet-stream'
client.fput_object(bucket, object_name, file_path, content_type=ct)
return object_name
def presigned_get_url(object_name: str, expires_seconds: int = 8 * 60 * 60) -> str:
client = get_minio_client()
if client is None:
raise RuntimeError('MinIO 未配置')
bucket = get_bucket_name()
ensure_bucket_exists()
exp = max(1, int(expires_seconds or 0))
return client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=exp))

View File

@@ -6,6 +6,12 @@ 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 httpx==0.27.2
zai-sdk==0.2.2
Pillow==10.4.0 Pillow==10.4.0
minio>=7.2.0,<8
gunicorn==21.2.0 gunicorn==21.2.0
whitenoise==6.6.0 whitenoise==6.6.0
django-browser-reload==1.21.0
captcha==0.7.1
cryptography==46.0.3
pymupdf==1.25.3