65 Commits

Author SHA1 Message Date
ecd9933c0f [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 3s
2026-03-18 21:16:04 +08:00
71e34a57f1 [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 2s
2026-03-18 21:13:39 +08:00
a0fa32bdba [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 2s
2026-03-18 21:07:01 +08:00
61d2189f21 [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 3s
2026-03-18 20:44:05 +08:00
24d5caef5a [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 4s
2026-03-18 20:42:19 +08:00
415ddc7110 [0.2.7.4][ci]
Some checks failed
CI / docker-ci (push) Failing after 3s
2026-03-18 20:39:00 +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
26 changed files with 3941 additions and 616 deletions

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

@@ -0,0 +1,153 @@
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: ${{ gitea.token }}
USER_TOKEN: ${{ secrets.token }}
run: |
git config --global init.defaultBranch main
if [ -f "$GITHUB_WORKSPACE/Dockerfile" ]; then exit 0; fi
mkdir -p "$GITHUB_WORKSPACE"
cd "$GITHUB_WORKSPACE"
git init .
# 优先使用 gitea.token (TOKEN)
AUTH_TOKEN="$TOKEN"
USE_BASIC_AUTH="false"
if [ -n "$TOKEN" ]; then
echo "Using gitea.token for authentication (Basic Auth)."
USE_BASIC_AUTH="true"
elif [ -n "$USER_TOKEN" ]; then
AUTH_TOKEN="$USER_TOKEN"
USE_BASIC_AUTH="true"
echo "Using secrets.token for authentication (Basic Auth)."
else
echo "Warning: No token found. Attempting unauthenticated fetch (will fail for private repos)."
fi
if [ -z "$AUTH_TOKEN" ]; then
git fetch --depth=1 "$SERVER/$REPO.git" "$REF"
elif [ "$USE_BASIC_AUTH" = "true" ]; then
# 使用 Gitea 支持的 Basic Auth: https://x-access-token:token@gitea.domain/user/repo.git
# 去掉 SERVER 中的 https:// 或 http:// 前缀以构建正确的 URL
CLEAN_SERVER=$(echo "$SERVER" | sed -E 's/^\s*.*:\/\///g')
git fetch --depth=1 "https://x-access-token:$AUTH_TOKEN@$CLEAN_SERVER/$REPO.git" "$REF"
else
# 使用 Bearer Token 进行认证
git -c http.extraHeader="Authorization: Bearer $AUTH_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

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'accounts', 'accounts',
'main', 'main',
'elastic', 'elastic',
'minio_storage',
'django_elasticsearch_dsl', 'django_elasticsearch_dsl',
] ]
@@ -166,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')

195
README.md
View File

@@ -1 +1,196 @@
# 多级权限控制数据结构说明
## 核心概念
该设计通过 **关键字匹配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()" python manage.py shell -c "from elastic.es_connect import create_index_with_mapping; create_index_with_mapping()"

View File

@@ -91,3 +91,25 @@ def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool:
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
except Exception: except Exception:
return False return False
def generate_rsa_private_pem_b64() -> str:
if rsa is None or serialization is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = priv.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
return base64.b64encode(pem).decode('ascii')
def public_spki_b64_from_private_pem_b64(private_pem_b64: str) -> str:
if serialization is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
pub = priv.public_key()
spki = pub.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
return base64.b64encode(spki).decode('ascii')
def rsa_oaep_decrypt_b64_with_private_pem(private_pem_b64: str, ciphertext_b64: str) -> bytes:
if serialization is None or padding is None or hashes is None:
raise RuntimeError("cryptography library is required for RSA operations")
priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None)
ct = base64.b64decode(ciphertext_b64)
return priv.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))

View File

@@ -77,8 +77,20 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const setKeyResp = await fetch('/accounts/session-key/', { const setKeyResp = await fetch('/accounts/session-key/', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 }) method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 })
}); });
const setKeyJson = await setKeyResp.json(); const setKeySnapshot = await (async () => {
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败'); const clone = setKeyResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await setKeyResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!setKeySnapshot.parsed) {
const msg = (setKeySnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
throw new Error(mapped);
}
const setKeyJson = setKeySnapshot.parsed;
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
const aesKey = await importAesKey(aesKeyRaw); const aesKey = await importAesKey(aesKeyRaw);
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv); const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
@@ -92,7 +104,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const submitResp = await fetch('/accounts/login/secure-submit/', { const submitResp = await fetch('/accounts/login/secure-submit/', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 }) method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
}); });
const submitJson = await submitResp.json(); const submitSnapshot = await (async () => {
const clone = submitResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await submitResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!submitSnapshot.parsed) {
const msg = (submitSnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
throw new Error(mapped);
}
const submitJson = submitSnapshot.parsed;
if (!submitResp.ok || !submitJson.ok) { if (!submitResp.ok || !submitJson.ok) {
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); } if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
throw new Error(submitJson.message || '登录失败'); throw new Error(submitJson.message || '登录失败');

View File

@@ -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

@@ -20,10 +20,14 @@
<h1>注册新用户</h1> <h1>注册新用户</h1>
<form id="regForm"> <form id="regForm">
{% csrf_token %} {% csrf_token %}
<label for="code">注册码</label> <label for="code">注册码(选填)</label>
<input id="code" name="code" type="text" required /> <input id="code" name="code" type="text" />
<label for="email">邮箱</label> <label for="email">邮箱</label>
<input id="email" name="email" type="email" required /> <input id="email" name="email" type="email" required />
<button id="sendCodeBtn" type="button">发送验证码</button>
<div id="sendMsg" class="hint"></div>
<label for="email_code">邮箱验证码</label>
<input id="email_code" name="email_code" type="text" required />
<label for="username">用户名</label> <label for="username">用户名</label>
<input id="username" name="username" type="text" required /> <input id="username" name="username" type="text" required />
<label for="password">密码</label> <label for="password">密码</label>
@@ -33,7 +37,7 @@
<button id="regBtn" type="submit">注册</button> <button id="regBtn" type="submit">注册</button>
<div id="error" class="error"></div> <div id="error" class="error"></div>
</form> </form>
<div class="hint">仅允许持有管理员提供注册码的学生注册</div> <div class="hint">有注册码请填写,否则可留空</div>
</div> </div>
<script> <script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();} function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
@@ -43,20 +47,36 @@
const code=(document.getElementById('code').value||'').trim(); const code=(document.getElementById('code').value||'').trim();
const email=(document.getElementById('email').value||'').trim(); const email=(document.getElementById('email').value||'').trim();
const username=(document.getElementById('username').value||'').trim(); const username=(document.getElementById('username').value||'').trim();
const email_code=(document.getElementById('email_code').value||'').trim();
const password=document.getElementById('password').value||''; const password=document.getElementById('password').value||'';
const confirm=document.getElementById('confirm').value||''; const confirm=document.getElementById('confirm').value||'';
if(!code||!email||!username||!password){err.textContent='请填写所有字段';return;} if(!email||!email_code||!username||!password){err.textContent='请填写所有必填字段';return;}
if(password!==confirm){err.textContent='两次密码不一致';return;} if(password!==confirm){err.textContent='两次密码不一致';return;}
const btn=document.getElementById('regBtn'); btn.disabled=true; const btn=document.getElementById('regBtn'); btn.disabled=true;
try{ try{
const csrftoken=getCookie('csrftoken'); const csrftoken=getCookie('csrftoken');
const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,username,password})}); const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,email_code,username,password})});
const data=await resp.json(); const data=await resp.json();
if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');} if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');}
window.location.href=data.redirect_url; window.location.href=data.redirect_url;
}catch(e){err.textContent=e.message||'发生错误';} }catch(e){err.textContent=e.message||'发生错误';}
finally{btn.disabled=false;} finally{btn.disabled=false;}
}); });
document.getElementById('sendCodeBtn').addEventListener('click',async()=>{
const email=(document.getElementById('email').value||'').trim();
const msg=document.getElementById('sendMsg');
msg.textContent='';
if(!email){msg.textContent='请输入邮箱';return;}
const btn=document.getElementById('sendCodeBtn'); btn.disabled=true;
try{
const csrftoken=getCookie('csrftoken');
const resp=await fetch('/accounts/email/send-code/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({email})});
const data=await resp.json();
if(!resp.ok||!data.ok){throw new Error(data.message||'发送失败');}
msg.textContent='验证码已发送,请查收邮件';
}catch(e){msg.textContent=e.message||'发送失败';}
finally{btn.disabled=false;}
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -11,4 +11,12 @@ urlpatterns = [
path("logout/", views.logout, name="logout"), path("logout/", views.logout, name="logout"),
path("register/", views.register_page, name="register"), path("register/", views.register_page, name="register"),
path("register/submit/", views.register_submit, name="register_submit"), path("register/submit/", views.register_submit, name="register_submit"),
path("email/send-code/", views.send_email_code, name="send_email_code"),
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

@@ -4,6 +4,8 @@ import os
import io import io
import random import random
import string import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
@@ -12,8 +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 get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password, generate_rsa_private_pem_b64, public_spki_b64_from_private_pem_b64, rsa_oaep_decrypt_b64_with_private_pem
from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data, 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"])
@@ -25,7 +27,11 @@ def login_page(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@ensure_csrf_cookie @ensure_csrf_cookie
def pubkey(request): def pubkey(request):
pk_b64 = get_public_key_spki_b64() pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
pem_b64 = generate_rsa_private_pem_b64()
request.session["rsa_private_pem_b64"] = pem_b64
pk_b64 = public_spki_b64_from_private_pem_b64(pem_b64)
return JsonResponse({"public_key_spki": pk_b64}) return JsonResponse({"public_key_spki": pk_b64})
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -56,12 +62,44 @@ def set_session_key(request):
if not enc_key_b64: if not enc_key_b64:
return HttpResponseBadRequest("Missing fields") return HttpResponseBadRequest("Missing fields")
try: try:
key_bytes = rsa_oaep_decrypt_b64(enc_key_b64) pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
return HttpResponseBadRequest("Decrypt error")
key_bytes = rsa_oaep_decrypt_b64_with_private_pem(pem_b64, enc_key_b64)
except Exception: except Exception:
return HttpResponseBadRequest("Decrypt error") return HttpResponseBadRequest("Decrypt error")
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii") request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
return JsonResponse({"ok": True}) return JsonResponse({"ok": True})
@require_http_methods(["GET"])
@ensure_csrf_cookie
def profile_page(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return redirect("/accounts/login/")
# 获取用户信息
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"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
def secure_login_submit(request): def secure_login_submit(request):
@@ -110,6 +148,8 @@ def secure_login_submit(request):
request.session["permission"] = 1 request.session["permission"] = 1
if "session_enc_key_b64" in request.session: if "session_enc_key_b64" in request.session:
del request.session["session_enc_key_b64"] del request.session["session_enc_key_b64"]
if "rsa_private_pem_b64" in request.session:
del request.session["rsa_private_pem_b64"]
if "login_failed_once" in request.session: if "login_failed_once" in request.session:
del request.session["login_failed_once"] del request.session["login_failed_once"]
if "captcha_code" in request.session: if "captcha_code" in request.session:
@@ -174,10 +214,75 @@ def register_submit(request):
return HttpResponseBadRequest("Invalid JSON") return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip() code = (payload.get("code") or "").strip()
email = (payload.get("email") or "").strip() email = (payload.get("email") or "").strip()
email_code = (payload.get("email_code") or "").strip()
username = (payload.get("username") or "").strip() username = (payload.get("username") or "").strip()
password = (payload.get("password") or "") password = (payload.get("password") or "")
if not code or not email or not username or not password: if not email or not email_code or not username or not password:
return HttpResponseBadRequest("Missing fields") return HttpResponseBadRequest("Missing fields")
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) rc = get_registration_code(code)
if not rc: if not rc:
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400) return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
@@ -192,20 +297,198 @@ def register_submit(request):
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400) return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
except Exception: except Exception:
pass pass
existing = es_get_user_by_username(username) keys = list(rc.get("keys") or [])
if existing: manage_keys = list(rc.get("manage_keys") or [])
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409) ok = update_user_by_id(session_user_id, key=keys, manage_key=manage_keys, registration_code=code)
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") or [],
"manage_key": rc.get("manage_keys") or [],
})
if not ok: if not ok:
return JsonResponse({"ok": False, "message": "注册失败"}, status=500) return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"}) 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

@@ -35,6 +35,9 @@ class UserDocument(Document):
user_id = fields.LongField() user_id = fields.LongField()
username = fields.KeywordField() username = fields.KeywordField()
email = fields.KeywordField() email = fields.KeywordField()
registration_code = fields.KeywordField()
can_manage_registration_codes = fields.IntegerField()
registration_manage_keys = fields.KeywordField(multi=True)
password_hash = fields.KeywordField() password_hash = fields.KeywordField()
password_salt = fields.KeywordField() password_salt = fields.KeywordField()
permission = fields.IntegerField() # 还是2种权限0为管理员1为用户区别在于0有全部权限1在数据管理页面有搜索框但是索引到的录入信息要根据其用户id查询其key若其中之一与用户的manage_key字段匹配就显示否则不显示 permission = fields.IntegerField() # 还是2种权限0为管理员1为用户区别在于0有全部权限1在数据管理页面有搜索框但是索引到的录入信息要根据其用户id查询其key若其中之一与用户的manage_key字段匹配就显示否则不显示
@@ -60,6 +63,7 @@ class RegistrationCodeDocument(Document):
code = fields.KeywordField() #具体值 code = fields.KeywordField() #具体值
keys = fields.KeywordField(multi=True) #对应的key keys = fields.KeywordField(multi=True) #对应的key
manage_keys = fields.KeywordField(multi=True) #对应的manage_key manage_keys = fields.KeywordField(multi=True) #对应的manage_key
created_at = fields.DateField() #创建时间
expires_at = fields.DateField() #过期时间 expires_at = fields.DateField() #过期时间
created_by = fields.LongField() #创建者id created_by = fields.LongField() #创建者id
class Django: class Django:

View File

@@ -197,6 +197,55 @@ def get_registration_code(code: str):
except Exception: except Exception:
return None return None
def list_registration_codes():
try:
search = RegistrationCodeDocument.search()
body = {
"sort": [{"created_at": {"order": "desc"}}],
"query": {"exists": {"field": "code"}}
}
search = search.update_from_dict(body)
resp = search.execute()
out = []
now = datetime.now(timezone.utc)
for hit in resp:
try:
if not getattr(hit, 'code', None):
continue
except Exception:
continue
exp = getattr(hit, 'expires_at', None)
try:
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = datetime.fromisoformat(str(exp))
except Exception:
exp_dt = None
active = bool(exp_dt and exp_dt > now)
out.append({
"code": getattr(hit, 'code', ''),
"keys": list(getattr(hit, 'keys', []) or []),
"manage_keys": list(getattr(hit, 'manage_keys', []) or []),
"created_at": getattr(hit, 'created_at', None),
"expires_at": getattr(hit, 'expires_at', None),
"created_by": getattr(hit, 'created_by', None),
"active": active,
})
return out
except Exception:
return []
def revoke_registration_code(code: str):
try:
doc = RegistrationCodeDocument.get(id=str(code))
now = datetime.now(timezone.utc).isoformat()
doc.expires_at = now
doc.save()
return True
except Exception:
return False
def get_doc_id(data): def get_doc_id(data):
""" """
根据数据内容生成唯一ID用于去重 根据数据内容生成唯一ID用于去重
@@ -440,9 +489,111 @@ def analytics_trend(gte: str = None, lte: str = None, interval: str = "day"):
print(f"分析趋势失败: {str(e)}") print(f"分析趋势失败: {str(e)}")
return [] return []
def analytics_types(gte: str = None, lte: str = None, size: int = 10): def delete_key_globally(key_to_remove: str):
try: try:
filters = _type_filters_from_list(limit=size) # 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"}} # 确保是注册码文档
]
}
}
}
updated_count = 0
for hit in scan(es, query=query, index=GLOBAL_INDEX_NAME):
try:
# 重新获取文档对象进行操作
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:
print(f"同步清理注册码 {hit['_id']} 失败: {str(e)}")
# 3. 同步清理所有用户中的该 key (无论是 key 还是 manage_key 字段)
try:
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 = { body = {
"size": 0, "size": 0,
"aggs": { "aggs": {
@@ -540,6 +691,25 @@ def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
pass pass
return "" 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() search = AchievementDocument.search()
body = { body = {
"size": max(1, min(limit, 100)), "size": max(1, min(limit, 100)),
@@ -570,11 +740,13 @@ def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
except Exception: except Exception:
uname = None uname = None
tval = _extract_type(getattr(hit, 'data', '')) tval = _extract_type(getattr(hit, 'data', ''))
dval = _extract_detail(getattr(hit, 'data', ''))
results.append({ results.append({
"_id": hit.meta.id, "_id": hit.meta.id,
"writer_id": w, "writer_id": w,
"username": uname or "", "username": uname or "",
"type": tval or "", "type": tval or "",
"detail": dval or "",
"time": getattr(hit, 'time', None) "time": getattr(hit, 'time', None)
}) })
return results return results
@@ -611,6 +783,9 @@ def write_user_data(user_data):
password_salt=pwd_salt_b64, password_salt=pwd_salt_b64,
permission=perm_val, permission=perm_val,
email=user_data.get('email'), 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 []), key=list(user_data.get('key') or []),
manage_key=list(user_data.get('manage_key') or []), manage_key=list(user_data.get('manage_key') or []),
) )
@@ -621,25 +796,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,
"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):
""" """
根据用户名获取用户数据 根据用户名获取用户数据
@@ -681,7 +837,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": int(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
@@ -700,6 +862,12 @@ def get_user_by_id(user_id):
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"permission": int(hit.permission), "permission": int(hit.permission),
"email": getattr(hit, 'email', None),
"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:
@@ -721,7 +889,7 @@ def delete_user_by_id(user_id):
print(f"删除用户失败: {str(e)}") print(f"删除用户失败: {str(e)}")
return False return False
def update_user_by_id(user_id, username=None, permission=None, password=None): 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):
try: try:
search = UserDocument.search() search = UserDocument.search()
search = search.query("term", user_id=int(user_id)) search = search.query("term", user_id=int(user_id))
@@ -737,9 +905,120 @@ def update_user_by_id(user_id, username=None, permission=None, password=None):
salt_b64, hash_b64 = hash_password_random_salt(str(password)) salt_b64, hash_b64 = hash_password_random_salt(str(password))
doc.password_hash = hash_b64 doc.password_hash = hash_b64
doc.password_salt = salt_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() doc.save()
return True return True
return False return False
except Exception as e: except Exception as e:
print(f"更新用户失败: {str(e)}") print(f"更新用户失败: {str(e)}")
return False return False
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

View File

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

View File

@@ -3,223 +3,52 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>数据管理</title> <title>数据管理</title>
<style> <style>
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;} body{margin:0;font-family:sans-serif;background:#fafafa}
/* 导航栏样式 */ .sidebar{position:fixed;top:0;left:0;width:180px;height:100vh;background:#1e1e2e;color:white;padding:20px;box-shadow:2px 0 5px rgba(0,0,0,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
.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; .user-id{text-align:center;margin-bottom:0}
flex-direction: column;align-items: center;} .sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
.user-id {text-align: center;margin-bottom: 0px;} .navigation-links{width:100%;margin-top:60px}
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;} .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}
.navigation-links {width: 100%;margin-top: 60px;} .sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
.sidebar a, .main-content{margin-left:200px;padding:20px;color:#333}
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent; .container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;} table{width:100%;border-collapse:collapse;margin-top:20px}
.sidebar a:hover, th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);} th{background:#f8f9fa;font-weight:600}
/* 主内容区 */ .inner-table { width: 100%; margin: 0; border: 1px solid #e0e0e0; border-collapse: collapse; table-layout: fixed; }
.main-content { .inner-table th, .inner-table td { border: 1px solid #e0e0e0; padding: 8px; font-size: 13px; word-break: break-all; }
margin-left: 200px; .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; }
color: #333; .inner-table th { background-color: #f9f9f9; }
} img{max-width:120px;border:1px solid #eee;border-radius:6px;cursor:pointer}
.btn{padding:6px 10px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin:2px}
/* 原有样式保持不变 */ .btn-primary{background:#1677ff;color:#fff}
.container { .btn-danger{background:#ff4d4f;color:#fff}
max-width: 1200px; .btn-secondary{background:#f0f0f0;color:#333}
margin: 0 auto; .muted{color:#666;font-size:12px}
background: #fff; .modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
border-radius: 10px; .modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
box-shadow: 0 6px 18px rgba(0,0,0,0.06); textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
padding: 20px; #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}
table { .search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
width: 100%; .search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
border-collapse: collapse; .search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
margin-top: 20px; .search-result.empty{background:#fff8e8}
} .search-result.error{background:#ffe8e8}
th, td { .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}
border-bottom: 1px solid #eee; @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
padding: 12px 8px; @media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
text-align: left; .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}
vertical-align: top; .image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
} .image-modal-content.dragging{cursor:grabbing}
th { .image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
background-color: #f8f9fa; .image-modal-close:hover{color:#bbb}
font-weight: 600; .zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
} .zoom-btn{background:rgba(255,255,255,.7);border:none;border-radius:50%;width:40px;height:40px;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.3);transition:background .3s}
img { .zoom-btn:hover{background:rgba(255,255,255,.9)}
max-width: 120px; .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}
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer; /* 添加指针样式 */
}
.btn {
padding: 6px 10px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin: 2px;
}
.btn-primary {
background: #1677ff;
color: #fff;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.muted {
color: #666;
font-size: 12px;
}
.modal {
position: fixed;
inset: 0;
display: none;
background: rgba(0,0,0,0.4);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal .dialog {
width: 720px;
max-width: 92vw;
background: #fff;
border-radius: 10px;
padding: 20px;
max-height: 80vh;
overflow-y: auto;
}
textarea {
width: 100%;
min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
}
#kvForm {
border: 1px solid #eee;
border-radius: 6px;
padding: 8px;
max-height: 300px;
overflow: auto;
}
/* 搜索区域样式 */
.search-container {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.search-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-result {
margin-top: 10px;
padding: 10px;
background: #e8f4ff;
border-radius: 4px;
font-size: 14px;
}
.search-result.empty {
background: #fff8e8;
}
.search-result.error {
background: #ffe8e8;
}
/* 加载动画 */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1677ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式调整 */
@media (max-width: 768px) {
.search-controls {
flex-direction: column;
align-items: stretch;
}
.search-input {
min-width: auto;
}
.btn {
width: 100%;
margin: 2px 0;
}
}
/* 图片放大模态框 */
.image-modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.9);
overflow: auto;
}
.image-modal-content {
margin: auto;
display: block;
width: 80%;
max-width: 800px;
max-height: 80%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.image-modal-close {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.image-modal-close:hover {
color: #bbb;
}
</style> </style>
</head> </head>
<body> <body>
@@ -240,12 +69,18 @@
<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>
<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>
@@ -262,7 +97,6 @@
<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>
@@ -299,7 +133,13 @@
<!-- 图片放大模态框 --> <!-- 图片放大模态框 -->
<div id="imageModal" class="image-modal"> <div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span> <span class="image-modal-close">&times;</span>
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
<img class="image-modal-content" id="expandedImage"> <img class="image-modal-content" id="expandedImage">
<div class="zoom-controls">
<button class="zoom-btn" id="zoomOutBtn">-</button>
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
<button class="zoom-btn" id="zoomInBtn">+</button>
</div>
</div> </div>
<script> <script>
@@ -312,6 +152,7 @@ function getCookie(name) {
// DOM元素引用 // DOM元素引用
const searchQueryInput = document.getElementById('searchQuery'); const searchQueryInput = document.getElementById('searchQuery');
const keyFilterSelect = document.getElementById('keyFilter');
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,12 +168,30 @@ const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const imageModal = document.getElementById('imageModal'); const imageModal = document.getElementById('imageModal');
const expandedImage = document.getElementById('expandedImage'); const expandedImage = document.getElementById('expandedImage');
const imageModalClose = document.querySelector('.image-modal-close'); const imageModalClose = document.querySelector('.image-modal-close');
const zoomInBtn = document.getElementById('zoomInBtn');
const zoomOutBtn = document.getElementById('zoomOutBtn');
const resetZoomBtn = document.getElementById('resetZoomBtn');
const zoomValue = document.getElementById('zoomValue');
// 全局变量 // 全局变量
let currentId = ''; let currentId = '';
let currentWriter = ''; let currentWriter = '';
let currentImage = ''; let currentImage = '';
let allDataCache = []; // 缓存所有数据,避免重复请求 let allDataCache = []; // 缓存所有数据,避免重复请求
let currentSearchQuery = ''; // 记录当前搜索查询
let isFuzzySearch = false; // 记录当前是否为模糊搜索
let isDeleting = false; // 标记是否正在删除
let currentKeyFilter = '';
// 图片缩放相关变量
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) {
@@ -342,6 +201,13 @@ async function performSearch(type) {
return; return;
} }
if (currentKeyFilter) {
currentKeyFilter = '';
if (keyFilterSelect) keyFilterSelect.value = '';
}
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
showSearchLoading(); showSearchLoading();
try { try {
@@ -400,9 +266,21 @@ function showSearchMessage(message, type = '') {
// 加载所有数据 // 加载所有数据
async function loadAllData() { async function loadAllData() {
currentSearchQuery = '';
showSearchLoading(); showSearchLoading();
try { try {
if (currentKeyFilter) {
const response = await fetch(`/elastic/filter-by-key/?key=${encodeURIComponent(currentKeyFilter)}`);
const data = await response.json();
if (data.status === 'success') {
displayAllData(data.data || [], currentKeyFilter);
} else {
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
}
return;
}
// 如果已有缓存,直接使用 // 如果已有缓存,直接使用
if (allDataCache.length > 0) { if (allDataCache.length > 0) {
displayAllData(allDataCache); displayAllData(allDataCache);
@@ -425,10 +303,10 @@ 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 = '显示全部数据'; searchStatus.textContent = key ? `按Key筛查${key}` : '显示全部数据';
searchCount.textContent = `${data.length} 条记录`; searchCount.textContent = `${data.length} 条记录`;
renderTable(data); renderTable(data);
@@ -438,8 +316,13 @@ function displayAllData(data) {
function clearSearch() { function clearSearch() {
searchQueryInput.value = ''; searchQueryInput.value = '';
searchResultDiv.style.display = 'none'; searchResultDiv.style.display = 'none';
currentSearchQuery = '';
if (currentKeyFilter) {
loadAllData();
return;
}
// 如果有缓存数据,显示全部
if (allDataCache.length > 0) { if (allDataCache.length > 0) {
renderTable(allDataCache); renderTable(allDataCache);
} else { } else {
@@ -448,13 +331,41 @@ 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();
}
// 渲染表格 // 渲染表格
function renderTable(data) { function renderTable(data) {
tableBody.innerHTML = ''; tableBody.innerHTML = '';
if (!data || data.length === 0) { if (!data || data.length === 0) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = '<td colspan="5" style="text-align: center; color: #999;">暂无数据</td>'; row.innerHTML = '<td colspan="4" style="text-align: center; color: #999;">暂无数据</td>';
tableBody.appendChild(row); tableBody.appendChild(row);
return; return;
} }
@@ -467,22 +378,51 @@ 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='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'} <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;">${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>
@@ -492,6 +432,12 @@ function renderTable(data) {
}); });
} }
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
@@ -545,16 +491,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{
@@ -644,15 +598,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();
} }
@@ -662,8 +610,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',
@@ -675,25 +635,38 @@ async function doDelete(id){
if(data.status!=='success') throw new Error(data.message || '删除失败'); if(data.status!=='success') throw new Error(data.message || '删除失败');
alert('删除成功'); alert('删除成功');
// 重新加载数据 // 清空缓存,确保下次加载获取最新数据
if (searchResultDiv.style.display !== 'none') { allDataCache = [];
const query = searchQueryInput.value.trim();
if (query) { // 根据当前显示状态重新加载数据
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊'); if (currentSearchQuery) {
performSearch(isFuzzy ? 'fuzzy' : 'exact'); // 如果当前显示的是搜索结果,重新执行搜索
} else { performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
loadAllData();
}
} else { } else {
loadAllData(); // 修复:重新加载所有数据时,强制刷新缓存
const response = await fetch('/elastic/all-data/');
const data = await response.json();
if (data.status === 'success') {
allDataCache = data.data || [];
displayAllData(allDataCache);
} else {
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
}
} }
} catch (e) { } catch (e) {
alert(e.message||'删除失败'); alert(e.message||'删除失败');
} finally {
isDeleting = false;
if (deleteButton) {
deleteButton.disabled = false;
deleteButton.textContent = '删除';
}
} }
} }
// 页面加载时自动加载所有数据 // 页面加载时自动加载所有数据
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initKeyFilter();
loadAllData(); loadAllData();
}); });
@@ -724,6 +697,30 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
} }
}); });
// 图片缩放功能
function updateZoom() {
expandedImage.style.transform = `translate(-50%, -50%) scale(${currentScale}) translate(${currentX}px, ${currentY}px)`;
zoomValue.textContent = `${Math.round(currentScale * 100)}%`;
}
function resetZoom() {
currentScale = 1;
currentX = 0;
currentY = 0;
updateZoom();
}
function zoomIn() {
currentScale *= 1.2;
updateZoom();
}
function zoomOut() {
currentScale /= 1.2;
if (currentScale < 0.1) currentScale = 0.1; // 最小缩放限制
updateZoom();
}
// 图片放大功能 // 图片放大功能
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// 为所有图片添加点击事件监听器 // 为所有图片添加点击事件监听器
@@ -732,6 +729,9 @@ document.addEventListener('DOMContentLoaded', function() {
const imgSrc = e.target.src; const imgSrc = e.target.src;
expandedImage.src = imgSrc; expandedImage.src = imgSrc;
imageModal.style.display = 'block'; imageModal.style.display = 'block';
// 重置缩放状态
resetZoom();
} }
}); });
@@ -746,6 +746,91 @@ document.addEventListener('DOMContentLoaded', function() {
imageModal.style.display = 'none'; imageModal.style.display = 'none';
} }
} }
// 缩放按钮事件
zoomInBtn.addEventListener('click', zoomIn);
zoomOutBtn.addEventListener('click', zoomOut);
resetZoomBtn.addEventListener('click', resetZoom);
// 鼠标滚轮缩放
expandedImage.addEventListener('wheel', function(e) {
e.preventDefault();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
});
// 拖拽功能
expandedImage.addEventListener('mousedown', function(e) {
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
imgStartX = currentX;
imgStartY = currentY;
expandedImage.classList.add('dragging');
});
document.addEventListener('mousemove', function(e) {
if (isDragging) {
const deltaX = e.clientX - dragStartX;
const deltaY = e.clientY - dragStartY;
currentX = imgStartX + deltaX / currentScale;
currentY = imgStartY + deltaY / currentScale;
updateZoom();
}
});
document.addEventListener('mouseup', function() {
isDragging = false;
expandedImage.classList.remove('dragging');
});
// 触摸事件支持(移动端)
expandedImage.addEventListener('touchstart', function(e) {
if (e.touches.length === 1) {
isDragging = true;
dragStartX = e.touches[0].clientX;
dragStartY = e.touches[0].clientY;
imgStartX = currentX;
imgStartY = currentY;
} else if (e.touches.length === 2) {
// 双指缩放
initialDistance = getDistance(e.touches[0], e.touches[1]);
initialScale = currentScale;
}
});
document.addEventListener('touchmove', function(e) {
e.preventDefault();
if (isDragging && e.touches.length === 1) {
const deltaX = e.touches[0].clientX - dragStartX;
const deltaY = e.touches[0].clientY - dragStartY;
currentX = imgStartX + deltaX / currentScale;
currentY = imgStartY + deltaY / currentScale;
updateZoom();
} else if (e.touches.length === 2) {
// 双指缩放
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const scale = (currentDistance / initialDistance) * initialScale;
currentScale = Math.max(0.1, Math.min(scale, 10)); // 限制缩放范围
updateZoom();
}
});
document.addEventListener('touchend', function() {
isDragging = false;
});
// 计算两点间距离
function getDistance(touch1, touch2) {
return Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
}
}); });
</script> </script>
</body> </body>

View File

@@ -19,13 +19,35 @@
.btn { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; margin:0 4px; } .btn { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; margin:0 4px; }
.btn-primary { background:#4f46e5; color:#fff; } .btn-primary { background:#4f46e5; color:#fff; }
.btn-secondary { background:#64748b; 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 { padding:10px; border-radius:6px; margin-top:10px; display:none; }
.notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; } .notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; } .notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
.code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; } .code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); display:flex; align-items:center; justify-content:center; z-index:2000; }
.spinner { width:42px; height:42px; border:4px solid #cbd5e1; border-top-color:#4f46e5; border-radius:50%; animation:spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeUp 0.25s ease-out; }
@keyframes fadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
table tr:hover { background-color:#f3f4f6; transition: background-color 0.2s ease; }
.btn { transition: transform 0.1s ease, box-shadow 0.2s ease; }
.btn:hover { transform: translateY(-1px); box-shadow:0 6px 16px rgba(31,35,40,0.12); }
</style> </style>
{% csrf_token %} {% csrf_token %}
<script> <script>
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();} function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
async function loadKeys(){ async function loadKeys(){
const resp=await fetch('/elastic/registration-codes/keys/'); const resp=await fetch('/elastic/registration-codes/keys/');
@@ -36,8 +58,17 @@
keySel.innerHTML=''; mkeySel.innerHTML=''; keySel.innerHTML=''; mkeySel.innerHTML='';
opts.forEach(k=>{ opts.forEach(k=>{
const o=document.createElement('option'); o.value=k; o.textContent=k; keySel.appendChild(o); const o=document.createElement('option'); o.value=k; o.textContent=k; keySel.appendChild(o);
const o2=document.createElement('option'); o2.value=k; o2.textContent=k; mkeySel.appendChild(o2); 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(){ async function addKey(){
const keyName=(document.getElementById('newKey').value||'').trim(); const keyName=(document.getElementById('newKey').value||'').trim();
@@ -46,16 +77,104 @@
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 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 data=await resp.json();
const msg=document.getElementById('msg'); const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){msg.textContent='新增key成功'; msg.className='notice success'; msg.style.display='block'; document.getElementById('newKey').value=''; loadKeys();} 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';} else{msg.textContent=data.message||'新增失败'; msg.className='notice error'; msg.style.display='block';}
} }
function selectedValues(sel){return Array.from(sel.selectedOptions).map(o=>o.value);} async function deleteSelectedKey(){
function enableToggleSelect(sel){ sel.addEventListener('mousedown',function(e){ if(e.target && e.target.tagName==='OPTION'){ e.preventDefault(); const op=e.target; op.selected=!op.selected; this.dispatchEvent(new Event('change',{bubbles:true})); } }); } const keySel = document.getElementById('keys');
function clearSelection(id){ const sel=document.getElementById(id); Array.from(sel.options).forEach(o=>o.selected=false); } const mkeySel = document.getElementById('manageKeys');
async function generateCode(){
// 优先获取左侧选中的,如果没有则获取右侧选中的
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 csrftoken=getCookie('csrftoken');
const keys=selectedValues(document.getElementById('keys')); const url = '/elastic/registration-codes/keys/remove/';
const manageKeys=selectedValues(document.getElementById('manageKeys')); 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; 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);} 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 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})});
@@ -64,11 +183,40 @@
const msg=document.getElementById('msg'); const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){out.textContent=data.data.code; msg.textContent='生成成功'; msg.className='notice success'; msg.style.display='block';} if(resp.ok && data.status==='success'){out.textContent=data.data.code; msg.textContent='生成成功'; msg.className='notice success'; msg.style.display='block';}
else{msg.textContent=data.message||'生成失败'; msg.className='notice error'; msg.style.display='block';} else{msg.textContent=data.message||'生成失败'; msg.className='notice error'; msg.style.display='block';}
ov.style.display='none';
} }
document.addEventListener('DOMContentLoaded',()=>{loadKeys(); enableToggleSelect(document.getElementById('keys')); enableToggleSelect(document.getElementById('manageKeys'));}); async function loadCodes(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const resp=await fetch('/elastic/registration-codes/list/');
const data=await resp.json();
const tbody=document.getElementById('codesBody');
if(!tbody) return;
tbody.innerHTML='';
if(resp.ok && data.status==='success'){
(data.data||[]).forEach(it=>{
const tr=document.createElement('tr');
const status = it.active? '有效' : '失效';
const ka = Array.isArray(it.keys)? it.keys.join('、') : '';
const mka = Array.isArray(it.manage_keys)? it.manage_keys.join('、') : '';
tr.innerHTML = `<td>${it.code||''}</td><td>${ka}</td><td>${mka}</td><td>${formatDate(it.created_at)}</td><td>${formatDate(it.expires_at)}</td><td>${status}</td><td>${it.active? '<button class=\"btn btn-secondary\" data-code=\"'+it.code+'\">作废</button>':''}</td>`;
tbody.appendChild(tr);
});
}
ov.style.display='none';
}
function formatDate(t){ if(!t) return ''; try{ const d = new Date(t); if(String(d)!='Invalid Date'){ const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;} }catch(e){} return ''; }
async function revokeCode(code){ const csrftoken=getCookie('csrftoken'); const resp=await fetch('/elastic/registration-codes/revoke/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code})}); const msg=document.getElementById('msg'); const data=await resp.json(); if(resp.ok && data.status==='success'){ msg.textContent='已作废'; msg.className='notice success'; msg.style.display='block'; loadCodes(); } else { msg.textContent=data.message||'作废失败'; msg.className='notice error'; msg.style.display='block'; } }
document.addEventListener('click',function(e){ const btn=e.target; if(btn && btn.matches('button[data-code]')){ revokeCode(btn.getAttribute('data-code')); }});
document.addEventListener('DOMContentLoaded',()=>{
loadKeys();
enableToggleSelect(document.getElementById('keys'));
enableToggleSelect(document.getElementById('manageKeys'));
loadCodes();
});
</script> </script>
</head> </head>
<body> <body>
<div id="overlay" class="overlay" style="display:none"><div class="spinner"></div></div>
<div class="sidebar"> <div class="sidebar">
<h3>你好,{{ username|default:"访客" }}</h3> <h3>你好,{{ username|default:"访客" }}</h3>
<div class="navigation-links"> <div class="navigation-links">
@@ -79,25 +227,34 @@
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<div class="card"> <div class="card fade-in">
<h2>管理注册码</h2> <h2>管理注册码</h2>
{% if is_admin or has_manage_key or can_manage_registration_codes %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label>新增key</label> <label>管理 Key</label>
<input id="newKey" type="text" placeholder="输入新的key" /> <div style="display:flex; gap:8px;">
<button class="btn btn-secondary" onclick="addKey()">新增</button> <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>
</div> </div>
{% endif %}
<div class="row" style="margin-top:12px;"> <div class="row" style="margin-top:12px;">
<div class="col"> <div class="col">
<label>选择keys</label> <label>选择 keys</label>
<select id="keys" multiple size="10"></select> <select id="keys" multiple size="10"></select>
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('keys')">清空选择</button></div> <div style="margin-top:8px;"><button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('keys')">清空 keys 选择</button></div>
</div> </div>
<div class="col"> <div class="col">
<label>选择manage_keys</label> <label>选择 manage_keys</label>
<select id="manageKeys" multiple size="10"></select> <select id="manageKeys" multiple size="10"></select>
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('manageKeys')">清空选择</button></div> <div style="margin-top:8px;">
<button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('manageKeys')">清空 manage_keys 选择</button>
</div>
</div> </div>
</div> </div>
<div class="row" style="margin-top:12px;"> <div class="row" style="margin-top:12px;">
@@ -116,6 +273,30 @@
</div> </div>
<div id="msg" class="notice"></div> <div id="msg" class="notice"></div>
<div class="code-box" id="codeOut"></div> <div class="code-box" id="codeOut"></div>
<div class="row" style="margin-top:12px;">
<div class="col">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3>已生成的注册码</h3>
<div>
<button class="btn btn-secondary" onclick="loadCodes()">刷新列表</button>
</div>
</div>
<table style="width:100%; border-collapse:collapse; margin-top:10px;">
<thead>
<tr>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">code</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">manage_keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">创建时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">过期时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">状态</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">操作</th>
</tr>
</thead>
<tbody id="codesBody"></tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
<script> <script>

View File

@@ -42,18 +42,38 @@
.preview-box {flex: 1; text-align: center; } .preview-box {flex: 1; text-align: center; }
.preview-box h3 {margin-top: 0;color: #334155; } .preview-box h3 {margin-top: 0;color: #334155; }
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;} .preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
.preview-list {display: grid;grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));gap: 12px; margin-top: 20px;}
.preview-item {position: relative;}
.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 {flex: 1;}
.result-box h3 { margin-top: 0; color: #334155;} .result-box h3 { margin-top: 0; color: #334155;}
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;} .form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;} .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); }
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; } .pending-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; padding-bottom: 12px; }
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;} .pending-item-title { font-weight: 600; color: #1e293b; font-size: 16px; }
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0; .pending-item-body { display: flex; gap: 20px; }
border-radius: 8px; resize: vertical;box-sizing: border-box; } .pending-item-preview { flex: 0 0 240px; }
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; } .pending-item-preview img { width: 100%; border-radius: 8px; border: 1px solid #f1f5f9; }
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .pending-item-edit { flex: 1; }
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; } .pending-item-footer { margin-top: 16px; text-align: right; }
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; } @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;}
.kv-form-container {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 400px; overflow: auto; margin-bottom: 12px; background: #f8fafc;}
.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;}
.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; }
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
.progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
.progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
.progress-wrap {display:none; margin-top: 8px;}
.progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
</style> </style>
</head> </head>
<body> <body>
@@ -75,37 +95,35 @@
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<div> <div>
<h2>图片上传识别</h2> <h2>图片与PDF上传识别</h2>
<p>选择图片后上传,服务端调用大模型解析为可编辑的 JSON再确认入库。</p> <p>选择图片或PDF文件后上传,服务端调用大模型解析为可编辑的 JSON再确认入库。</p>
</div> </div>
</div> </div>
<div class="upload-section" id="dropArea"> <div class="upload-section" id="dropArea">
<h3>上传图片</h3> <h3>上传文件</h3>
<p>点击下方按钮选择图片,或拖拽图片到此区域</p> <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/*" required /> <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> <br>
<button type="submit" class="btn btn-primary">上传并识别</button> <button type="submit" class="btn btn-primary">上传并识别</button>
</form> </form>
<div class="status-message" id="uploadMsg"></div> <div class="status-message" id="uploadMsg"></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
<div class="progress-text" id="progressText"></div>
</div>
</div> </div>
<div class="preview-container"> <div class="preview-container">
<div class="preview-box">
<h3>图片预览</h3>
<img id="preview" alt="预览" />
</div>
<div class="result-box"> <div class="result-box">
<h3>识别结果(可编辑)</h3> <h3>待处理文件列表</h3>
<div class="form-controls"> <div id="pendingItems" class="pending-list">
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button> <!-- 这里将动态生成每个文件的预览和编辑区域 -->
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
</div> </div>
<div id="kvForm"></div>
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
</div> </div>
</div> </div>
@@ -126,18 +144,59 @@ 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 addFieldBtn = document.getElementById('addFieldBtn');
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const dropArea = document.getElementById('dropArea'); const dropArea = document.getElementById('dropArea');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let currentImageRel = ''; let currentItems = []; // 存储当前待处理的所有文件结果
let selectedFiles = [];
function setProgress(p, text){
const v = Math.max(0, Math.min(100, Math.round(p||0)));
progressBar.style.width = v + '%';
progressText.textContent = (text||'') + (text? ' ' : '') + v + '%';
}
function showProgress(){
progressWrap.style.display = 'block';
}
function hideProgress(){
progressWrap.style.display = 'none';
setProgress(0, '');
}
async function convertToJpeg(file){
const url = URL.createObjectURL(file);
let img;
try{
const blob = await fetch(url).then(r=>r.blob());
img = await createImageBitmap(blob);
}catch(e){
img = await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=url;});
}
URL.revokeObjectURL(url);
const maxDim = 2000;
const w = img.width;
const h = img.height;
const scale = Math.min(1, maxDim/Math.max(w,h));
const nw = Math.round(w*scale);
const nh = Math.round(h*scale);
const canvas = document.createElement('canvas');
canvas.width = nw;
canvas.height = nh;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, nw, nh);
const blob = await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.82));
const name = (file.name||'image').replace(/\.[^/.]+$/, '') + '.jpg';
return new File([blob], name, {type:'image/jpeg'});
}
// 拖拽上传功能 // 拖拽上传功能
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
@@ -171,25 +230,77 @@ function handleDrop(e) {
const dt = e.dataTransfer; const dt = e.dataTransfer;
const files = dt.files; const files = dt.files;
if (files.length) { if (files.length) {
fileInput.files = files; addFiles(files);
const event = new Event('change', { bubbles: true });
fileInput.dispatchEvent(event);
} }
} }
// 文件选择后预览 function setPreviewList(urls) {
fileInput.addEventListener('change', function(e) { previewList.innerHTML = '';
const file = e.target.files[0]; (urls || []).forEach((url, index) => {
if (file && file.type.startsWith('image/')) { if (!url) return;
const reader = new FileReader(); const item = document.createElement('div');
reader.onload = function(e) { item.className = 'preview-item';
preview.src = e.target.result; 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);
}
}; };
reader.readAsDataURL(file); 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 createRow(k = '', v = '') { function createKvRow(k = '', v = '', onInput) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'form-row'; row.className = 'form-row';
const keyInput = document.createElement('input'); const keyInput = document.createElement('input');
@@ -204,123 +315,213 @@ function createRow(k = '', v = '') {
delBtn.type = 'button'; delBtn.type = 'button';
delBtn.className = 'btn btn-danger'; delBtn.className = 'btn btn-danger';
delBtn.textContent = '删除'; delBtn.textContent = '删除';
delBtn.onclick = () => { delBtn.onclick = () => {
if (kvForm.children.length > 1) { const container = row.parentElement;
kvForm.removeChild(row); if (container.querySelectorAll('.form-row').length > 1) {
container.removeChild(row);
} else { } else {
keyInput.value = ''; keyInput.value = '';
valInput.value = ''; valInput.value = '';
} }
syncTextarea(); if (onInput) onInput();
}; };
keyInput.oninput = syncTextarea;
valInput.oninput = syncTextarea; 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 obj = {}; const itemEl = document.createElement('div');
Array.from(kvForm.children).forEach(row => { itemEl.className = 'pending-item';
const [kInput, vInput] = row.querySelectorAll('input');
const k = (kInput.value || '').trim(); const header = document.createElement('div');
if (!k) return; header.className = 'pending-item-header';
const raw = vInput.value; header.innerHTML = `<span class="pending-item-title">${index + 1}. ${item.name}</span>`;
try {
obj[k] = JSON.parse(raw); const removeBtn = document.createElement('button');
} catch (_) { removeBtn.className = 'btn btn-danger';
obj[k] = raw; 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 = {};
kvForm.querySelectorAll('.form-row').forEach(row => {
const inputs = row.querySelectorAll('input');
const k = inputs[0].value.trim();
if (!k) return;
try { obj[k] = JSON.parse(inputs[1].value); } catch(e) { obj[k] = inputs[1].value; }
});
item.data = obj;
textarea.value = JSON.stringify(obj, null, 2);
};
Object.entries(item.data).forEach(([k, v]) => {
kvForm.appendChild(createKvRow(k, v, syncData));
});
if (kvForm.querySelectorAll('.form-row').length === 0) {
kvForm.appendChild(createKvRow('', '', syncData));
}
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);
}); });
return obj;
confirmBtn.disabled = items.length === 0;
} }
function syncTextarea() {
const obj = objectFromForm();
resultBox.value = JSON.stringify(obj, null, 2);
}
addFieldBtn.addEventListener('click', () => {
kvForm.appendChild(createRow());
syncTextarea();
});
syncFromTextBtn.addEventListener('click', () => {
try {
const obj = JSON.parse(resultBox.value || '{}');
renderFormFromObject(obj);
uploadMsg.textContent = '已从文本区刷新表单';
uploadMsg.className = 'status-message success';
uploadMsg.style.display = 'block';
setTimeout(() => {
uploadMsg.style.display = 'none';
}, 2000);
} catch (e) {
uploadMsg.textContent = '文本区不是有效JSON';
uploadMsg.className = 'status-message error';
uploadMsg.style.display = 'block';
}
});
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 = 'status-message error';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
return; return;
} }
showProgress();
setProgress(5, '预处理中');
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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);
}
} 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 data = await resp.json(); const data = await resp.json();
if (!resp.ok || data.status !== 'success') { if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '上传识别失败'); throw new Error(data.message || '上传识别失败');
} }
setProgress(100, '识别完成');
uploadMsg.textContent = data.message || '识别成功'; uploadMsg.textContent = data.message || '识别成功';
uploadMsg.className = 'status-message success'; uploadMsg.className = 'status-message success';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
preview.src = data.image_url;
renderFormFromObject(data.data || {}); renderPendingItems(data.items || []);
currentImageRel = data.image; setTimeout(hideProgress, 800);
confirmBtn.disabled = false;
} catch (e) { } catch (e) {
uploadMsg.textContent = e.message || '发生错误'; uploadMsg.textContent = e.message || '发生错误';
uploadMsg.className = 'status-message error'; uploadMsg.className = 'status-message error';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
progressText.textContent = '识别失败';
} }
}); });
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',
@@ -328,7 +529,7 @@ 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') {
@@ -336,6 +537,12 @@ confirmBtn.addEventListener('click', async () => {
} }
confirmMsg.textContent = data.message || '录入成功'; confirmMsg.textContent = data.message || '录入成功';
confirmMsg.style.color = '#179957'; 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.style.color = '#d14343'; confirmMsg.style.color = '#d14343';
@@ -344,15 +551,18 @@ confirmBtn.addEventListener('click', async () => {
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
fileInput.value = ''; fileInput.value = '';
preview.src = ''; previewList.innerHTML = '';
resultBox.value = ''; pendingItems.innerHTML = '';
kvForm.innerHTML = '';
kvForm.appendChild(createRow()); // 保留一个空行
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');

View File

@@ -134,6 +134,13 @@
border-radius: 6px; border-radius: 6px;
} }
.search-container select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
}
.search-container button { .search-container button {
padding: 8px 15px; padding: 8px 15px;
background: #4f46e5; background: #4f46e5;
@@ -156,7 +163,7 @@
.modal-content { .modal-content {
background-color: white; background-color: white;
margin: 10% auto; margin: 6% auto;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
width: 80%; width: 80%;
@@ -200,6 +207,71 @@
margin-top: 5px; margin-top: 5px;
text-align: center; text-align: center;
} }
.keys-box {
max-height: 140px;
overflow: auto;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 10px;
background: #fff;
}
.key-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 14px;
color: #111827;
user-select: none;
}
.key-item input[type="checkbox"] {
width: auto;
padding: 0;
margin: 0;
}
.key-edit-row {
display: flex;
gap: 10px;
align-items: center;
}
.selected-keys {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.key-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: #eef2ff;
color: #1f2937;
border: 1px solid #c7d2fe;
font-size: 13px;
}
.key-tag button {
border: none;
background: transparent;
cursor: pointer;
color: #4b5563;
font-size: 14px;
line-height: 1;
}
.key-tag.locked {
background: #f3f4f6;
border: 1px solid #e5e7eb;
color: #374151;
}
</style> </style>
</head> </head>
<body> <body>
@@ -216,25 +288,56 @@
</div> </div>
</div> </div>
<!-- 主内容区域 -->
<div class="main-content"> <div class="main-content">
{% if is_student %}
<div class="card">
<div class="header"><h2>修改密码</h2></div>
<form id="selfPwdForm">
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
<div class="form-group">
<label for="password">新密码</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" name="confirmPassword" required>
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
{% else %}
{% if is_tutor %}
<div class="card">
<div class="header"><h2>修改本人密码</h2></div>
<form id="selfPwdForm">
<input type="hidden" id="selfUserId" name="user_id" value="{{ user_id }}">
<div class="form-group">
<label for="password">新密码</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" name="confirmPassword" required>
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
{% endif %}
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h2>用户管理</h2> <h2>用户管理</h2>
<button id="addUserBtn" class="btn btn-primary">添加用户</button> {% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
</div> </div>
<div class="notification success" id="successNotification"> <div class="notification success" id="successNotification">操作成功!</div>
操作成功! <div class="notification error" id="errorNotification">操作失败!</div>
</div>
<div class="notification error" id="errorNotification">
操作失败!
</div>
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="搜索用户名..."> <input type="text" id="searchInput" placeholder="搜索用户名...">
<select id="keyFilter"></select>
<button id="searchBtn">搜索</button> <button id="searchBtn">搜索</button>
<button id="resetBtn">重置</button> <button id="resetBtn">重置</button>
<button id="clearKeyBtn">清空Key</button>
</div> </div>
<div class="table-container"> <div class="table-container">
@@ -243,16 +346,17 @@
<tr> <tr>
<th>用户ID</th> <th>用户ID</th>
<th>用户名</th> <th>用户名</th>
<th>Key</th>
<th>Manage Key</th>
<th>权限</th> <th>权限</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="usersTableBody"> <tbody id="usersTableBody"></tbody>
<!-- 用户数据将通过JavaScript加载 -->
</tbody>
</table> </table>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<!-- 添加/编辑用户模态框 --> <!-- 添加/编辑用户模态框 -->
@@ -267,7 +371,7 @@
<label for="username">用户名</label> <label for="username">用户名</label>
<input type="text" id="username" name="username" required> <input type="text" id="username" name="username" required>
</div> </div>
<div class="form-group"> <div class="form-group" id="permissionGroup">
<label for="permission">权限</label> <label for="permission">权限</label>
<select id="permission" name="permission" required> <select id="permission" name="permission" required>
<option value="0">管理员</option> <option value="0">管理员</option>
@@ -275,6 +379,28 @@
</select> </select>
</div> </div>
</div> </div>
<div class="form-group">
<label>Key从已有 Key 中选择)</label>
<div class="key-edit-row">
<select id="userKeySelect"></select>
<button type="button" id="addUserKeyBtn" class="btn btn-primary">添加</button>
<button type="button" id="clearUserKeyBtn" class="btn">清空</button>
</div>
<div id="userKeysSelected" class="selected-keys"></div>
<div id="userKeysReadonlyGroup" style="display:none; margin-top: 10px;">
<div style="font-weight: 600; color: #374151; font-size: 13px; margin-bottom: 6px;">导师Key不可修改</div>
<div id="userKeysReadonly" class="selected-keys"></div>
</div>
</div>
<div class="form-group" id="manageKeyGroup">
<label>Manage Key从已有 Key 中选择)</label>
<div class="key-edit-row">
<select id="userManageKeySelect"></select>
<button type="button" id="addUserManageKeyBtn" class="btn btn-primary">添加</button>
<button type="button" id="clearUserManageKeyBtn" class="btn">清空</button>
</div>
<div id="userManageKeysSelected" class="selected-keys"></div>
</div>
<div class="form-group"> <div class="form-group">
<label for="password">密码</label> <label for="password">密码</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
@@ -301,6 +427,14 @@
</div> </div>
<script> <script>
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
const IS_TUTOR = {{ is_tutor|yesno:"true,false" }};
const MY_MANAGE_KEYS_RAW = JSON.parse('{{ manage_keys_json|default:"[]"|escapejs }}');
const MY_KEYS_RAW = JSON.parse('{{ my_keys_json|default:"[]"|escapejs }}');
let KEY_OPTIONS_CACHE = null;
let MODAL_SELECTED_KEYS = [];
let MODAL_SELECTED_MANAGE_KEYS = [];
// 获取CSRF令牌的函数 // 获取CSRF令牌的函数
function getCookie(name) { function getCookie(name) {
const value = `; ${document.cookie}`; const value = `; ${document.cookie}`;
@@ -336,11 +470,12 @@
} }
// 获取所有用户 // 获取所有用户
async function loadUsers(searchTerm = '') { async function loadUsers(searchTerm = '', key = '') {
try { try {
const url = searchTerm ? const params = new URLSearchParams();
`/elastic/users/?search=${encodeURIComponent(searchTerm)}` : if ((searchTerm || '').trim()) params.set('search', (searchTerm || '').trim());
'/elastic/users/'; if ((key || '').trim()) params.set('key', (key || '').trim());
const url = params.toString() ? `/elastic/users/?${params.toString()}` : '/elastic/users/';
const response = await fetch(url); const response = await fetch(url);
const result = await response.json(); const result = await response.json();
@@ -357,10 +492,16 @@
// 根据权限值显示权限名称 // 根据权限值显示权限名称
const permissionText = Number(user.permission) === 0 ? '管理员' : '普通用户'; const permissionText = Number(user.permission) === 0 ? '管理员' : '普通用户';
const keys = Array.isArray(user.key) ? user.key : (user.key ? [user.key] : []);
const keysText = keys.map(k => String(k || '').trim()).filter(Boolean).join('、') || '-';
const manageKeys = Array.isArray(user.manage_key) ? user.manage_key : (user.manage_key ? [user.manage_key] : []);
const manageKeysText = manageKeys.map(k => String(k || '').trim()).filter(Boolean).join('、') || '-';
row.innerHTML = ` row.innerHTML = `
<td>${user.user_id}</td> <td>${user.user_id}</td>
<td>${user.username}</td> <td>${user.username}</td>
<td>${keysText}</td>
<td>${manageKeysText}</td>
<td>${permissionText}</td> <td>${permissionText}</td>
<td class="action-buttons"> <td class="action-buttons">
<button class="btn btn-success edit-btn" data-user='${JSON.stringify(user)}'>编辑</button> <button class="btn btn-success edit-btn" data-user='${JSON.stringify(user)}'>编辑</button>
@@ -379,22 +520,225 @@
} }
} }
async function initKeyFilter() {
const select = document.getElementById('keyFilter');
if (!select) return;
select.innerHTML = '<option value="">全部Key</option>';
try {
const keys = await fetchKeyOptions();
keys.forEach(k => {
const opt = document.createElement('option');
opt.value = String(k || '').trim();
opt.textContent = String(k || '').trim();
if (opt.value) select.appendChild(opt);
});
} catch (e) {
}
select.addEventListener('change', () => {
const searchTerm = document.getElementById('searchInput').value;
loadUsers(searchTerm, select.value);
});
}
function normalizeStr(v) {
return String(v || '').trim();
}
const MY_MANAGE_KEYS = (Array.isArray(MY_MANAGE_KEYS_RAW) ? MY_MANAGE_KEYS_RAW : [])
.map(normalizeStr)
.filter(Boolean);
const MY_MANAGE_KEYS_SET = new Set(MY_MANAGE_KEYS);
const MY_KEYS = (Array.isArray(MY_KEYS_RAW) ? MY_KEYS_RAW : [])
.map(normalizeStr)
.filter(Boolean);
const MY_KEYS_SET = new Set(MY_KEYS);
async function fetchKeyOptions() {
if (Array.isArray(KEY_OPTIONS_CACHE)) return KEY_OPTIONS_CACHE;
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 || []).map(normalizeStr).filter(Boolean);
KEY_OPTIONS_CACHE = keys;
return keys;
} catch (e) {
return [];
}
}
function setSelectOptions(selectId, options) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = '<option value="">请选择Key</option>';
(options || []).forEach(k => {
const s = normalizeStr(k);
if (!s) return;
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
select.appendChild(opt);
});
}
function setSelectOptionsMixed(selectId, enabledOptions, disabledOptions) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = '<option value="">请选择Key</option>';
(enabledOptions || []).forEach(k => {
const s = normalizeStr(k);
if (!s) return;
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
select.appendChild(opt);
});
(disabledOptions || []).forEach(k => {
const s = normalizeStr(k);
if (!s) return;
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
opt.disabled = true;
select.appendChild(opt);
});
}
function renderSelectedTags(containerId, selectedArr) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
(selectedArr || []).forEach(k => {
const tag = document.createElement('span');
tag.className = 'key-tag';
const text = document.createElement('span');
text.textContent = k;
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '×';
btn.addEventListener('click', () => {
const idx = selectedArr.indexOf(k);
if (idx >= 0) selectedArr.splice(idx, 1);
renderSelectedTags(containerId, selectedArr);
});
tag.appendChild(text);
tag.appendChild(btn);
container.appendChild(tag);
});
}
function renderReadonlyTags(containerId, keysArr) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
(keysArr || []).forEach(k => {
const tag = document.createElement('span');
tag.className = 'key-tag locked';
const text = document.createElement('span');
text.textContent = k;
tag.appendChild(text);
container.appendChild(tag);
});
}
function setReadonlyKeysVisible(visible) {
const group = document.getElementById('userKeysReadonlyGroup');
if (group) group.style.display = visible ? '' : 'none';
}
function setKeyEditorDisabled(prefix, disabled) {
const select = document.getElementById(prefix + 'Select');
const addBtn = document.getElementById('add' + prefix.charAt(0).toUpperCase() + prefix.slice(1) + 'Btn');
const clearBtn = document.getElementById('clear' + prefix.charAt(0).toUpperCase() + prefix.slice(1) + 'Btn');
if (select) select.disabled = !!disabled;
if (addBtn) addBtn.disabled = !!disabled;
if (clearBtn) clearBtn.disabled = !!disabled;
}
function addFromSelect(selectId, selectedArr, renderId) {
const select = document.getElementById(selectId);
if (!select) return;
const v = normalizeStr(select.value);
if (!v) return;
if (!selectedArr.includes(v)) selectedArr.push(v);
renderSelectedTags(renderId, selectedArr);
}
function clearSelected(selectedArr, renderId) {
selectedArr.length = 0;
renderSelectedTags(renderId, selectedArr);
}
// 打开添加用户模态框 // 打开添加用户模态框
function openAddModal() { async function openAddModal() {
document.getElementById('modalTitle').textContent = '添加用户'; document.getElementById('modalTitle').textContent = '添加用户';
document.getElementById('userForm').reset(); document.getElementById('userForm').reset();
document.getElementById('userId').value = ''; document.getElementById('userId').value = '';
document.getElementById('username').disabled = false;
document.getElementById('permission').disabled = false;
document.getElementById('permissionGroup').style.display = '';
document.getElementById('manageKeyGroup').style.display = '';
const options = await fetchKeyOptions();
if ((!IS_ADMIN) && IS_TUTOR) {
const enabled = (options || []).map(normalizeStr).filter(k => k && !MY_KEYS_SET.has(k));
setSelectOptionsMixed('userKeySelect', enabled, MY_KEYS);
} else {
setSelectOptions('userKeySelect', options);
}
setSelectOptions('userManageKeySelect', options);
MODAL_SELECTED_KEYS = [];
MODAL_SELECTED_MANAGE_KEYS = [];
renderSelectedTags('userKeysSelected', MODAL_SELECTED_KEYS);
renderSelectedTags('userManageKeysSelected', MODAL_SELECTED_MANAGE_KEYS);
setReadonlyKeysVisible(false);
renderReadonlyTags('userKeysReadonly', []);
setKeyEditorDisabled('userKey', false);
setKeyEditorDisabled('userManageKey', false);
document.getElementById('password').required = true; document.getElementById('password').required = true;
document.getElementById('confirmPassword').required = true; document.getElementById('confirmPassword').required = true;
document.getElementById('userModal').style.display = 'block'; document.getElementById('userModal').style.display = 'block';
} }
// 打开编辑用户模态框 // 打开编辑用户模态框
function openEditModal(user) { async function openEditModal(user) {
document.getElementById('modalTitle').textContent = '编辑用户'; document.getElementById('modalTitle').textContent = '编辑用户';
document.getElementById('username').value = user.username; document.getElementById('username').value = user.username;
document.getElementById('userId').value = user.user_id; document.getElementById('userId').value = user.user_id;
document.getElementById('permission').value = user.permission; document.getElementById('permission').value = user.permission;
const options = await fetchKeyOptions();
setSelectOptions('userManageKeySelect', options);
const allUserKeys = (Array.isArray(user.key) ? user.key : (user.key ? [user.key] : [])).map(normalizeStr).filter(Boolean);
const lockedKeys = allUserKeys.filter(k => MY_KEYS_SET.has(k));
if ((!IS_ADMIN) && IS_TUTOR) {
const enabled = (options || []).map(normalizeStr).filter(k => k && !MY_KEYS_SET.has(k));
setSelectOptionsMixed('userKeySelect', enabled, MY_KEYS);
} else {
setSelectOptions('userKeySelect', options);
}
MODAL_SELECTED_KEYS = IS_ADMIN ? allUserKeys : allUserKeys.filter(k => !MY_KEYS_SET.has(k));
MODAL_SELECTED_MANAGE_KEYS = (Array.isArray(user.manage_key) ? user.manage_key : (user.manage_key ? [user.manage_key] : [])).map(normalizeStr).filter(Boolean);
MODAL_SELECTED_KEYS = Array.from(new Set(MODAL_SELECTED_KEYS));
MODAL_SELECTED_MANAGE_KEYS = Array.from(new Set(MODAL_SELECTED_MANAGE_KEYS));
renderSelectedTags('userKeysSelected', MODAL_SELECTED_KEYS);
renderSelectedTags('userManageKeysSelected', MODAL_SELECTED_MANAGE_KEYS);
setReadonlyKeysVisible((!IS_ADMIN) && IS_TUTOR && lockedKeys.length > 0);
renderReadonlyTags('userKeysReadonly', ((!IS_ADMIN) && IS_TUTOR) ? Array.from(new Set(lockedKeys)) : []);
if (IS_ADMIN) {
document.getElementById('username').disabled = false;
document.getElementById('permission').disabled = false;
document.getElementById('permissionGroup').style.display = '';
document.getElementById('manageKeyGroup').style.display = '';
setKeyEditorDisabled('userKey', false);
setKeyEditorDisabled('userManageKey', false);
} else {
document.getElementById('username').disabled = true;
document.getElementById('permission').disabled = true;
document.getElementById('permissionGroup').style.display = 'none';
document.getElementById('manageKeyGroup').style.display = 'none';
setKeyEditorDisabled('userKey', !IS_TUTOR);
setKeyEditorDisabled('userManageKey', true);
}
document.getElementById('password').required = false; document.getElementById('password').required = false;
document.getElementById('confirmPassword').required = false; document.getElementById('confirmPassword').required = false;
document.getElementById('userModal').style.display = 'block'; document.getElementById('userModal').style.display = 'block';
@@ -430,10 +774,15 @@
return; return;
} }
const data = { const data = {};
username: username, if (IS_ADMIN) {
permission: parseInt(permission) data.username = username;
}; data.permission = parseInt(permission);
data.key = MODAL_SELECTED_KEYS;
data.manage_key = MODAL_SELECTED_MANAGE_KEYS;
} else {
data.key = MODAL_SELECTED_KEYS;
}
if (password) { if (password) {
data.password = password; data.password = password;
@@ -470,7 +819,9 @@
if (result.status === 'success') { if (result.status === 'success') {
showNotification(userId ? '用户更新成功' : '用户添加成功'); showNotification(userId ? '用户更新成功' : '用户添加成功');
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
loadUsers(); const searchTerm = (document.getElementById('searchInput') || {}).value || '';
const key = (document.getElementById('keyFilter') || {}).value || '';
loadUsers(searchTerm, key);
} else { } else {
showNotification(result.message || '操作失败', false); showNotification(result.message || '操作失败', false);
} }
@@ -511,7 +862,10 @@
} }
// 事件监听器 // 事件监听器
document.getElementById('addUserBtn').addEventListener('click', openAddModal); const addBtn = document.getElementById('addUserBtn');
if (addBtn) {
addBtn.addEventListener('click', openAddModal);
}
document.getElementById('userForm').addEventListener('submit', saveUser); document.getElementById('userForm').addEventListener('submit', saveUser);
@@ -523,15 +877,59 @@
}); });
}); });
document.getElementById('searchBtn').addEventListener('click', function() { const searchBtn = document.getElementById('searchBtn');
const searchTerm = document.getElementById('searchInput').value; if (searchBtn) {
loadUsers(searchTerm); searchBtn.addEventListener('click', function() {
}); const searchTerm = document.getElementById('searchInput').value;
const key = (document.getElementById('keyFilter') || {}).value || '';
loadUsers(searchTerm, key);
});
}
document.getElementById('resetBtn').addEventListener('click', function() { const resetBtn = document.getElementById('resetBtn');
document.getElementById('searchInput').value = ''; if (resetBtn) {
loadUsers(); resetBtn.addEventListener('click', function() {
}); document.getElementById('searchInput').value = '';
const select = document.getElementById('keyFilter');
if (select) select.value = '';
loadUsers('', '');
});
}
const clearKeyBtn = document.getElementById('clearKeyBtn');
if (clearKeyBtn) {
clearKeyBtn.addEventListener('click', function() {
const select = document.getElementById('keyFilter');
if (select) select.value = '';
const searchTerm = document.getElementById('searchInput').value;
loadUsers(searchTerm, '');
});
}
const addUserKeyBtn = document.getElementById('addUserKeyBtn');
if (addUserKeyBtn) {
addUserKeyBtn.addEventListener('click', function() {
addFromSelect('userKeySelect', MODAL_SELECTED_KEYS, 'userKeysSelected');
});
}
const clearUserKeyBtn = document.getElementById('clearUserKeyBtn');
if (clearUserKeyBtn) {
clearUserKeyBtn.addEventListener('click', function() {
clearSelected(MODAL_SELECTED_KEYS, 'userKeysSelected');
});
}
const addUserManageKeyBtn = document.getElementById('addUserManageKeyBtn');
if (addUserManageKeyBtn) {
addUserManageKeyBtn.addEventListener('click', function() {
addFromSelect('userManageKeySelect', MODAL_SELECTED_MANAGE_KEYS, 'userManageKeysSelected');
});
}
const clearUserManageKeyBtn = document.getElementById('clearUserManageKeyBtn');
if (clearUserManageKeyBtn) {
clearUserManageKeyBtn.addEventListener('click', function() {
clearSelected(MODAL_SELECTED_MANAGE_KEYS, 'userManageKeysSelected');
});
}
// 点击模态框外部关闭模态框 // 点击模态框外部关闭模态框
window.addEventListener('click', function(event) { window.addEventListener('click', function(event) {
@@ -572,7 +970,36 @@
// 页面加载时获取用户列表 // 页面加载时获取用户列表
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadUsers(); initKeyFilter();
const selfForm = document.getElementById('selfPwdForm');
if (selfForm) {
selfForm.addEventListener('submit', async (e) => {
e.preventDefault();
const uid = document.getElementById('selfUserId').value;
const pwd = document.getElementById('password').value;
const cpwd = document.getElementById('confirmPassword').value;
if (pwd !== cpwd) { showNotification('密码和确认密码不匹配', false); return; }
if ((pwd || '').length < 6) { showNotification('密码长度至少为6位', false); return; }
try {
const csrftoken = getCookie('csrftoken');
const resp = await fetch(`/elastic/users/${uid}/update/`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
body: JSON.stringify({ password: pwd })
});
const result = await resp.json();
if (resp.ok && result.status === 'success') { showNotification('修改成功'); }
else { showNotification(result.message || '操作失败', false); }
} catch (error) {
showNotification('保存失败', false);
}
});
}
const tbody = document.getElementById('usersTableBody');
if (tbody) {
const select = document.getElementById('keyFilter');
loadUsers('', select ? select.value : '');
}
}); });
// 为表格中的编辑和删除按钮添加事件监听器 // 为表格中的编辑和删除按钮添加事件监听器

View File

@@ -17,6 +17,8 @@ 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('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('users/', views.get_users, name='get_users'), path('users/', views.get_users, name='get_users'),
@@ -35,7 +37,11 @@ urlpatterns = [
path('registration-codes/manage/', views.registration_code_manage_page, name='registration_code_manage_page'), 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/', views.get_keys_list_view, name='get_keys_list'),
path('registration-codes/keys/add/', views.add_key_view, name='add_key'), path('registration-codes/keys/add/', views.add_key_view, name='add_key'),
path('registration-codes/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/generate/', views.generate_registration_code_view, name='generate_registration_code'),
path('registration-codes/list/', views.list_registration_codes_view, name='list_registration_codes'),
path('registration-codes/revoke/', views.revoke_registration_code_view, name='revoke_registration_code'),
# 分析接口 # 分析接口
path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'), path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),

File diff suppressed because it is too large Load Diff

View File

@@ -41,11 +41,22 @@
<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>
{% if is_admin %} {% endif %}
{% if is_admin or has_manage_key %}
<a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a> <a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a>
{% 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> <a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
{% endif %} {% 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> <a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div> <div id="logoutMsg"></div>
{% csrf_token %} {% csrf_token %}
@@ -56,7 +67,7 @@
<div class="main-content"> <div class="main-content">
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h2>主页</h2> <h2>师生共创系统</h2>
<span class="badge">用户:{{ user_id }}</span> <span class="badge">用户:{{ user_id }}</span>
</div> </div>
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div> <div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
@@ -67,7 +78,10 @@
<div id="chartTrend" style="width:100%;height:320px;"></div> <div id="chartTrend" style="width:100%;height:320px;"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="header"><h3>类型占比近30天</h3></div> <div class="header">
<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 id="chartTypes" style="width:100%;height:320px;"></div>
</div> </div>
<div class="card"> <div class="card">
@@ -81,6 +95,24 @@
</div> </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>
<script> <script>
// 获取CSRF令牌的函数 // 获取CSRF令牌的函数
function getCookie(name) { function getCookie(name) {
@@ -147,6 +179,68 @@
} }
}); });
const applyRegBtn = document.getElementById('applyRegBtn');
const applyRegModal = document.getElementById('applyRegModal');
const applyRegClose = document.getElementById('applyRegClose');
const applyRegSubmit = document.getElementById('applyRegSubmit');
const applyRegMsg = document.getElementById('applyRegMsg');
const applyReason = document.getElementById('applyReason');
function openApplyRegModal() {
if (!applyRegModal) return;
applyRegMsg.textContent = '';
applyReason.value = '';
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 {
const resp = await fetch('/accounts/registration-code/request/submit/', {
method: 'POST',
credentials: 'same-origin',
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) {
applyRegMsg.textContent = '提交失败';
}
});
}
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); } function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
function qs(params){ const u = new URLSearchParams(params); return u.toString(); } function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
@@ -170,19 +264,74 @@
}); });
} }
let typesChartData = [];
let currentChartType = 'pie';
let typesChartInterval = null;
async function loadTypes(){ async function loadTypes(){
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 }); const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
const res = await fetchJSON(url); const res = await fetchJSON(url);
if(res.status!=='success') return; if(res.status!=='success') return;
const buckets = res.data || []; const buckets = res.data || [];
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 })); typesChartData = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
typesChart.setOption({ renderTypesChart();
tooltip:{trigger:'item'}, startTypesChartRotation();
legend:{type:'scroll'},
series:[{ type:'pie', radius:['40%','70%'], data }]
});
} }
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);
} else {
const names = typesChartData.map(d => d.name);
const values = typesChartData.map(d => d.value);
typesChart.setOption({
tooltip:{trigger:'axis', axisPointer:{type:'shadow'}},
legend:{show: false},
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();
});
async function loadTypesTrend(){ async function loadTypesTrend(){
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 }); const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
const res = await fetchJSON(url); const res = await fetchJSON(url);
@@ -233,7 +382,8 @@
const t = formatTime(it.time); const t = formatTime(it.time);
const u = it.username || ''; const u = it.username || '';
const ty = it.type || '未知'; const ty = it.type || '未知';
li.textContent = `${t}${u}${ty}`; const de = it.detail ? `${it.detail}` : '';
li.textContent = `${t}${u}${ty}${de}`;
listEl.appendChild(li); listEl.appendChild(li);
}); });
} }

View File

@@ -10,12 +10,10 @@ 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: try:
perm = int((u or {}).get("permission", 1)) perm = int((u or {}).get("permission", 1))
except Exception: except Exception:
@@ -26,8 +24,15 @@ def home(request):
perm = int(perm) perm = int(perm)
except Exception: except Exception:
perm = 1 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,
"username": (u or {}).get("username"),
"is_admin": (int(perm) == 0), "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,9 +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 django-browser-reload==1.21.0
captcha==0.7.1 captcha==0.7.1
cryptography==46.0.3 cryptography==46.0.3
pymupdf==1.25.3