Compare commits
70 Commits
8f9fc9c914
...
0.2.8.14
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f8a64fdb | |||
| 01a3b2dfdb | |||
| 0dd7879389 | |||
| 19f805c818 | |||
| d84d0218cd | |||
| e92964ce71 | |||
| 1a3aee39e0 | |||
| 7fa7b42b1a | |||
| 26452161f8 | |||
| 07d3a4420c | |||
| 2c3c2d6acf | |||
| afc663844b | |||
| 9e3fe7150b | |||
| c9611fa622 | |||
| fe7f08ed1c | |||
| 5e38ebf856 | |||
| 71a0723a74 | |||
| 85dd7bc991 | |||
| 3596e344e2 | |||
| b0c3707ccd | |||
| f38cb5ec76 | |||
| 8c4e4e4c0d | |||
| e05791e52f | |||
| 4d83864e9f | |||
| ebe88d93c9 | |||
| 6f1abc1681 | |||
| d69858434f | |||
| 109c06e1d9 | |||
| 1163110810 | |||
| 462c744d06 | |||
| b35f603399 | |||
| b4cea89796 | |||
| ee7987aa23 | |||
| 193f739693 | |||
| 418cc798df | |||
| 14e407d06a | |||
| bfbf100595 | |||
| abc435afe6 | |||
| 6b0be35832 | |||
| 45005fcc92 | |||
| df18bdfa7e | |||
| 281ade6ac9 | |||
| 835426b133 | |||
| d001fec21e | |||
| 253de3639c | |||
| a0507b8054 | |||
| 9f803880fa | |||
| 71fe964476 | |||
| 0f5c8c08ff | |||
| e032253327 | |||
| 3f108e2138 | |||
| 2d913e397f | |||
| 74bc8aa498 | |||
| 5d747faee1 | |||
| 7bd8eeca77 | |||
| 782b2dd82e | |||
| f9c0abb3a0 | |||
| c5300591e6 | |||
| f96629566f | |||
| 8d581ac638 | |||
| acc80074ea | |||
| 62d28be032 | |||
| 5b956e1365 | |||
| 7485ba16e6 | |||
| ac580599b3 | |||
| faae7032f1 | |||
| 615d9433fe | |||
| d755f4710f | |||
| 3e598fe0a1 | |||
| 5a9d98282a |
129
.gitea/workflows/ci.yml
Normal file
129
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,129 @@
|
||||
name: CI
|
||||
|
||||
# Required Secrets:
|
||||
# - DJANGO_SECRET_KEY: Django Secret Key
|
||||
# - token: Gitea API token for creating releases
|
||||
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
|
||||
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
|
||||
# - WEBDAV_USER: WebDAV username
|
||||
# - WEBDAV_PASSWORD: WebDAV password
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Django
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 版本号(如 0.2.2),为空则自动生成
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker-ci:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
|
||||
DJANGO_DEBUG: "False"
|
||||
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
|
||||
IMAGE_NAME: achievement_inputing_ci
|
||||
ARTIFACT_DIR: artifacts
|
||||
# 请在 Secrets 中配置 ALIST_PUBLIC_URL,例如 http://139.224.69.213:8080/d/ci
|
||||
DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
RELEASE_TOKEN: ${{ secrets.token }}
|
||||
steps:
|
||||
- name: Ensure source present
|
||||
env:
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ -f "$GITHUB_WORKSPACE/Dockerfile" ]; then exit 0; fi
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
git init .
|
||||
if [ -z "$TOKEN" ]; then
|
||||
git fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||
else
|
||||
git -c http.extraHeader="Authorization: Bearer $TOKEN" fetch --depth=1 "$SERVER/$REPO.git" "$REF"
|
||||
fi
|
||||
git checkout FETCH_HEAD
|
||||
- name: Derive version
|
||||
run: |
|
||||
msg="${{ github.event.head_commit.message }}"
|
||||
ver_input="${{ github.event.inputs.version }}"
|
||||
ver=""
|
||||
if [ -n "$ver_input" ]; then
|
||||
ver="$ver_input"
|
||||
else
|
||||
ver=$(echo "$msg" | grep -Eo "\[[0-9]+(\.[0-9]+){1,}\]" | head -n1 | tr -d '[]')
|
||||
fi
|
||||
if [ -z "$ver" ]; then
|
||||
ver="$(date +%Y%m%d%H%M)-${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "VERSION=$ver" >> $GITHUB_ENV
|
||||
- name: Build application image
|
||||
run: |
|
||||
docker build -t "$IMAGE_NAME:$VERSION" -f "$GITHUB_WORKSPACE/Dockerfile" "$GITHUB_WORKSPACE"
|
||||
- name: Output image info
|
||||
run: |
|
||||
docker image inspect "$IMAGE_NAME:$VERSION" --format '{{.Id}} {{.Size}}'
|
||||
- name: Export image tar
|
||||
run: |
|
||||
ART="achievement_inputing_ci_${VERSION}.tar"
|
||||
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
|
||||
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
|
||||
- name: Publish artifact locally
|
||||
run: |
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
|
||||
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
|
||||
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||
- name: Publish to WebDAV
|
||||
env:
|
||||
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
|
||||
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||
|
||||
# 检查必要的 secrets 是否存在
|
||||
if [ -z "$WEBDAV_URL" ]; then
|
||||
echo "Error: WEBDAV_URL secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 确保 URL 以 / 结尾
|
||||
case "$WEBDAV_URL" in
|
||||
*/) ;;
|
||||
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
|
||||
esac
|
||||
|
||||
echo "Uploading $ART to $WEBDAV_URL..."
|
||||
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
|
||||
echo "Upload success."
|
||||
- name: Create release with download link
|
||||
if: env.RELEASE_TOKEN != ''
|
||||
run: |
|
||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
TAG="$VERSION"
|
||||
NAME="$VERSION"
|
||||
BASE="${DOWNLOAD_BASE%/}"
|
||||
DL="$BASE/$ART"
|
||||
echo "download: $DL"
|
||||
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
|
||||
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
/.idea/
|
||||
.idea/
|
||||
|
||||
/media/
|
||||
media/
|
||||
|
||||
*.tar
|
||||
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
||||
'accounts',
|
||||
'main',
|
||||
'elastic',
|
||||
'minio_storage',
|
||||
'django_elasticsearch_dsl',
|
||||
]
|
||||
|
||||
@@ -166,3 +167,4 @@ ELASTICSEARCH_INDEX_NAMES = {
|
||||
# AI Studio/OpenAI client settings
|
||||
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_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')
|
||||
|
||||
197
README.md
197
README.md
@@ -1 +1,196 @@
|
||||
python manage.py shell -c "from elastic.es_connect import create_index_with_mapping; create_index_with_mapping()"
|
||||
# 多级权限控制数据结构说明
|
||||
|
||||
## 核心概念
|
||||
|
||||
该设计通过 **关键字匹配(Keyword Matching)** 实现数据行级权限控制,适用于学校、企业等层级组织架构场景。
|
||||
|
||||
### 字段定义
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `key` | `KeywordField(multi=True)` | **身份标识关键字** - 表示用户所属的层级/组织,用于匹配"自己的数据" |
|
||||
| `manage_key` | `KeywordField(multi=True)` | **管理范围关键字** - 表示用户能管理的数据范围,用于匹配"管辖范围内的数据" |
|
||||
|
||||
---
|
||||
|
||||
## 权限模型图解
|
||||
|
||||
```
|
||||
数据权限 = (数据.key ∩ 用户.key) ∪ (数据.key ∩ 用户.manage_key)
|
||||
|
||||
解释:
|
||||
- 用户能看到的数据 = 自己的数据 OR 管辖范围内的数据
|
||||
- 两者都满足"用户权限"(非管理员),只是数据范围不同
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 具体场景示例
|
||||
|
||||
### 场景1:学生视角
|
||||
|
||||
**用户:学生A(2024届人工智能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()"
|
||||
|
||||
488
accounts/templates/accounts/profile.html
Normal file
488
accounts/templates/accounts/profile.html
Normal file
@@ -0,0 +1,488 @@
|
||||
<!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; overflow: hidden; }
|
||||
.image-modal-content { max-width: 90%; max-height: 90%; border-radius: 8px; transform-origin: center center; cursor: grab; user-select: none; }
|
||||
.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>你好,<span id="sidebarUsername">{{ username|default:"访客" }}</span></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">
|
||||
{% if subpage %}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>{{ subpage_title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<a href="{% url 'accounts:profile' %}" style="color:#2d8cf0; text-decoration:none;">返回个人中心</a>
|
||||
</div>
|
||||
{% if subpage == "username" %}
|
||||
<form id="nameForm">
|
||||
<div class="form-group">
|
||||
<label for="newUsername">新用户名</label>
|
||||
<input type="text" id="newUsername" placeholder="请输入新用户名" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<div id="nameMsg" class="msg"></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if subpage == "password" %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if subpage == "registration-code" %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<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> <span id="profileUsername">{{ profile_user.username }}</span></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="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h2>账号设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<a class="btn" href="{% url 'accounts:profile_username' %}">修改用户名</a>
|
||||
<a class="btn" href="{% url 'accounts:profile_password' %}">修改密码</a>
|
||||
<a class="btn" href="{% url 'accounts:profile_registration_code' %}">替换注册码</a>
|
||||
</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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 图片放大模态框 -->
|
||||
<div id="imageModal" class="image-modal">
|
||||
<span class="image-modal-close" onclick="closeModal()">×</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('登出失败'); }
|
||||
});
|
||||
|
||||
// 图片放大功能
|
||||
let modalScale = 1;
|
||||
let modalTranslateX = 0;
|
||||
let modalTranslateY = 0;
|
||||
let modalDragging = false;
|
||||
let modalDragStartX = 0;
|
||||
let modalDragStartY = 0;
|
||||
let modalDragOriginX = 0;
|
||||
let modalDragOriginY = 0;
|
||||
|
||||
function applyModalTransform() {
|
||||
const modalImg = document.getElementById('modalImg');
|
||||
modalImg.style.transform = `translate(${modalTranslateX}px, ${modalTranslateY}px) scale(${modalScale})`;
|
||||
}
|
||||
|
||||
function resetModalTransform() {
|
||||
modalScale = 1;
|
||||
modalTranslateX = 0;
|
||||
modalTranslateY = 0;
|
||||
applyModalTransform();
|
||||
}
|
||||
|
||||
function clampScale(next) {
|
||||
if (next < 0.2) return 0.2;
|
||||
if (next > 5) return 5;
|
||||
return next;
|
||||
}
|
||||
|
||||
function openModal(src) {
|
||||
const modal = document.getElementById('imageModal');
|
||||
const modalImg = document.getElementById('modalImg');
|
||||
modal.style.display = "flex";
|
||||
modalImg.src = src;
|
||||
resetModalTransform();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('imageModal').style.display = "none";
|
||||
}
|
||||
|
||||
const modalEl = document.getElementById('imageModal');
|
||||
const modalImgEl = document.getElementById('modalImg');
|
||||
if (modalEl && modalImgEl) {
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) closeModal();
|
||||
});
|
||||
|
||||
modalImgEl.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
modalDragging = true;
|
||||
modalDragStartX = e.clientX;
|
||||
modalDragStartY = e.clientY;
|
||||
modalDragOriginX = modalTranslateX;
|
||||
modalDragOriginY = modalTranslateY;
|
||||
modalImgEl.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!modalDragging) return;
|
||||
const dx = e.clientX - modalDragStartX;
|
||||
const dy = e.clientY - modalDragStartY;
|
||||
modalTranslateX = modalDragOriginX + dx;
|
||||
modalTranslateY = modalDragOriginY + dy;
|
||||
applyModalTransform();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (!modalDragging) return;
|
||||
modalDragging = false;
|
||||
modalImgEl.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
modalEl.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const rect = modalImgEl.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
const nextScale = clampScale(modalScale * (e.deltaY < 0 ? 1.1 : 0.9));
|
||||
const ratio = nextScale / modalScale;
|
||||
modalTranslateX = (modalTranslateX - cx) * ratio + cx;
|
||||
modalTranslateY = (modalTranslateY - cy) * ratio + cy;
|
||||
modalScale = nextScale;
|
||||
applyModalTransform();
|
||||
}, { passive: false });
|
||||
|
||||
modalImgEl.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
modalDragging = true;
|
||||
modalDragStartX = t.clientX;
|
||||
modalDragStartY = t.clientY;
|
||||
modalDragOriginX = modalTranslateX;
|
||||
modalDragOriginY = modalTranslateY;
|
||||
}, { passive: true });
|
||||
|
||||
modalImgEl.addEventListener('touchmove', (e) => {
|
||||
if (!modalDragging || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - modalDragStartX;
|
||||
const dy = t.clientY - modalDragStartY;
|
||||
modalTranslateX = modalDragOriginX + dx;
|
||||
modalTranslateY = modalDragOriginY + dy;
|
||||
applyModalTransform();
|
||||
}, { passive: true });
|
||||
|
||||
modalImgEl.addEventListener('touchend', () => {
|
||||
modalDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
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 nameForm = document.getElementById('nameForm');
|
||||
if (nameForm) {
|
||||
nameForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('nameMsg');
|
||||
msg.textContent = '';
|
||||
msg.className = 'msg';
|
||||
const input = document.getElementById('newUsername');
|
||||
const newName = (input.value || '').trim();
|
||||
const currentNameEl = document.getElementById('profileUsername');
|
||||
const currentName = (currentNameEl && currentNameEl.textContent ? currentNameEl.textContent : '').trim();
|
||||
if (!newName) {
|
||||
msg.textContent = '请输入用户名';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
if (newName.length > 50) {
|
||||
msg.textContent = '用户名过长';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
if (currentName && newName === currentName) {
|
||||
msg.textContent = '用户名未变化';
|
||||
msg.className = 'msg error';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
const resp = await fetch('/accounts/profile/username/update/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken || ''
|
||||
},
|
||||
body: JSON.stringify({ username: newName })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.ok) {
|
||||
msg.textContent = '修改成功';
|
||||
msg.className = 'msg success';
|
||||
if (currentNameEl) currentNameEl.textContent = data.username || newName;
|
||||
const sidebarName = document.getElementById('sidebarUsername');
|
||||
if (sidebarName) sidebarName.textContent = data.username || newName;
|
||||
input.value = '';
|
||||
} else {
|
||||
msg.textContent = (data && data.message) ? 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>
|
||||
@@ -20,10 +20,14 @@
|
||||
<h1>注册新用户</h1>
|
||||
<form id="regForm">
|
||||
{% csrf_token %}
|
||||
<label for="code">注册码</label>
|
||||
<input id="code" name="code" type="text" required />
|
||||
<label for="code">注册码(选填)</label>
|
||||
<input id="code" name="code" type="text" />
|
||||
<label for="email">邮箱</label>
|
||||
<input id="email" name="email" type="email" required />
|
||||
<button id="sendCodeBtn" type="button">发送验证码</button>
|
||||
<div id="sendMsg" class="hint"></div>
|
||||
<label for="email_code">邮箱验证码</label>
|
||||
<input id="email_code" name="email_code" type="text" required />
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" required />
|
||||
<label for="password">密码</label>
|
||||
@@ -33,7 +37,7 @@
|
||||
<button id="regBtn" type="submit">注册</button>
|
||||
<div id="error" class="error"></div>
|
||||
</form>
|
||||
<div class="hint">仅允许持有管理员提供注册码的学生注册</div>
|
||||
<div class="hint">有注册码请填写,否则可留空</div>
|
||||
</div>
|
||||
<script>
|
||||
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
|
||||
@@ -43,20 +47,36 @@
|
||||
const code=(document.getElementById('code').value||'').trim();
|
||||
const email=(document.getElementById('email').value||'').trim();
|
||||
const username=(document.getElementById('username').value||'').trim();
|
||||
const email_code=(document.getElementById('email_code').value||'').trim();
|
||||
const password=document.getElementById('password').value||'';
|
||||
const confirm=document.getElementById('confirm').value||'';
|
||||
if(!code||!email||!username||!password){err.textContent='请填写所有字段';return;}
|
||||
if(!email||!email_code||!username||!password){err.textContent='请填写所有必填字段';return;}
|
||||
if(password!==confirm){err.textContent='两次密码不一致';return;}
|
||||
const btn=document.getElementById('regBtn'); btn.disabled=true;
|
||||
try{
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const resp=await fetch('/accounts/register/submit/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code,email,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();
|
||||
if(!resp.ok||!data.ok){throw new Error(data.message||'注册失败');}
|
||||
window.location.href=data.redirect_url;
|
||||
}catch(e){err.textContent=e.message||'发生错误';}
|
||||
finally{btn.disabled=false;}
|
||||
});
|
||||
document.getElementById('sendCodeBtn').addEventListener('click',async()=>{
|
||||
const email=(document.getElementById('email').value||'').trim();
|
||||
const msg=document.getElementById('sendMsg');
|
||||
msg.textContent='';
|
||||
if(!email){msg.textContent='请输入邮箱';return;}
|
||||
const btn=document.getElementById('sendCodeBtn'); btn.disabled=true;
|
||||
try{
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const resp=await fetch('/accounts/email/send-code/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({email})});
|
||||
const data=await resp.json();
|
||||
if(!resp.ok||!data.ok){throw new Error(data.message||'发送失败');}
|
||||
msg.textContent='验证码已发送,请查收邮件';
|
||||
}catch(e){msg.textContent=e.message||'发送失败';}
|
||||
finally{btn.disabled=false;}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
186
accounts/templates/accounts/registration_code_requests.html
Normal file
186
accounts/templates/accounts/registration_code_requests.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>注册码申请管理</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #f5f6fa; }
|
||||
.sidebar { position: fixed; top: 0; left: 0; width: 180px; height: 100vh; background: #1e1e2e; color: white; padding: 20px; box-shadow: 2px 0 5px rgba(0,0,0,0.1); z-index: 1000; display: flex; flex-direction: column; align-items: center; }
|
||||
.sidebar h3 { margin-top: 0; font-size: 18px; color: #add8e6; text-align: center; margin-bottom: 20px; }
|
||||
.navigation-links { width: 100%; margin-top: 60px; }
|
||||
.sidebar a { display: block; color: #8be9fd; text-decoration: none; margin: 10px 0; font-size: 16px; padding: 15px; border-radius: 4px; transition: all 0.2s ease; }
|
||||
.sidebar a:hover { color: #ff79c6; background-color: rgba(139, 233, 253, 0.2); }
|
||||
|
||||
.main-content { margin-left: 220px; padding: 40px; }
|
||||
.card { background: #fff; border-radius: 14px; box-shadow: 0 10px 24px rgba(31,35,40,0.08); padding: 24px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.btn { padding: 8px 12px; border: none; border-radius: 10px; cursor: pointer; }
|
||||
.btn-primary { background: #4f46e5; color: #fff; }
|
||||
.btn-secondary { background: #64748b; color: #fff; }
|
||||
.btn-danger { background: #ff4d4f; color: #fff; }
|
||||
.muted { color: #6b7280; font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { text-align: left; border-bottom: 1px solid #e5e7eb; padding: 10px 8px; vertical-align: top; font-size: 13px; }
|
||||
tr:hover { background: #f8fafc; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #eef2ff; color: #3730a3; }
|
||||
.tag.pending { background: #fff7ed; color: #9a3412; }
|
||||
.tag.approved { background: #dcfce7; color: #166534; }
|
||||
.tag.rejected { background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
{% csrf_token %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<h3>你好,{{ username|default:"管理员" }}</h3>
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}">返回主页</a>
|
||||
<a id="logoutBtn" style="cursor:pointer;">退出登录</a>
|
||||
<div id="logoutMsg" class="muted" style="margin-top:6px;"></div>
|
||||
{% csrf_token %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2 style="margin:0;">注册码申请管理</h2>
|
||||
<div style="display:flex; gap:10px; align-items:center;">
|
||||
<select id="statusFilter" style="padding:8px 10px; border:1px solid #d1d5db; border-radius:10px;">
|
||||
<option value="pending">待审核</option>
|
||||
<option value="">全部</option>
|
||||
<option value="approved">已同意</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</select>
|
||||
<button id="refreshBtn" class="btn btn-secondary" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted">同意后,用户会获得“注册码管理”入口,且仅能使用自己新增的 key。</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:120px;">用户</th>
|
||||
<th>申请理由</th>
|
||||
<th style="width:170px;">时间</th>
|
||||
<th style="width:110px;">状态</th>
|
||||
<th style="width:220px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reqBody"></tbody>
|
||||
</table>
|
||||
<div id="pageMsg" class="muted" style="margin-top:12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
|
||||
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
const msg = document.getElementById('logoutMsg');
|
||||
msg.textContent = '';
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
try {
|
||||
const resp = await fetch('/accounts/logout/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) window.location.href = data.redirect_url;
|
||||
} catch (e) { msg.textContent = '登出失败'; }
|
||||
});
|
||||
|
||||
function fmtTime(t){
|
||||
try{
|
||||
const d = new Date(t);
|
||||
if(String(d) !== 'Invalid Date'){
|
||||
const pad = n=> String(n).padStart(2,'0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
}catch(e){}
|
||||
return t || '';
|
||||
}
|
||||
|
||||
function renderStatus(s){
|
||||
const v = String(s || 'pending');
|
||||
const cls = (v === 'approved' || v === 'rejected') ? v : 'pending';
|
||||
const text = v === 'approved' ? '已同意' : (v === 'rejected' ? '已拒绝' : '待审核');
|
||||
return `<span class="tag ${cls}">${text}</span>`;
|
||||
}
|
||||
|
||||
async function loadRequests(){
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const msg = document.getElementById('pageMsg');
|
||||
msg.textContent = '加载中...';
|
||||
const url = status ? `/accounts/registration-code/requests/list/?status=${encodeURIComponent(status)}` : '/accounts/registration-code/requests/list/';
|
||||
try{
|
||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if(!(resp.ok && data && data.ok)){
|
||||
msg.textContent = (data && data.message) ? data.message : '加载失败';
|
||||
return;
|
||||
}
|
||||
const body = document.getElementById('reqBody');
|
||||
body.innerHTML = '';
|
||||
const rows = data.data || [];
|
||||
if(!rows.length){
|
||||
msg.textContent = '暂无数据';
|
||||
return;
|
||||
}
|
||||
msg.textContent = '';
|
||||
rows.forEach(r=>{
|
||||
const tr = document.createElement('tr');
|
||||
const uname = (r.username || '') + (r.user_id !== undefined ? `(${r.user_id})` : '');
|
||||
const reason = String(r.reason || '').replace(/</g,'<').replace(/>/g,'>');
|
||||
const created = fmtTime(r.created_at);
|
||||
const statusHtml = renderStatus(r.status);
|
||||
const id = r.request_id || r._id || '';
|
||||
const ops = (String(r.status || 'pending') === 'pending')
|
||||
? `<button class="btn btn-primary" data-act="approve" data-id="${id}">同意</button>
|
||||
<button class="btn btn-danger" data-act="reject" data-id="${id}">拒绝</button>`
|
||||
: `<button class="btn btn-secondary" data-act="view" data-id="${id}">查看</button>`;
|
||||
tr.innerHTML = `<td>${uname}</td><td style="white-space:pre-wrap;">${reason}</td><td>${created}</td><td>${statusHtml}</td><td>${ops}</td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}catch(e){
|
||||
msg.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(id, action){
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
const note = '';
|
||||
const resp = await fetch('/accounts/registration-code/requests/decide/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
|
||||
body: JSON.stringify({ request_id: id, action, note })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if(!(resp.ok && data && data.ok)){
|
||||
alert((data && data.message) ? data.message : '操作失败');
|
||||
return;
|
||||
}
|
||||
loadRequests();
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadRequests);
|
||||
document.getElementById('statusFilter').addEventListener('change', loadRequests);
|
||||
document.addEventListener('click', (e)=>{
|
||||
const t = e.target;
|
||||
if(!(t && t.dataset && t.dataset.id && t.dataset.act)) return;
|
||||
const id = t.dataset.id;
|
||||
const act = t.dataset.act;
|
||||
if(act === 'approve'){
|
||||
if(confirm('确定同意该申请吗?')) decide(id, 'approve');
|
||||
}else if(act === 'reject'){
|
||||
if(confirm('确定拒绝该申请吗?')) decide(id, 'reject');
|
||||
}else if(act === 'view'){
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
loadRequests();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,4 +11,16 @@ urlpatterns = [
|
||||
path("logout/", views.logout, name="logout"),
|
||||
path("register/", views.register_page, name="register"),
|
||||
path("register/submit/", views.register_submit, name="register_submit"),
|
||||
]
|
||||
path("email/send-code/", views.send_email_code, name="send_email_code"),
|
||||
path("profile/", views.profile_page, name="profile"),
|
||||
path("profile/username/", views.profile_username_page, name="profile_username"),
|
||||
path("profile/password/", views.profile_password_page, name="profile_password"),
|
||||
path("profile/registration-code/", views.profile_registration_code_page, name="profile_registration_code"),
|
||||
path("profile/username/update/", views.update_profile_username_view, name="update_profile_username"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
import io
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import smtplib
|
||||
|
||||
from django.http import JsonResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import render, redirect
|
||||
@@ -13,7 +15,7 @@ from django.conf import settings
|
||||
|
||||
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, 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"])
|
||||
@@ -69,6 +71,64 @@ def set_session_key(request):
|
||||
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
def _build_profile_context(request):
|
||||
session_user_id = request.session.get("user_id")
|
||||
if session_user_id is None:
|
||||
return None
|
||||
user = get_user_by_id(session_user_id)
|
||||
if not user:
|
||||
return None
|
||||
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 "普通用户"
|
||||
return {
|
||||
"username": request.session.get("username"),
|
||||
"profile_user": user,
|
||||
"permission_name": permission_name,
|
||||
"achievements": achievements,
|
||||
}
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = ""
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_username_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "username"
|
||||
context["subpage_title"] = "修改用户名"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_password_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "password"
|
||||
context["subpage_title"] = "修改密码"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def profile_registration_code_page(request):
|
||||
context = _build_profile_context(request)
|
||||
if context is None:
|
||||
return redirect("/accounts/login/")
|
||||
context["subpage"] = "registration-code"
|
||||
context["subpage_title"] = "替换注册码"
|
||||
return render(request, "accounts/profile.html", context)
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def secure_login_submit(request):
|
||||
@@ -183,10 +243,75 @@ def register_submit(request):
|
||||
return HttpResponseBadRequest("Invalid JSON")
|
||||
code = (payload.get("code") or "").strip()
|
||||
email = (payload.get("email") or "").strip()
|
||||
email_code = (payload.get("email_code") or "").strip()
|
||||
username = (payload.get("username") or "").strip()
|
||||
password = (payload.get("password") or "")
|
||||
if not 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")
|
||||
v = request.session.get("email_verify") or {}
|
||||
if (v.get("email") or "") != email:
|
||||
return JsonResponse({"ok": False, "message": "请先验证邮箱"}, status=400)
|
||||
try:
|
||||
exp_ts = int(v.get("expires_at") or 0)
|
||||
except Exception:
|
||||
exp_ts = 0
|
||||
if exp_ts < int(time.time()):
|
||||
return JsonResponse({"ok": False, "message": "验证码已过期"}, status=400)
|
||||
if (v.get("code") or "") != email_code:
|
||||
return JsonResponse({"ok": False, "message": "邮箱验证码错误"}, status=400)
|
||||
rc = None
|
||||
if code:
|
||||
rc = get_registration_code(code)
|
||||
if not rc:
|
||||
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
|
||||
try:
|
||||
exp = rc.get("expires_at")
|
||||
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
|
||||
if hasattr(exp, 'isoformat'):
|
||||
exp_dt = exp
|
||||
else:
|
||||
exp_dt = __import__("datetime").datetime.fromisoformat(str(exp))
|
||||
if exp_dt <= now:
|
||||
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
|
||||
except Exception:
|
||||
pass
|
||||
existing = es_get_user_by_username(username)
|
||||
if existing:
|
||||
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
|
||||
users = es_get_all_users()
|
||||
next_id = (max([int(u.get("user_id", 0)) for u in users]) + 1) if users else 1
|
||||
ok = write_user_data({
|
||||
"user_id": next_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"permission": 1,
|
||||
"email": email,
|
||||
"key": (rc.get("keys") if rc else []) or [],
|
||||
"manage_key": (rc.get("manage_keys") if rc else []) or [],
|
||||
"registration_code": (rc.get("code") if rc else None),
|
||||
})
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
|
||||
try:
|
||||
if "email_verify" in request.session:
|
||||
del request.session["email_verify"]
|
||||
except Exception:
|
||||
pass
|
||||
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def replace_registration_code_view(request):
|
||||
session_user_id = request.session.get("user_id")
|
||||
if session_user_id is None:
|
||||
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("Invalid JSON")
|
||||
code = (payload.get("code") or "").strip()
|
||||
if not code:
|
||||
return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400)
|
||||
rc = get_registration_code(code)
|
||||
if not rc:
|
||||
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
|
||||
@@ -201,20 +326,226 @@ def register_submit(request):
|
||||
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") or [],
|
||||
"manage_key": rc.get("manage_keys") or [],
|
||||
})
|
||||
keys = list(rc.get("keys") or [])
|
||||
manage_keys = list(rc.get("manage_keys") or [])
|
||||
ok = update_user_by_id(session_user_id, key=keys, manage_key=manage_keys, registration_code=code)
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
|
||||
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
|
||||
return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@csrf_protect
|
||||
def update_profile_username_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 JsonResponse({"ok": False, "message": "JSON无效"}, status=400)
|
||||
new_username = (payload.get("username") or "").strip()
|
||||
if not new_username:
|
||||
return JsonResponse({"ok": False, "message": "请输入用户名"}, status=400)
|
||||
if len(new_username) > 50:
|
||||
return JsonResponse({"ok": False, "message": "用户名过长"}, status=400)
|
||||
me = get_user_by_id(session_user_id) or {}
|
||||
if str(me.get("username", "")).strip() == new_username:
|
||||
request.session["username"] = new_username
|
||||
return JsonResponse({"ok": True, "username": new_username})
|
||||
existing = es_get_user_by_username(new_username)
|
||||
if existing and str(existing.get("user_id")) != str(session_user_id):
|
||||
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
|
||||
ok = update_user_by_id(session_user_id, username=new_username)
|
||||
if not ok:
|
||||
return JsonResponse({"ok": False, "message": "修改失败"}, status=500)
|
||||
request.session["username"] = new_username
|
||||
return JsonResponse({"ok": True, "username": new_username})
|
||||
|
||||
@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)
|
||||
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
@@ -35,6 +35,9 @@ class UserDocument(Document):
|
||||
user_id = fields.LongField()
|
||||
username = 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_salt = fields.KeywordField()
|
||||
permission = fields.IntegerField() # 还是2种权限,0为管理员,1为用户(区别在于0有全部权限,1在数据管理页面有搜索框,但是索引到的录入信息要根据其用户id查询其key,若其中之一与用户的manage_key字段匹配就显示否则不显示)
|
||||
@@ -60,6 +63,7 @@ class RegistrationCodeDocument(Document):
|
||||
code = fields.KeywordField() #具体值
|
||||
keys = fields.KeywordField(multi=True) #对应的key
|
||||
manage_keys = fields.KeywordField(multi=True) #对应的manage_key
|
||||
created_at = fields.DateField() #创建时间
|
||||
expires_at = fields.DateField() #过期时间
|
||||
created_by = fields.LongField() #创建者id
|
||||
class Django:
|
||||
|
||||
@@ -307,7 +307,8 @@ def search_data(query):
|
||||
"_id": hit.meta.id,
|
||||
"writer_id": hit.writer_id,
|
||||
"data": hit.data,
|
||||
"image": hit.image
|
||||
"image": hit.image,
|
||||
"time": getattr(hit, "time", None),
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -328,7 +329,8 @@ def search_all():
|
||||
"_id": hit.meta.id,
|
||||
"writer_id": hit.writer_id,
|
||||
"data": hit.data,
|
||||
"image": hit.image
|
||||
"image": hit.image,
|
||||
"time": getattr(hit, "time", None),
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -435,7 +437,8 @@ def search_by_any_field(keyword):
|
||||
"_id": hit.meta.id,
|
||||
"writer_id": hit.writer_id,
|
||||
"data": hit.data,
|
||||
"image": hit.image
|
||||
"image": hit.image,
|
||||
"time": getattr(hit, "time", None),
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -489,9 +492,111 @@ def analytics_trend(gte: str = None, lte: str = None, interval: str = "day"):
|
||||
print(f"分析趋势失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def analytics_types(gte: str = None, lte: str = None, size: int = 10):
|
||||
def delete_key_globally(key_to_remove: str):
|
||||
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 = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
@@ -589,6 +694,25 @@ def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _extract_detail(s: str):
|
||||
if not s:
|
||||
return ""
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
if isinstance(obj, dict):
|
||||
# 尝试获取常见的标题字段
|
||||
for key in ["标题", "名称", "项目名称", "成果名称", "软件名称", "专利名称", "获奖名称", "证书名称", "姓名"]:
|
||||
v = obj.get(key)
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
# 如果没有找到常见标题,尝试获取第一个非"数据类型"的字符串值
|
||||
for k, v in obj.items():
|
||||
if k != "数据类型" and isinstance(v, str) and v and len(v) < 50:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
search = AchievementDocument.search()
|
||||
body = {
|
||||
"size": max(1, min(limit, 100)),
|
||||
@@ -619,11 +743,13 @@ def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
|
||||
except Exception:
|
||||
uname = None
|
||||
tval = _extract_type(getattr(hit, 'data', ''))
|
||||
dval = _extract_detail(getattr(hit, 'data', ''))
|
||||
results.append({
|
||||
"_id": hit.meta.id,
|
||||
"writer_id": w,
|
||||
"username": uname or "",
|
||||
"type": tval or "",
|
||||
"detail": dval or "",
|
||||
"time": getattr(hit, 'time', None)
|
||||
})
|
||||
return results
|
||||
@@ -660,6 +786,9 @@ def write_user_data(user_data):
|
||||
password_salt=pwd_salt_b64,
|
||||
permission=perm_val,
|
||||
email=user_data.get('email'),
|
||||
registration_code=(user_data.get('registration_code') or None),
|
||||
can_manage_registration_codes=int(user_data.get('can_manage_registration_codes') or 0),
|
||||
registration_manage_keys=list(user_data.get('registration_manage_keys') or []),
|
||||
key=list(user_data.get('key') or []),
|
||||
manage_key=list(user_data.get('manage_key') or []),
|
||||
)
|
||||
@@ -670,25 +799,6 @@ def write_user_data(user_data):
|
||||
print(f"用户数据写入失败: {str(e)}")
|
||||
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):
|
||||
"""
|
||||
根据用户名获取用户数据
|
||||
@@ -721,18 +831,19 @@ def get_user_by_username(username):
|
||||
def get_all_users():
|
||||
"""获取所有用户"""
|
||||
try:
|
||||
search = UserDocument.search()
|
||||
search = search.query("match_all")
|
||||
response = search.execute()
|
||||
|
||||
users = []
|
||||
for hit in response:
|
||||
for hit in UserDocument.search().query("match_all").scan():
|
||||
users.append({
|
||||
"user_id": hit.user_id,
|
||||
"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
|
||||
except Exception as e:
|
||||
print(f"获取所有用户失败: {str(e)}")
|
||||
@@ -749,6 +860,12 @@ def get_user_by_id(user_id):
|
||||
"user_id": hit.user_id,
|
||||
"username": hit.username,
|
||||
"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
|
||||
except Exception as e:
|
||||
@@ -770,7 +887,7 @@ def delete_user_by_id(user_id):
|
||||
print(f"删除用户失败: {str(e)}")
|
||||
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:
|
||||
search = UserDocument.search()
|
||||
search = search.query("term", user_id=int(user_id))
|
||||
@@ -786,9 +903,120 @@ def update_user_by_id(user_id, username=None, permission=None, password=None):
|
||||
salt_b64, hash_b64 = hash_password_random_salt(str(password))
|
||||
doc.password_hash = hash_b64
|
||||
doc.password_salt = salt_b64
|
||||
if key is not None:
|
||||
doc.key = list(key)
|
||||
if manage_key is not None:
|
||||
doc.manage_key = list(manage_key)
|
||||
if registration_code is not None:
|
||||
doc.registration_code = str(registration_code) if str(registration_code).strip() else None
|
||||
if can_manage_registration_codes is not None:
|
||||
try:
|
||||
doc.can_manage_registration_codes = int(can_manage_registration_codes)
|
||||
except Exception:
|
||||
doc.can_manage_registration_codes = 0
|
||||
if registration_manage_keys is not None:
|
||||
doc.registration_manage_keys = list(registration_manage_keys)
|
||||
doc.save()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"更新用户失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def _rc_request_now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def create_registration_code_manage_request(user_id: int, username: str, reason: str):
|
||||
try:
|
||||
rid = uuid.uuid4().hex
|
||||
doc = {
|
||||
"kind": "registration_code_manage_request",
|
||||
"request_id": rid,
|
||||
"user_id": int(user_id),
|
||||
"username": str(username or ""),
|
||||
"reason": str(reason or ""),
|
||||
"status": "pending",
|
||||
"created_at": _rc_request_now_iso(),
|
||||
}
|
||||
es.index(index=GLOBAL_INDEX_NAME, id=rid, body=doc)
|
||||
return rid
|
||||
except Exception as e:
|
||||
print(f"创建注册码管理申请失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_pending_registration_code_manage_request(user_id: int):
|
||||
try:
|
||||
body = {
|
||||
"size": 1,
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"term": {"kind": "registration_code_manage_request"}},
|
||||
{"term": {"user_id": int(user_id)}},
|
||||
{"term": {"status": "pending"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
"sort": [{"created_at": {"order": "desc"}}],
|
||||
}
|
||||
resp = es.search(index=GLOBAL_INDEX_NAME, body=body)
|
||||
hits = (resp.get("hits") or {}).get("hits") or []
|
||||
if not hits:
|
||||
return None
|
||||
h = hits[0]
|
||||
src = h.get("_source") or {}
|
||||
src["_id"] = h.get("_id")
|
||||
return src
|
||||
except Exception as e:
|
||||
print(f"查询注册码管理申请失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_registration_code_manage_request(request_id: str):
|
||||
try:
|
||||
resp = es.get(index=GLOBAL_INDEX_NAME, id=str(request_id))
|
||||
src = resp.get("_source") or {}
|
||||
if (src.get("kind") or "") != "registration_code_manage_request":
|
||||
return None
|
||||
src["_id"] = resp.get("_id")
|
||||
return src
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def list_registration_code_manage_requests(status: str = None, limit: int = 200):
|
||||
try:
|
||||
must = [{"term": {"kind": "registration_code_manage_request"}}]
|
||||
if status:
|
||||
must.append({"term": {"status": str(status)}})
|
||||
body = {
|
||||
"size": max(1, min(int(limit or 200), 500)),
|
||||
"query": {"bool": {"must": must}},
|
||||
"sort": [{"created_at": {"order": "desc"}}],
|
||||
}
|
||||
resp = es.search(index=GLOBAL_INDEX_NAME, body=body)
|
||||
hits = (resp.get("hits") or {}).get("hits") or []
|
||||
out = []
|
||||
for h in hits:
|
||||
src = h.get("_source") or {}
|
||||
src["_id"] = h.get("_id")
|
||||
out.append(src)
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"列出注册码管理申请失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def decide_registration_code_manage_request(request_id: str, status: str, reviewed_by: int, reviewer_note: str = None):
|
||||
try:
|
||||
sid = str(status or "").strip().lower()
|
||||
if sid not in ("approved", "rejected"):
|
||||
return False
|
||||
doc = {
|
||||
"status": sid,
|
||||
"reviewed_at": _rc_request_now_iso(),
|
||||
"reviewed_by": int(reviewed_by),
|
||||
"reviewer_note": str(reviewer_note or ""),
|
||||
}
|
||||
es.update(index=GLOBAL_INDEX_NAME, id=str(request_id), body={"doc": doc})
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"审批注册码管理申请失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
INDEX_NAME = "wordsearch2666661"
|
||||
USER_NAME = "users11111666789"
|
||||
INDEX_NAME = "wordsearch21"
|
||||
USER_NAME = "users16"
|
||||
ACHIEVEMENT_INDEX_NAME = INDEX_NAME
|
||||
USER_INDEX_NAME = USER_NAME
|
||||
GLOBAL_INDEX_NAME = "global11111111"
|
||||
GLOBAL_INDEX_NAME = "global11121"
|
||||
|
||||
@@ -3,226 +3,52 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>数据管理</title>
|
||||
<style>
|
||||
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||||
/* 导航栏样式 */
|
||||
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
|
||||
flex-direction: column;align-items: center;}
|
||||
.user-id {text-align: center;margin-bottom: 0px;}
|
||||
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
|
||||
.navigation-links {width: 100%;margin-top: 60px;}
|
||||
.sidebar a,
|
||||
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||||
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||||
.sidebar a:hover,
|
||||
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 原有样式保持不变 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.06);
|
||||
padding: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 12px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
img {
|
||||
max-width: 120px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
cursor: pointer; /* 添加指针样式 */
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 2px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
.muted {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(0,0,0,0.4);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal .dialog {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
#kvForm {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 搜索区域样式 */
|
||||
.search-container {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #e8f4ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-result.empty {
|
||||
background: #fff8e8;
|
||||
}
|
||||
.search-result.error {
|
||||
background: #ffe8e8;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #1677ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.search-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片放大模态框 */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.9);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.image-modal-content {
|
||||
margin: auto;
|
||||
display: block;
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: contain; /* 保持图片比例,完整显示 */
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 35px;
|
||||
color: #f1f1f1;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
transition: 0.3s;
|
||||
cursor: pointer;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.image-modal-close:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
<style>
|
||||
body{margin:0;font-family:sans-serif;background:#fafafa}
|
||||
.sidebar{position:fixed;top:0;left:0;width:180px;height:100vh;background:#1e1e2e;color:white;padding:20px;box-shadow:2px 0 5px rgba(0,0,0,.1);z-index:1000;display:flex;flex-direction:column;align-items:center}
|
||||
.user-id{text-align:center;margin-bottom:0}
|
||||
.sidebar h3{margin:0;font-size:18px;color:#add8e6;text-align:center;margin-bottom:20px}
|
||||
.navigation-links{width:100%;margin-top:60px}
|
||||
.sidebar a,.sidebar button{display:block;color:#8be9fd;text-decoration:none;margin:10px 0;font-size:16px;padding:15px;border-radius:4px;background:transparent;border:none;cursor:pointer;width:calc(100% - 40px);text-align:left;transition:.2s}
|
||||
.sidebar a:hover,.sidebar button:hover{color:#ff79c6;background-color:rgba(139,233,253,.2)}
|
||||
.main-content{margin-left:200px;padding:20px;color:#333}
|
||||
.container{max-width:1200px;margin:0 auto;background:#fff;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.06);padding:20px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:20px}
|
||||
th,td{border-bottom:1px solid #eee;padding:12px 8px;text-align:left;vertical-align:top}
|
||||
th{background:#f8f9fa;font-weight:600}
|
||||
.inner-table { width: 100%; margin: 0; border: 1px solid #e0e0e0; border-collapse: collapse; table-layout: fixed; }
|
||||
.inner-table th, .inner-table td { border: 1px solid #e0e0e0; padding: 8px; font-size: 13px; word-break: break-all; }
|
||||
.inner-table td:first-child { width: 30%; background-color: #f8fafc; font-weight: 600; color: #475569; }
|
||||
.inner-table td:last-child { width: 70%; background-color: #fff; }
|
||||
.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}
|
||||
.btn-danger{background:#ff4d4f;color:#fff}
|
||||
.btn-secondary{background:#f0f0f0;color:#333}
|
||||
.muted{color:#666;font-size:12px}
|
||||
.modal{position:fixed;inset:0;display:none;background:rgba(0,0,0,.4);align-items:center;justify-content:center;z-index:1000}
|
||||
.modal .dialog{width:720px;max-width:92vw;background:#fff;border-radius:10px;padding:20px;max-height:80vh;overflow-y:auto}
|
||||
textarea{width:100%;min-height:240px;font-family:monospace;font-size:14px;padding:10px;border:1px solid #ddd;border-radius:4px;resize:vertical}
|
||||
#kvForm{border:1px solid #eee;border-radius:6px;padding:8px;max-height:300px;overflow:auto}
|
||||
.search-container{background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px}
|
||||
.search-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.search-input{flex:1;min-width:200px;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}
|
||||
.search-result{margin-top:10px;padding:10px;background:#e8f4ff;border-radius:4px;font-size:14px}
|
||||
.search-result.empty{background:#fff8e8}
|
||||
.search-result.error{background:#ffe8e8}
|
||||
.loading{display:inline-block;width:20px;height:20px;border:3px solid #f3f3f3;border-top:3px solid #1677ff;border-radius:50%;animation:spin 1s linear infinite}
|
||||
@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
|
||||
@media(max-width:768px){.search-controls{flex-direction:column;align-items:stretch}.search-input{min-width:auto}.btn{width:100%;margin:2px 0}}
|
||||
.image-modal{display:none;position:fixed;z-index:2000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.9);overflow:hidden}
|
||||
.image-modal-content{margin:auto;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-width:80%;max-height:80%;object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);cursor:grab;transition:transform .3s ease}
|
||||
.image-modal-content.dragging{cursor:grabbing}
|
||||
.image-modal-close{position:absolute;top:15px;right:35px;color:#f1f1f1;font-size:40px;font-weight:bold;transition:.3s;cursor:pointer;z-index:2001}
|
||||
.image-modal-close:hover{color:#bbb}
|
||||
.zoom-controls{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:2001}
|
||||
.zoom-btn{background:rgba(255,255,255,.7);border:none;border-radius:50%;width:40px;height:40px;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.3);transition:background .3s}
|
||||
.zoom-btn:hover{background:rgba(255,255,255,.9)}
|
||||
.zoom-info{position:absolute;top:15px;left:15px;color:#f1f1f1;font-size:14px;z-index:2001;background:rgba(0,0,0,.5);padding:5px 10px;border-radius:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -243,12 +69,20 @@
|
||||
<div class="main-content">
|
||||
<div class="container">
|
||||
<h2>数据管理</h2>
|
||||
{% if is_admin %}
|
||||
<p class="muted">仅管理员可见。可查看、编辑、删除所有记录。</p>
|
||||
{% else %}
|
||||
<p class="muted">可查看本人及所管理 Key 的上传数据。</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- 搜索功能区域 -->
|
||||
<div class="search-container">
|
||||
<div class="search-controls">
|
||||
<input type="text" id="searchQuery" class="search-input" placeholder="请输入搜索关键词...">
|
||||
<select id="keyFilter" class="search-input"></select>
|
||||
<button class="btn" onclick="clearKeyFilter()">清空Key筛查</button>
|
||||
<select id="typeFilter" class="search-input"></select>
|
||||
<button class="btn" onclick="clearTypeFilter()">清空类型筛查</button>
|
||||
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
|
||||
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
|
||||
<button class="btn" onclick="loadAllData()">显示全部</button>
|
||||
@@ -261,13 +95,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin or has_manage_key %}
|
||||
<div class="search-container" style="margin-top: 12px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">统计报表</div>
|
||||
<div class="search-controls" style="flex-wrap: wrap;">
|
||||
<input type="datetime-local" id="reportFrom" class="search-input" placeholder="开始时间">
|
||||
<input type="datetime-local" id="reportTo" class="search-input" placeholder="结束时间">
|
||||
<select id="reportInterval" class="search-input">
|
||||
<option value="day">按天</option>
|
||||
<option value="week">按周</option>
|
||||
<option value="month">按月</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="generateReport()">生成报表</button>
|
||||
<button class="btn" onclick="downloadReportCsv()">下载CSV</button>
|
||||
</div>
|
||||
<div id="reportBox" class="search-result" style="display: none; margin-top: 10px;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<table id="dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>图片</th>
|
||||
<th>数据</th>
|
||||
<th>时间</th>
|
||||
<th>录入人</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -302,7 +154,13 @@
|
||||
<!-- 图片放大模态框 -->
|
||||
<div id="imageModal" class="image-modal">
|
||||
<span class="image-modal-close">×</span>
|
||||
<div class="zoom-info">缩放: <span id="zoomValue">100%</span></div>
|
||||
<img class="image-modal-content" id="expandedImage">
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" id="zoomOutBtn">-</button>
|
||||
<button class="zoom-btn" id="resetZoomBtn">1:1</button>
|
||||
<button class="zoom-btn" id="zoomInBtn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -315,6 +173,12 @@ function getCookie(name) {
|
||||
|
||||
// DOM元素引用
|
||||
const searchQueryInput = document.getElementById('searchQuery');
|
||||
const keyFilterSelect = document.getElementById('keyFilter');
|
||||
const typeFilterSelect = document.getElementById('typeFilter');
|
||||
const reportFromInput = document.getElementById('reportFrom');
|
||||
const reportToInput = document.getElementById('reportTo');
|
||||
const reportIntervalSelect = document.getElementById('reportInterval');
|
||||
const reportBox = document.getElementById('reportBox');
|
||||
const searchResultDiv = document.getElementById('searchResult');
|
||||
const searchStatus = document.getElementById('searchStatus');
|
||||
const searchCount = document.getElementById('searchCount');
|
||||
@@ -330,12 +194,34 @@ const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||
const imageModal = document.getElementById('imageModal');
|
||||
const expandedImage = document.getElementById('expandedImage');
|
||||
const imageModalClose = document.querySelector('.image-modal-close');
|
||||
const zoomInBtn = document.getElementById('zoomInBtn');
|
||||
const zoomOutBtn = document.getElementById('zoomOutBtn');
|
||||
const resetZoomBtn = document.getElementById('resetZoomBtn');
|
||||
const zoomValue = document.getElementById('zoomValue');
|
||||
|
||||
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
|
||||
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
|
||||
|
||||
// 全局变量
|
||||
let currentId = '';
|
||||
let currentWriter = '';
|
||||
let currentImage = '';
|
||||
let allDataCache = []; // 缓存所有数据,避免重复请求
|
||||
let currentSearchQuery = ''; // 记录当前搜索查询
|
||||
let isFuzzySearch = false; // 记录当前是否为模糊搜索
|
||||
let isDeleting = false; // 标记是否正在删除
|
||||
let currentKeyFilter = '';
|
||||
let currentTypeFilter = '';
|
||||
|
||||
// 图片缩放相关变量
|
||||
let currentScale = 1;
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let isDragging = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let imgStartX = 0;
|
||||
let imgStartY = 0;
|
||||
|
||||
// 搜索功能
|
||||
async function performSearch(type) {
|
||||
@@ -345,6 +231,17 @@ async function performSearch(type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentKeyFilter) {
|
||||
currentKeyFilter = '';
|
||||
if (keyFilterSelect) keyFilterSelect.value = '';
|
||||
}
|
||||
if (currentTypeFilter) {
|
||||
currentTypeFilter = '';
|
||||
if (typeFilterSelect) typeFilterSelect.value = '';
|
||||
}
|
||||
|
||||
currentSearchQuery = query;
|
||||
isFuzzySearch = type === 'fuzzy';
|
||||
showSearchLoading();
|
||||
|
||||
try {
|
||||
@@ -403,9 +300,21 @@ function showSearchMessage(message, type = '') {
|
||||
|
||||
// 加载所有数据
|
||||
async function loadAllData() {
|
||||
currentSearchQuery = '';
|
||||
showSearchLoading();
|
||||
|
||||
try {
|
||||
if (currentKeyFilter || currentTypeFilter) {
|
||||
const response = await fetch(`/elastic/filter/?key=${encodeURIComponent(currentKeyFilter)}&type=${encodeURIComponent(currentTypeFilter)}`);
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
displayAllData(data.data || [], currentKeyFilter, currentTypeFilter);
|
||||
} else {
|
||||
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有缓存,直接使用
|
||||
if (allDataCache.length > 0) {
|
||||
displayAllData(allDataCache);
|
||||
@@ -428,10 +337,13 @@ async function loadAllData() {
|
||||
}
|
||||
|
||||
// 显示所有数据
|
||||
function displayAllData(data) {
|
||||
function displayAllData(data, key) {
|
||||
searchResultDiv.style.display = 'block';
|
||||
searchResultDiv.className = 'search-result';
|
||||
searchStatus.textContent = '显示全部数据';
|
||||
const labels = [];
|
||||
if (key) labels.push(`Key:${key}`);
|
||||
if (currentTypeFilter) labels.push(`类型:${currentTypeFilter}`);
|
||||
searchStatus.textContent = labels.length ? `筛查:${labels.join(',')}` : '显示全部数据';
|
||||
searchCount.textContent = `共 ${data.length} 条记录`;
|
||||
|
||||
renderTable(data);
|
||||
@@ -441,8 +353,13 @@ function displayAllData(data) {
|
||||
function clearSearch() {
|
||||
searchQueryInput.value = '';
|
||||
searchResultDiv.style.display = 'none';
|
||||
currentSearchQuery = '';
|
||||
|
||||
if (currentKeyFilter || currentTypeFilter) {
|
||||
loadAllData();
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有缓存数据,显示全部
|
||||
if (allDataCache.length > 0) {
|
||||
renderTable(allDataCache);
|
||||
} else {
|
||||
@@ -451,6 +368,145 @@ function clearSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initKeyFilter() {
|
||||
if (!keyFilterSelect) return;
|
||||
keyFilterSelect.innerHTML = '<option value="">全部Key</option>';
|
||||
try {
|
||||
const resp = await fetch('/elastic/keys-for-filter/', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (data.status !== 'success') return;
|
||||
const keys = data.data || [];
|
||||
keys.forEach(k => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(k || '');
|
||||
opt.textContent = String(k || '');
|
||||
keyFilterSelect.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
}
|
||||
keyFilterSelect.addEventListener('change', () => {
|
||||
currentKeyFilter = (keyFilterSelect.value || '').trim();
|
||||
loadAllData();
|
||||
});
|
||||
}
|
||||
|
||||
function clearKeyFilter() {
|
||||
currentKeyFilter = '';
|
||||
if (keyFilterSelect) keyFilterSelect.value = '';
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
async function initTypeFilter() {
|
||||
if (!typeFilterSelect) return;
|
||||
typeFilterSelect.innerHTML = '<option value="">全部类型</option>';
|
||||
try {
|
||||
const resp = await fetch('/elastic/types-for-filter/', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (data.status !== 'success') return;
|
||||
const types = data.data || [];
|
||||
types.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(t || '');
|
||||
opt.textContent = String(t || '');
|
||||
typeFilterSelect.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
}
|
||||
typeFilterSelect.addEventListener('change', () => {
|
||||
currentTypeFilter = (typeFilterSelect.value || '').trim();
|
||||
loadAllData();
|
||||
});
|
||||
}
|
||||
|
||||
function clearTypeFilter() {
|
||||
currentTypeFilter = '';
|
||||
if (typeFilterSelect) typeFilterSelect.value = '';
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
function buildReportParams() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentKeyFilter) params.set('key', currentKeyFilter);
|
||||
if (currentTypeFilter) params.set('type', currentTypeFilter);
|
||||
const iv = (reportIntervalSelect && reportIntervalSelect.value) ? reportIntervalSelect.value : 'day';
|
||||
params.set('interval', iv);
|
||||
const fromVal = reportFromInput ? (reportFromInput.value || '').trim() : '';
|
||||
const toVal = reportToInput ? (reportToInput.value || '').trim() : '';
|
||||
if (fromVal) params.set('from', fromVal);
|
||||
if (toVal) params.set('to', toVal);
|
||||
return params;
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
if (!reportBox) return;
|
||||
reportBox.style.display = 'block';
|
||||
reportBox.className = 'search-result';
|
||||
reportBox.innerHTML = '<div>正在生成报表...</div>';
|
||||
try {
|
||||
const params = buildReportParams();
|
||||
const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' });
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.status !== 'success') {
|
||||
reportBox.className = 'search-result error';
|
||||
reportBox.innerHTML = `<div>生成失败:${data.message || '未知错误'}</div>`;
|
||||
return;
|
||||
}
|
||||
const r = data.data || {};
|
||||
const total = r.total || 0;
|
||||
const byType = r.by_type || [];
|
||||
const byTime = r.by_time || [];
|
||||
const rng = r.range || {};
|
||||
const flt = r.filters || {};
|
||||
const lines = [];
|
||||
const filterParts = [];
|
||||
if (flt.key) filterParts.push(`Key:${flt.key}`);
|
||||
if (flt.type) filterParts.push(`类型:${flt.type}`);
|
||||
if (flt.interval) filterParts.push(`粒度:${flt.interval}`);
|
||||
lines.push(`<div style="font-weight: 600;">总数:${total}</div>`);
|
||||
if (rng.from || rng.to) {
|
||||
lines.push(`<div class="muted" style="margin-top: 4px;">时间范围:${(rng.from || '')} ~ ${(rng.to || '')}</div>`);
|
||||
}
|
||||
if (filterParts.length) {
|
||||
lines.push(`<div class="muted" style="margin-top: 4px;">筛查:${filterParts.join(',')}</div>`);
|
||||
}
|
||||
|
||||
const typeRows = byType.map(it => `<tr><td style="padding:4px 8px;">${it.type || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
|
||||
const timeRows = byTime.map(it => `<tr><td style="padding:4px 8px;">${it.bucket || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
|
||||
|
||||
lines.push(`<div style="display:flex; gap:12px; flex-wrap: wrap; margin-top: 10px;">
|
||||
<div style="min-width: 260px; flex: 1;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">按成果类型</div>
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">类型</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
|
||||
<tbody>${typeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="min-width: 260px; flex: 1;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">按时间</div>
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">时间</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
|
||||
<tbody>${timeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
reportBox.innerHTML = lines.join('');
|
||||
} catch (e) {
|
||||
reportBox.className = 'search-result error';
|
||||
reportBox.innerHTML = `<div>生成失败:${e.message || '未知错误'}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadReportCsv() {
|
||||
const params = buildReportParams();
|
||||
window.location.href = `/elastic/report/csv/?${params.toString()}`;
|
||||
}
|
||||
|
||||
// 渲染表格
|
||||
function renderTable(data) {
|
||||
tableBody.innerHTML = '';
|
||||
@@ -470,22 +526,52 @@ function renderTable(data) {
|
||||
|
||||
// 解析data字段,如果是JSON字符串则格式化显示
|
||||
let displayData = item.data || '';
|
||||
let parsed = null;
|
||||
try {
|
||||
const parsed = JSON.parse(item.data);
|
||||
displayData = JSON.stringify(parsed, null, 2);
|
||||
if (typeof displayData === 'object' && displayData !== null) {
|
||||
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) {
|
||||
// 如果不是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 = `
|
||||
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</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>
|
||||
<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 style="font-size: 12px;">${item.writer_id || ''}</td>
|
||||
<td style="font-size: 12px; white-space: nowrap;">${formatDateTime(item.time)}</td>
|
||||
<td style="font-size: 12px;">${item.writer_name || item.writer_id || ''}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" onclick="openEdit('${item._id || item.id}')">编辑</button>
|
||||
<button class="btn btn-danger" onclick="doDelete('${item._id || item.id}')">删除</button>
|
||||
@@ -495,6 +581,24 @@ function renderTable(data) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(t) {
|
||||
if (!t) return '';
|
||||
try {
|
||||
const d = new Date(t);
|
||||
if (String(d) === 'Invalid Date') return String(t);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} catch (e) {
|
||||
return String(t);
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageCell(item) {
|
||||
const urls = Array.isArray(item.image_urls) ? item.image_urls : (item.image_url ? [item.image_url] : []);
|
||||
if (!urls.length) return '无图片';
|
||||
return urls.map(u => `<img src="${u}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="${u}" />`).join('');
|
||||
}
|
||||
|
||||
// 转义HTML以防止XSS
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
@@ -548,16 +652,24 @@ function createRow(k = '', v = '') {
|
||||
}
|
||||
|
||||
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])));
|
||||
if (!kvForm.children.length) kvForm.appendChild(createRow());
|
||||
if (kvForm.querySelectorAll('div[style*="grid"]').length <= 1) kvForm.appendChild(createRow());
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
function formToObject(){
|
||||
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');
|
||||
if (!kI || !vI) return;
|
||||
const k=(kI.value||'').trim(); if(!k) return;
|
||||
const raw=vI.value;
|
||||
try{
|
||||
@@ -647,15 +759,9 @@ async function saveEdit(){
|
||||
alert('保存成功');
|
||||
closeModal();
|
||||
// 重新加载数据以显示更新
|
||||
if (searchResultDiv.style.display !== 'none') {
|
||||
if (currentSearchQuery) {
|
||||
// 如果当前显示的是搜索结果,重新执行搜索
|
||||
const query = searchQueryInput.value.trim();
|
||||
if (query) {
|
||||
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
|
||||
performSearch(isFuzzy ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
@@ -665,8 +771,20 @@ async function saveEdit(){
|
||||
}
|
||||
|
||||
async function doDelete(id){
|
||||
if (isDeleting) {
|
||||
alert('正在处理删除操作,请稍候...');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!confirm('确认删除该记录?此操作不可撤销')) return;
|
||||
|
||||
isDeleting = true;
|
||||
const deleteButton = document.querySelector(`button[onclick="doDelete('${id}')"]`);
|
||||
if (deleteButton) {
|
||||
deleteButton.disabled = true;
|
||||
deleteButton.textContent = '删除中...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/elastic/data/${id}/delete/`, {
|
||||
method:'DELETE',
|
||||
@@ -678,25 +796,50 @@ async function doDelete(id){
|
||||
if(data.status!=='success') throw new Error(data.message || '删除失败');
|
||||
|
||||
alert('删除成功');
|
||||
// 重新加载数据
|
||||
if (searchResultDiv.style.display !== 'none') {
|
||||
const query = searchQueryInput.value.trim();
|
||||
if (query) {
|
||||
const isFuzzy = document.querySelector('.search-result').textContent.includes('模糊');
|
||||
performSearch(isFuzzy ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
}
|
||||
// 清空缓存,确保下次加载获取最新数据
|
||||
allDataCache = [];
|
||||
|
||||
// 根据当前显示状态重新加载数据
|
||||
if (currentSearchQuery) {
|
||||
// 如果当前显示的是搜索结果,重新执行搜索
|
||||
performSearch(isFuzzySearch ? 'fuzzy' : 'exact');
|
||||
} else {
|
||||
loadAllData();
|
||||
// 修复:重新加载所有数据时,强制刷新缓存
|
||||
const response = await fetch('/elastic/all-data/');
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
allDataCache = data.data || [];
|
||||
displayAllData(allDataCache);
|
||||
} else {
|
||||
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message||'删除失败');
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
if (deleteButton) {
|
||||
deleteButton.disabled = false;
|
||||
deleteButton.textContent = '删除';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时自动加载所有数据
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initKeyFilter();
|
||||
initTypeFilter();
|
||||
if (reportFromInput && reportToInput && reportIntervalSelect) {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const toLocal = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
if (!reportToInput.value) reportToInput.value = toLocal(now);
|
||||
if (!reportFromInput.value) {
|
||||
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
reportFromInput.value = toLocal(from);
|
||||
}
|
||||
if (!reportIntervalSelect.value) reportIntervalSelect.value = 'day';
|
||||
}
|
||||
loadAllData();
|
||||
});
|
||||
|
||||
@@ -727,6 +870,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() {
|
||||
// 为所有图片添加点击事件监听器
|
||||
@@ -735,6 +902,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const imgSrc = e.target.src;
|
||||
expandedImage.src = imgSrc;
|
||||
imageModal.style.display = 'block';
|
||||
|
||||
// 重置缩放状态
|
||||
resetZoom();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -749,7 +919,92 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
imageModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放按钮事件
|
||||
zoomInBtn.addEventListener('click', zoomIn);
|
||||
zoomOutBtn.addEventListener('click', zoomOut);
|
||||
resetZoomBtn.addEventListener('click', resetZoom);
|
||||
|
||||
// 鼠标滚轮缩放
|
||||
expandedImage.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0) {
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽功能
|
||||
expandedImage.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
imgStartX = currentX;
|
||||
imgStartY = currentY;
|
||||
expandedImage.classList.add('dragging');
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStartX;
|
||||
const deltaY = e.clientY - dragStartY;
|
||||
currentX = imgStartX + deltaX / currentScale;
|
||||
currentY = imgStartY + deltaY / currentScale;
|
||||
updateZoom();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
expandedImage.classList.remove('dragging');
|
||||
});
|
||||
|
||||
// 触摸事件支持(移动端)
|
||||
expandedImage.addEventListener('touchstart', function(e) {
|
||||
if (e.touches.length === 1) {
|
||||
isDragging = true;
|
||||
dragStartX = e.touches[0].clientX;
|
||||
dragStartY = e.touches[0].clientY;
|
||||
imgStartX = currentX;
|
||||
imgStartY = currentY;
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指缩放
|
||||
initialDistance = getDistance(e.touches[0], e.touches[1]);
|
||||
initialScale = currentScale;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
e.preventDefault();
|
||||
if (isDragging && e.touches.length === 1) {
|
||||
const deltaX = e.touches[0].clientX - dragStartX;
|
||||
const deltaY = e.touches[0].clientY - dragStartY;
|
||||
currentX = imgStartX + deltaX / currentScale;
|
||||
currentY = imgStartY + deltaY / currentScale;
|
||||
updateZoom();
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指缩放
|
||||
const currentDistance = getDistance(e.touches[0], e.touches[1]);
|
||||
const scale = (currentDistance / initialDistance) * initialScale;
|
||||
currentScale = Math.max(0.1, Math.min(scale, 10)); // 限制缩放范围
|
||||
updateZoom();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', function() {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
// 计算两点间距离
|
||||
function getDistance(touch1, touch2) {
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
.btn { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; margin:0 4px; }
|
||||
.btn-primary { background:#4f46e5; color:#fff; }
|
||||
.btn-secondary { background:#64748b; color:#fff; }
|
||||
.btn-danger { background:#ff4d4f; color:#fff; }
|
||||
.btn-danger:hover { background:#ff7875 !important; }
|
||||
.btn-primary:hover { background:#6366f1 !important; }
|
||||
.btn-secondary:hover { background:#94a3b8 !important; }
|
||||
.notice { padding:10px; border-radius:6px; margin-top:10px; display:none; }
|
||||
.notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
|
||||
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
|
||||
@@ -34,6 +38,16 @@
|
||||
</style>
|
||||
{% csrf_token %}
|
||||
<script>
|
||||
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
|
||||
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
|
||||
const CAN_MANAGE_REG = {{ can_manage_registration_codes|yesno:"true,false" }};
|
||||
const MY_KEYS_RAW = JSON.parse('{{ my_keys_json|default:"[]"|escapejs }}');
|
||||
const MY_KEYS_SET = new Set((Array.isArray(MY_KEYS_RAW) ? MY_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
|
||||
const MY_MANAGE_KEYS_RAW = JSON.parse('{{ manage_keys_json|default:"[]"|escapejs }}');
|
||||
const MY_MANAGE_KEYS_SET = new Set((Array.isArray(MY_MANAGE_KEYS_RAW) ? MY_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
|
||||
const ALLOWED_MANAGE_KEYS_RAW = JSON.parse('{{ allowed_manage_keys_json|default:"[]"|escapejs }}');
|
||||
const ALLOWED_MANAGE_KEYS_SET = new Set((Array.isArray(ALLOWED_MANAGE_KEYS_RAW) ? ALLOWED_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
|
||||
|
||||
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
|
||||
async function loadKeys(){
|
||||
const resp=await fetch('/elastic/registration-codes/keys/');
|
||||
@@ -44,8 +58,17 @@
|
||||
keySel.innerHTML=''; mkeySel.innerHTML='';
|
||||
opts.forEach(k=>{
|
||||
const o=document.createElement('option'); o.value=k; o.textContent=k; keySel.appendChild(o);
|
||||
const o2=document.createElement('option'); o2.value=k; o2.textContent=k; mkeySel.appendChild(o2);
|
||||
const o2=document.createElement('option'); o2.value=k; o2.textContent=k;
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
const v = String(k || '').trim();
|
||||
if (v && !ALLOWED_MANAGE_KEYS_SET.has(v)) o2.disabled = true;
|
||||
}
|
||||
mkeySel.appendChild(o2);
|
||||
});
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
Array.from(keySel.options).forEach(o => { if (MY_KEYS_SET.has(String(o.value || '').trim())) o.selected = true; });
|
||||
Array.from(mkeySel.options).forEach(o => { o.selected = false; });
|
||||
}
|
||||
}
|
||||
async function addKey(){
|
||||
const keyName=(document.getElementById('newKey').value||'').trim();
|
||||
@@ -54,17 +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 data=await resp.json();
|
||||
const msg=document.getElementById('msg');
|
||||
if(resp.ok && data.status==='success'){msg.textContent='新增key成功'; msg.className='notice success'; msg.style.display='block'; document.getElementById('newKey').value=''; loadKeys();}
|
||||
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';}
|
||||
}
|
||||
function selectedValues(sel){return Array.from(sel.selectedOptions).map(o=>o.value);}
|
||||
function enableToggleSelect(sel){ sel.addEventListener('mousedown',function(e){ if(e.target && e.target.tagName==='OPTION'){ e.preventDefault(); const op=e.target; op.selected=!op.selected; this.dispatchEvent(new Event('change',{bubbles:true})); } }); }
|
||||
function clearSelection(id){ const sel=document.getElementById(id); Array.from(sel.options).forEach(o=>o.selected=false); }
|
||||
async function deleteSelectedKey(){
|
||||
const keySel = document.getElementById('keys');
|
||||
const mkeySel = document.getElementById('manageKeys');
|
||||
|
||||
// 优先获取左侧选中的,如果没有则获取右侧选中的
|
||||
const selectedKey = keySel.value || mkeySel.value;
|
||||
|
||||
if(!selectedKey){
|
||||
alert('请先在下方列表中选择一个要删除的Key');
|
||||
return;
|
||||
}
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
const v = String(selectedKey || '').trim();
|
||||
if (!v || !ALLOWED_MANAGE_KEYS_SET.has(v)) {
|
||||
const msg=document.getElementById('msg');
|
||||
msg.textContent='只能删除自己新增的 key';
|
||||
msg.className='notice error';
|
||||
msg.style.display='block';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(!confirm(`确定要全局删除Key \"${selectedKey}\" 吗?\n该操作将:\n1. 从全局可选Key列表中移除\n2. 从所有包含此Key的注册码中同步清除\n此操作不可恢复!`)) return;
|
||||
|
||||
const ov=document.getElementById('overlay'); ov.style.display='flex';
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const url = '/elastic/registration-codes/keys/remove/';
|
||||
const resp=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({key:selectedKey})});
|
||||
const data=await resp.json();
|
||||
const msg=document.getElementById('msg');
|
||||
if(resp.ok && data.status==='success'){
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
ALLOWED_MANAGE_KEYS_SET.delete(String(selectedKey||'').trim());
|
||||
}
|
||||
msg.textContent = data.message || '删除成功';
|
||||
msg.className='notice success';
|
||||
msg.style.display='block';
|
||||
loadKeys(); // 重新加载keys列表
|
||||
loadCodes(); // 重新加载注册码列表
|
||||
} else {
|
||||
msg.textContent=data.message||'删除失败';
|
||||
msg.className='notice error';
|
||||
msg.style.display='block';
|
||||
}
|
||||
ov.style.display='none';
|
||||
}
|
||||
function selectedValues(sel){return Array.from(sel.selectedOptions).map(o=>o.value);}
|
||||
function enableToggleSelect(sel){
|
||||
sel.addEventListener('mousedown', function(e){
|
||||
if(e.target && e.target.tagName==='OPTION'){
|
||||
e.preventDefault();
|
||||
const op=e.target;
|
||||
if (op.disabled) return;
|
||||
op.selected = !op.selected;
|
||||
this.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
}
|
||||
});
|
||||
}
|
||||
function clearSelection(id){
|
||||
const sel=document.getElementById(id);
|
||||
Array.from(sel.options).forEach(o=>{ o.selected = false; });
|
||||
}
|
||||
async function generateCode(){
|
||||
const ov=document.getElementById('overlay'); ov.style.display='flex';
|
||||
const csrftoken=getCookie('csrftoken');
|
||||
const keys=selectedValues(document.getElementById('keys'));
|
||||
const manageKeys=selectedValues(document.getElementById('manageKeys'));
|
||||
const keySel = document.getElementById('keys');
|
||||
let keys=selectedValues(keySel);
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
const selected = new Set(keys.map(k=>String(k||'').trim()).filter(Boolean));
|
||||
const missing = Array.from(MY_KEYS_SET).filter(k => !selected.has(k));
|
||||
if (missing.length) {
|
||||
const msg=document.getElementById('msg');
|
||||
msg.textContent = `必须选择导师原有的 key:${missing.join('、')}`;
|
||||
msg.className='notice error';
|
||||
msg.style.display='block';
|
||||
ov.style.display='none';
|
||||
return;
|
||||
}
|
||||
}
|
||||
let manageKeys=selectedValues(document.getElementById('manageKeys'));
|
||||
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
|
||||
const hasForbidden = manageKeys.some(k => !ALLOWED_MANAGE_KEYS_SET.has(String(k || '').trim()));
|
||||
if (hasForbidden) {
|
||||
const msg=document.getElementById('msg');
|
||||
msg.textContent='manage_key 只能选择本页新增的 key';
|
||||
msg.className='notice error';
|
||||
msg.style.display='block';
|
||||
ov.style.display='none';
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mode=document.getElementById('expireMode').value;
|
||||
let days=30; if(mode==='month') days=30; else if(mode==='fouryears') days=1460; else { const d=parseInt(document.getElementById('customDays').value||'30'); days=isNaN(d)?30:Math.max(1,d);}
|
||||
const resp=await fetch('/elastic/registration-codes/generate/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({keys,manage_keys:manageKeys,expires_in_days:days})});
|
||||
@@ -97,7 +207,12 @@
|
||||
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();});
|
||||
document.addEventListener('DOMContentLoaded',()=>{
|
||||
loadKeys();
|
||||
enableToggleSelect(document.getElementById('keys'));
|
||||
enableToggleSelect(document.getElementById('manageKeys'));
|
||||
loadCodes();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -114,23 +229,32 @@
|
||||
<div class="main">
|
||||
<div class="card fade-in">
|
||||
<h2>管理注册码</h2>
|
||||
{% if is_admin or has_manage_key or can_manage_registration_codes %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>新增key</label>
|
||||
<input id="newKey" type="text" placeholder="输入新的key" />
|
||||
<button class="btn btn-secondary" onclick="addKey()">新增</button>
|
||||
<label>管理 Key</label>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input id="newKey" type="text" placeholder="输入新的key进行新增,或在下方选择后删除" style="flex: 1;" />
|
||||
<button class="btn btn-secondary" onclick="addKey()">新增 Key</button>
|
||||
{% if is_admin or has_manage_key %}
|
||||
<button class="btn btn-danger" onclick="deleteSelectedKey()">删除选中 Key</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row" style="margin-top:12px;">
|
||||
<div class="col">
|
||||
<label>选择keys</label>
|
||||
<label>选择 keys</label>
|
||||
<select id="keys" multiple size="10"></select>
|
||||
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('keys')">清空选择</button></div>
|
||||
<div style="margin-top:8px;"><button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('keys')">清空 keys 选择</button></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>选择manage_keys</label>
|
||||
<label>选择 manage_keys</label>
|
||||
<select id="manageKeys" multiple size="10"></select>
|
||||
<div style="margin-top:8px;"><button class="btn btn-secondary" onclick="clearSelection('manageKeys')">清空选择</button></div>
|
||||
<div style="margin-top:8px;">
|
||||
<button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('manageKeys')">清空 manage_keys 选择</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:12px;">
|
||||
@@ -338,4 +462,4 @@
|
||||
loadRecent();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -42,18 +42,38 @@
|
||||
.preview-box {flex: 1; text-align: center; }
|
||||
.preview-box h3 {margin-top: 0;color: #334155; }
|
||||
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
|
||||
.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 h3 { margin-top: 0; color: #334155;}
|
||||
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
||||
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
|
||||
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
|
||||
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
|
||||
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
|
||||
border-radius: 8px; resize: vertical;box-sizing: border-box; }
|
||||
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
|
||||
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.result-box h3 { margin-top: 0; color: #334155;}
|
||||
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
||||
.pending-item { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
|
||||
.pending-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; padding-bottom: 12px; }
|
||||
.pending-item-title { font-weight: 600; color: #1e293b; font-size: 16px; }
|
||||
.pending-item-body { display: flex; gap: 20px; }
|
||||
.pending-item-preview { flex: 0 0 240px; }
|
||||
.pending-item-preview img { width: 100%; border-radius: 8px; border: 1px solid #f1f5f9; }
|
||||
.pending-item-edit { flex: 1; }
|
||||
.pending-item-footer { margin-top: 16px; text-align: right; }
|
||||
@media (max-width: 992px) {
|
||||
.pending-item-body { flex-direction: column; }
|
||||
.pending-item-preview { flex: 0 0 auto; }
|
||||
}
|
||||
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; align-items: center;}
|
||||
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px; width: 100%; box-sizing: border-box;}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -75,37 +95,36 @@
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>图片上传与识别</h2>
|
||||
<p>选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||
<h2>图片与PDF上传识别</h2>
|
||||
<p>选择图片或PDF文件后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-section" id="dropArea">
|
||||
<h3>上传图片</h3>
|
||||
<p>点击下方按钮选择图片,或拖拽图片到此区域</p>
|
||||
<h3>上传文件</h3>
|
||||
<p>点击下方按钮选择图片或PDF文件,或拖拽文件到此区域</p>
|
||||
<p style="margin: 8px 0 0; font-size: 13px; color: #64748b;">单次最多上传 {{ max_single_upload_count|default:"3" }} 个文件。</p>
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
{% 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>
|
||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||
</form>
|
||||
<div class="status-message" id="uploadMsg"></div>
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
|
||||
<div class="progress-text" id="progressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="preview-box">
|
||||
<h3>图片预览</h3>
|
||||
<img id="preview" alt="预览" />
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<h3>识别结果(可编辑)</h3>
|
||||
<div class="form-controls">
|
||||
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||
<h3>待处理文件列表</h3>
|
||||
<div id="pendingItems" class="pending-list">
|
||||
<!-- 这里将动态生成每个文件的预览和编辑区域 -->
|
||||
</div>
|
||||
<div id="kvForm"></div>
|
||||
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,18 +145,60 @@ function getCookie(name) {
|
||||
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const preview = document.getElementById('preview');
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
const fileHint = document.getElementById('fileHint');
|
||||
const previewList = document.getElementById('previewList');
|
||||
const pendingItems = document.getElementById('pendingItems');
|
||||
const uploadMsg = document.getElementById('uploadMsg');
|
||||
const confirmBtn = document.getElementById('confirmBtn');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
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 progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const MAX_SINGLE_UPLOAD_COUNT = Number('{{ max_single_upload_count|default:"3" }}');
|
||||
|
||||
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 => {
|
||||
@@ -171,25 +232,85 @@ function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
const event = new Event('change', { bubbles: true });
|
||||
fileInput.dispatchEvent(event);
|
||||
addFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件选择后预览
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.src = e.target.result;
|
||||
function setPreviewList(urls) {
|
||||
previewList.innerHTML = '';
|
||||
(urls || []).forEach((url, index) => {
|
||||
if (!url) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'preview-item';
|
||||
item.dataset.index = String(index);
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = '预览';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'preview-remove';
|
||||
btn.textContent = '×';
|
||||
btn.onclick = () => {
|
||||
const idx = Number(item.dataset.index);
|
||||
if (!Number.isNaN(idx)) {
|
||||
selectedFiles.splice(idx, 1);
|
||||
const urls = selectedFiles.map(f => {
|
||||
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZjQ0NDQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQgMmgyYTIgMiAwIDAgMSAyIDJ2MTZhMiAyIDAgMCAxLTIgMmgtMTJhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJoMiIvPjxwYXRoIGQ9Ik0xNCAydjRjMCAxLjEgLjkgMiAyIDJoNCIvPjxwYXRoIGQ9Ik03IDloNSIvPjxwYXRoIGQ9Ik03IDEzaDUiLz48cGF0aCBkPSJNNyAxN2g4Ii8+PC9zdmc+';
|
||||
}
|
||||
return URL.createObjectURL(f);
|
||||
});
|
||||
setPreviewList(urls);
|
||||
updateFileHint();
|
||||
setTimeout(() => urls.forEach(u => { if (u.startsWith('blob:')) URL.revokeObjectURL(u); }), 0);
|
||||
}
|
||||
};
|
||||
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}`));
|
||||
const rejected = [];
|
||||
incoming.forEach(f => {
|
||||
const key = `${f.name}|${f.size}|${f.lastModified}`;
|
||||
if (!existingKeys.has(key) && selectedFiles.length < MAX_SINGLE_UPLOAD_COUNT) {
|
||||
existingKeys.add(key);
|
||||
selectedFiles.push(f);
|
||||
} else if (!existingKeys.has(key) && selectedFiles.length >= MAX_SINGLE_UPLOAD_COUNT) {
|
||||
rejected.push(f.name);
|
||||
}
|
||||
});
|
||||
if (rejected.length) {
|
||||
uploadMsg.textContent = `单次最多上传 ${MAX_SINGLE_UPLOAD_COUNT} 个文件,以下文件未加入:${rejected.join('、')}`;
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
}
|
||||
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');
|
||||
row.className = 'form-row';
|
||||
const keyInput = document.createElement('input');
|
||||
@@ -204,123 +325,224 @@ function createRow(k = '', v = '') {
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-danger';
|
||||
delBtn.textContent = '删除';
|
||||
|
||||
delBtn.onclick = () => {
|
||||
if (kvForm.children.length > 1) {
|
||||
kvForm.removeChild(row);
|
||||
const container = row.parentElement;
|
||||
if (container.querySelectorAll('.form-row').length > 1) {
|
||||
container.removeChild(row);
|
||||
} else {
|
||||
keyInput.value = '';
|
||||
valInput.value = '';
|
||||
}
|
||||
syncTextarea();
|
||||
if (onInput) onInput();
|
||||
};
|
||||
keyInput.oninput = syncTextarea;
|
||||
valInput.oninput = syncTextarea;
|
||||
|
||||
keyInput.oninput = onInput;
|
||||
valInput.oninput = onInput;
|
||||
|
||||
row.appendChild(keyInput);
|
||||
row.appendChild(valInput);
|
||||
row.appendChild(delBtn);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFormFromObject(obj) {
|
||||
kvForm.innerHTML = '';
|
||||
Object.keys(obj || {}).forEach(k => {
|
||||
kvForm.appendChild(createRow(k, obj[k]));
|
||||
});
|
||||
if (!kvForm.children.length) kvForm.appendChild(createRow());
|
||||
syncTextarea();
|
||||
}
|
||||
function renderPendingItems(items) {
|
||||
pendingItems.innerHTML = '';
|
||||
currentItems = items;
|
||||
|
||||
function objectFromForm() {
|
||||
const obj = {};
|
||||
Array.from(kvForm.children).forEach(row => {
|
||||
const [kInput, vInput] = row.querySelectorAll('input');
|
||||
const k = (kInput.value || '').trim();
|
||||
if (!k) return;
|
||||
const raw = vInput.value;
|
||||
try {
|
||||
obj[k] = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
obj[k] = raw;
|
||||
items.forEach((item, index) => {
|
||||
const itemEl = document.createElement('div');
|
||||
itemEl.className = 'pending-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'pending-item-header';
|
||||
header.innerHTML = `<span class="pending-item-title">${index + 1}. ${item.name}</span>`;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn btn-danger';
|
||||
removeBtn.textContent = '忽略此项';
|
||||
removeBtn.onclick = () => {
|
||||
currentItems.splice(index, 1);
|
||||
renderPendingItems(currentItems);
|
||||
};
|
||||
header.appendChild(removeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'pending-item-body';
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'pending-item-preview';
|
||||
const mainImg = document.createElement('img');
|
||||
mainImg.src = item.image_urls[0];
|
||||
preview.appendChild(mainImg);
|
||||
if (item.image_urls.length > 1) {
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'muted';
|
||||
hint.style.textAlign = 'center';
|
||||
hint.textContent = `共 ${item.image_urls.length} 页`;
|
||||
preview.appendChild(hint);
|
||||
}
|
||||
|
||||
const edit = document.createElement('div');
|
||||
edit.className = 'pending-item-edit';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'form-controls';
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'btn btn-secondary';
|
||||
addBtn.textContent = '添加字段';
|
||||
const syncBtn = document.createElement('button');
|
||||
syncBtn.className = 'btn btn-secondary';
|
||||
syncBtn.textContent = '刷新表单';
|
||||
controls.appendChild(addBtn);
|
||||
controls.appendChild(syncBtn);
|
||||
|
||||
const kvForm = document.createElement('div');
|
||||
kvForm.className = 'kv-form-container';
|
||||
kvForm.innerHTML = '<div class="form-header"><div>字段名</div><div>字段值</div><div>操作</div></div>';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'result-textarea';
|
||||
|
||||
const syncData = () => {
|
||||
const obj = {};
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
uploadMsg.textContent = '';
|
||||
confirmMsg.textContent = '';
|
||||
confirmBtn.disabled = true;
|
||||
resultBox.value = '';
|
||||
currentImageRel = '';
|
||||
previewList.innerHTML = '';
|
||||
pendingItems.innerHTML = '';
|
||||
currentItems = [];
|
||||
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
uploadMsg.textContent = '请选择图片文件';
|
||||
if (!selectedFiles.length) {
|
||||
uploadMsg.textContent = '请选择文件';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (selectedFiles.length > MAX_SINGLE_UPLOAD_COUNT) {
|
||||
uploadMsg.textContent = `单次最多上传 ${MAX_SINGLE_UPLOAD_COUNT} 个文件,请分批上传`;
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
showProgress();
|
||||
setProgress(5, '预处理中');
|
||||
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 {
|
||||
let prog = 50;
|
||||
setProgress(prog, '识别中');
|
||||
const timer = setInterval(() => {
|
||||
prog = Math.min(95, prog + 1);
|
||||
setProgress(prog, '识别中');
|
||||
}, 200);
|
||||
|
||||
const resp = await fetch('/elastic/upload/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
||||
body: formData,
|
||||
});
|
||||
clearInterval(timer);
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.status !== 'success') {
|
||||
throw new Error(data.message || '上传识别失败');
|
||||
}
|
||||
setProgress(100, '识别完成');
|
||||
uploadMsg.textContent = data.message || '识别成功';
|
||||
uploadMsg.className = 'status-message success';
|
||||
uploadMsg.style.display = 'block';
|
||||
preview.src = data.image_url;
|
||||
renderFormFromObject(data.data || {});
|
||||
currentImageRel = data.image;
|
||||
confirmBtn.disabled = false;
|
||||
|
||||
renderPendingItems(data.items || []);
|
||||
setTimeout(hideProgress, 800);
|
||||
} catch (e) {
|
||||
uploadMsg.textContent = e.message || '发生错误';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
progressText.textContent = '识别失败';
|
||||
}
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
confirmMsg.textContent = '';
|
||||
confirmMsg.textContent = '正在录入...';
|
||||
try {
|
||||
const edited = objectFromForm();
|
||||
const payload = {
|
||||
items: currentItems.map(it => ({
|
||||
data: it.data,
|
||||
image: it.images
|
||||
}))
|
||||
};
|
||||
const resp = await fetch('/elastic/confirm/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -328,7 +550,7 @@ confirmBtn.addEventListener('click', async () => {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken') || ''
|
||||
},
|
||||
body: JSON.stringify({ data: edited, image: currentImageRel })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.status !== 'success') {
|
||||
@@ -336,6 +558,12 @@ confirmBtn.addEventListener('click', async () => {
|
||||
}
|
||||
confirmMsg.textContent = data.message || '录入成功';
|
||||
confirmMsg.style.color = '#179957';
|
||||
// 录入成功后清空待处理列表
|
||||
pendingItems.innerHTML = '';
|
||||
currentItems = [];
|
||||
selectedFiles = [];
|
||||
updateFileHint();
|
||||
confirmBtn.disabled = true;
|
||||
} catch (e) {
|
||||
confirmMsg.textContent = e.message || '发生错误';
|
||||
confirmMsg.style.color = '#d14343';
|
||||
@@ -344,15 +572,18 @@ confirmBtn.addEventListener('click', async () => {
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
preview.src = '';
|
||||
resultBox.value = '';
|
||||
kvForm.innerHTML = '';
|
||||
kvForm.appendChild(createRow()); // 保留一个空行
|
||||
previewList.innerHTML = '';
|
||||
pendingItems.innerHTML = '';
|
||||
uploadMsg.textContent = '';
|
||||
confirmMsg.textContent = '';
|
||||
confirmBtn.disabled = true;
|
||||
currentItems = [];
|
||||
selectedFiles = [];
|
||||
updateFileHint();
|
||||
});
|
||||
|
||||
updateFileHint();
|
||||
|
||||
// 退出登录处理
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
const msg = document.getElementById('logoutMsg');
|
||||
@@ -381,4 +612,4 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -134,6 +134,13 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.search-container select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search-container button {
|
||||
padding: 8px 15px;
|
||||
background: #4f46e5;
|
||||
@@ -156,7 +163,7 @@
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
margin: 10% auto;
|
||||
margin: 6% auto;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 80%;
|
||||
@@ -200,6 +207,71 @@
|
||||
margin-top: 5px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -216,25 +288,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-content">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2>用户管理</h2>
|
||||
<button id="addUserBtn" class="btn btn-primary">添加用户</button>
|
||||
{% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="notification success" id="successNotification">
|
||||
操作成功!
|
||||
</div>
|
||||
<div class="notification error" id="errorNotification">
|
||||
操作失败!
|
||||
</div>
|
||||
<div class="notification success" id="successNotification">操作成功!</div>
|
||||
<div class="notification error" id="errorNotification">操作失败!</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="搜索用户名...">
|
||||
<select id="keyFilter"></select>
|
||||
<button id="searchBtn">搜索</button>
|
||||
<button id="resetBtn">重置</button>
|
||||
<button id="clearKeyBtn">清空Key</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
@@ -243,13 +312,13 @@
|
||||
<tr>
|
||||
<th>用户ID</th>
|
||||
<th>用户名</th>
|
||||
<th>Key</th>
|
||||
<th>Manage Key</th>
|
||||
<th>权限</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- 用户数据将通过JavaScript加载 -->
|
||||
</tbody>
|
||||
<tbody id="usersTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +336,7 @@
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="permissionGroup">
|
||||
<label for="permission">权限</label>
|
||||
<select id="permission" name="permission" required>
|
||||
<option value="0">管理员</option>
|
||||
@@ -275,6 +344,28 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
@@ -301,6 +392,14 @@
|
||||
</div>
|
||||
|
||||
<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令牌的函数
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
@@ -336,11 +435,12 @@
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
async function loadUsers(searchTerm = '') {
|
||||
async function loadUsers(searchTerm = '', key = '') {
|
||||
try {
|
||||
const url = searchTerm ?
|
||||
`/elastic/users/?search=${encodeURIComponent(searchTerm)}` :
|
||||
'/elastic/users/';
|
||||
const params = new URLSearchParams();
|
||||
if ((searchTerm || '').trim()) params.set('search', (searchTerm || '').trim());
|
||||
if ((key || '').trim()) params.set('key', (key || '').trim());
|
||||
const url = params.toString() ? `/elastic/users/?${params.toString()}` : '/elastic/users/';
|
||||
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
@@ -357,10 +457,16 @@
|
||||
|
||||
// 根据权限值显示权限名称
|
||||
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 = `
|
||||
<td>${user.user_id}</td>
|
||||
<td>${user.username}</td>
|
||||
<td>${keysText}</td>
|
||||
<td>${manageKeysText}</td>
|
||||
<td>${permissionText}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-success edit-btn" data-user='${JSON.stringify(user)}'>编辑</button>
|
||||
@@ -379,22 +485,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('userForm').reset();
|
||||
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('confirmPassword').required = true;
|
||||
document.getElementById('userModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// 打开编辑用户模态框
|
||||
function openEditModal(user) {
|
||||
async function openEditModal(user) {
|
||||
document.getElementById('modalTitle').textContent = '编辑用户';
|
||||
document.getElementById('username').value = user.username;
|
||||
document.getElementById('userId').value = user.user_id;
|
||||
document.getElementById('permission').value = user.permission;
|
||||
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('confirmPassword').required = false;
|
||||
document.getElementById('userModal').style.display = 'block';
|
||||
@@ -430,10 +739,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: username,
|
||||
permission: parseInt(permission)
|
||||
};
|
||||
const data = {};
|
||||
if (IS_ADMIN) {
|
||||
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) {
|
||||
data.password = password;
|
||||
@@ -470,7 +784,9 @@
|
||||
if (result.status === 'success') {
|
||||
showNotification(userId ? '用户更新成功' : '用户添加成功');
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
loadUsers();
|
||||
const searchTerm = (document.getElementById('searchInput') || {}).value || '';
|
||||
const key = (document.getElementById('keyFilter') || {}).value || '';
|
||||
loadUsers(searchTerm, key);
|
||||
} else {
|
||||
showNotification(result.message || '操作失败', false);
|
||||
}
|
||||
@@ -511,7 +827,10 @@
|
||||
}
|
||||
|
||||
// 事件监听器
|
||||
document.getElementById('addUserBtn').addEventListener('click', openAddModal);
|
||||
const addBtn = document.getElementById('addUserBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', openAddModal);
|
||||
}
|
||||
|
||||
document.getElementById('userForm').addEventListener('submit', saveUser);
|
||||
|
||||
@@ -523,15 +842,59 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('searchBtn').addEventListener('click', function() {
|
||||
const searchTerm = document.getElementById('searchInput').value;
|
||||
loadUsers(searchTerm);
|
||||
});
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', function() {
|
||||
const searchTerm = document.getElementById('searchInput').value;
|
||||
const key = (document.getElementById('keyFilter') || {}).value || '';
|
||||
loadUsers(searchTerm, key);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
loadUsers();
|
||||
});
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', function() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
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) {
|
||||
@@ -572,7 +935,12 @@
|
||||
|
||||
// 页面加载时获取用户列表
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUsers();
|
||||
initKeyFilter();
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
if (tbody) {
|
||||
const select = document.getElementById('keyFilter');
|
||||
loadUsers('', select ? select.value : '');
|
||||
}
|
||||
});
|
||||
|
||||
// 为表格中的编辑和删除按钮添加事件监听器
|
||||
@@ -590,4 +958,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -17,6 +17,12 @@ urlpatterns = [
|
||||
path('search/', views.search, name='search'),
|
||||
path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'),
|
||||
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('types-for-filter/', views.types_for_filter_view, name='types_for_filter'),
|
||||
path('filter/', views.filter_view, name='filter'),
|
||||
path('report/', views.report_view, name='report'),
|
||||
path('report/csv/', views.report_csv_view, name='report_csv'),
|
||||
|
||||
# 用户管理
|
||||
path('users/', views.get_users, name='get_users'),
|
||||
@@ -35,6 +41,8 @@ urlpatterns = [
|
||||
path('registration-codes/manage/', views.registration_code_manage_page, name='registration_code_manage_page'),
|
||||
path('registration-codes/keys/', views.get_keys_list_view, name='get_keys_list'),
|
||||
path('registration-codes/keys/add/', views.add_key_view, name='add_key'),
|
||||
path('registration-codes/keys/remove/', views.remove_key_view, name='remove_key'),
|
||||
path('registration-codes/keys/unallow/', views.unallow_tutor_added_key_view, name='unallow_tutor_added_key'),
|
||||
path('registration-codes/generate/', views.generate_registration_code_view, name='generate_registration_code'),
|
||||
path('registration-codes/list/', views.list_registration_codes_view, name='list_registration_codes'),
|
||||
path('registration-codes/revoke/', views.revoke_registration_code_view, name='revoke_registration_code'),
|
||||
|
||||
1356
elastic/views.py
1356
elastic/views.py
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,22 @@
|
||||
<div class="navigation-links">
|
||||
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
|
||||
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
|
||||
{% if is_admin or has_manage_key %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<a href="/accounts/profile/">个人中心</a>
|
||||
{% if is_admin or has_manage_key or can_manage_registration_codes %}
|
||||
<a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<a href="{% url 'accounts:registration_code_requests_page' %}">注册码申请管理</a>
|
||||
{% endif %}
|
||||
{% if not is_admin and not has_manage_key and not can_manage_registration_codes and not has_registration_code %}
|
||||
<a id="applyRegBtn" href="javascript:void(0)">申请注册码管理</a>
|
||||
{% endif %}
|
||||
<a id="logoutBtn">退出登录</a>
|
||||
<div id="logoutMsg"></div>
|
||||
{% csrf_token %}
|
||||
@@ -67,7 +78,10 @@
|
||||
<div id="chartTrend" style="width:100%;height:320px;"></div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="card">
|
||||
@@ -81,6 +95,24 @@
|
||||
</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>
|
||||
// 获取CSRF令牌的函数
|
||||
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 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(){
|
||||
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
|
||||
const res = await fetchJSON(url);
|
||||
if(res.status!=='success') return;
|
||||
const buckets = res.data || [];
|
||||
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
|
||||
typesChart.setOption({
|
||||
tooltip:{trigger:'item'},
|
||||
legend:{type:'scroll'},
|
||||
series:[{ type:'pie', radius:['40%','70%'], data }]
|
||||
});
|
||||
typesChartData = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
|
||||
renderTypesChart();
|
||||
startTypesChartRotation();
|
||||
}
|
||||
|
||||
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(){
|
||||
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
|
||||
const res = await fetchJSON(url);
|
||||
@@ -233,7 +382,8 @@
|
||||
const t = formatTime(it.time);
|
||||
const u = it.username || '';
|
||||
const ty = it.type || '未知';
|
||||
li.textContent = `${t},${u},${ty}`;
|
||||
const de = it.detail ? `,${it.detail}` : '';
|
||||
li.textContent = `${t},${u},${ty}${de}`;
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
@@ -244,4 +394,4 @@
|
||||
loadRecent();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -10,12 +10,10 @@ def home(request):
|
||||
if session_user_id is None:
|
||||
return redirect("/accounts/login/")
|
||||
|
||||
# Show user_id (prefer query param if present, but don't trust it)
|
||||
user_id_qs = request.GET.get("user_id")
|
||||
uid = user_id_qs or session_user_id
|
||||
uid = session_user_id
|
||||
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:
|
||||
u = get_user_by_id(uid)
|
||||
try:
|
||||
perm = int((u or {}).get("permission", 1))
|
||||
except Exception:
|
||||
@@ -26,8 +24,15 @@ def home(request):
|
||||
perm = int(perm)
|
||||
except Exception:
|
||||
perm = 1
|
||||
has_manage_key = bool((u or {}).get("manage_key") or [])
|
||||
can_manage_registration_codes = bool(int((u or {}).get("can_manage_registration_codes") or 0) == 1)
|
||||
has_registration_code = bool(str((u or {}).get("registration_code") or "").strip())
|
||||
context = {
|
||||
"user_id": uid,
|
||||
"username": (u or {}).get("username"),
|
||||
"is_admin": (int(perm) == 0),
|
||||
"has_manage_key": has_manage_key,
|
||||
"can_manage_registration_codes": can_manage_registration_codes,
|
||||
"has_registration_code": has_registration_code,
|
||||
}
|
||||
return render(request, "main/home.html", context)
|
||||
return render(request, "main/home.html", context)
|
||||
|
||||
1
minio_storage/__init__.py
Normal file
1
minio_storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
22
minio_storage/apps.py
Normal file
22
minio_storage/apps.py
Normal 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}")
|
||||
|
||||
133
minio_storage/minio_connect.py
Normal file
133
minio_storage/minio_connect.py
Normal 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))
|
||||
@@ -6,9 +6,12 @@ elasticsearch-dsl==7.4.1
|
||||
requests==2.32.3
|
||||
openai==1.52.2
|
||||
httpx==0.27.2
|
||||
zai-sdk==0.2.2
|
||||
Pillow==10.4.0
|
||||
minio>=7.2.0,<8
|
||||
gunicorn==21.2.0
|
||||
whitenoise==6.6.0
|
||||
django-browser-reload==1.21.0
|
||||
captcha==0.7.1
|
||||
cryptography==46.0.3
|
||||
cryptography==46.0.3
|
||||
pymupdf==1.25.3
|
||||
|
||||
Reference in New Issue
Block a user