144 Commits

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

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.log
.DS_Store
.env
.venv/
venv/
node_modules/
.git/
.gitignore
db.sqlite3
media/
staticfiles/

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

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

10
.gitignore vendored Normal file
View File

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

View File

View File

@@ -0,0 +1,16 @@
"""
ASGI config for Achievement_Inputing project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Achievement_Inputing.settings')
application = get_asgi_application()

View File

@@ -0,0 +1,170 @@
"""
Django settings for Achievement_Inputing project.
Generated by 'django-admin startproject' using Django 5.2.8.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
import os
from elastic.indexes import INDEX_NAME
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-p^*6tak7wy1z#bw__#o^s5hsydearm=(-s(km!-61j2(#)*+-t')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').split(',')
# Application definition
INSTALLED_APPS = [
'django_browser_reload',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'accounts',
'main',
'elastic',
'minio_storage',
'django_elasticsearch_dsl',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django_browser_reload.middleware.BrowserReloadMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'Achievement_Inputing.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'Achievement_Inputing.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Media files (uploaded images)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Security settings for cookies and headers (dev-friendly defaults)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = False if DEBUG else True
CSRF_COOKIE_SECURE = False if DEBUG else True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_TRUSTED_ORIGINS = os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS') else []
X_FRAME_OPTIONS = 'DENY'
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Elasticsearch configuration
_ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')
if not (_ES_URL.startswith('http://') or _ES_URL.startswith('https://')):
_ES_URL = 'http://' + _ES_URL
ELASTICSEARCH_DSL = {
'default': {
'hosts': _ES_URL
},
}
# Elasticsearch index settings
ELASTICSEARCH_INDEX_NAMES = {
'elastic.documents.AchievementDocument': INDEX_NAME,
'elastic.documents.UserDocument': INDEX_NAME,
}
# 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')

View File

@@ -0,0 +1,33 @@
"""
URL configuration for Achievement_Inputing project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from main.views import home as main_home
urlpatterns = [
path("__reload__/", include("django_browser_reload.urls")),
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls', namespace='accounts')),
path('main/', include('main.urls', namespace='main')),
path('elastic/', include('elastic.urls', namespace='elastic')),
path('', main_home, name='root_home'),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -0,0 +1,16 @@
"""
WSGI config for Achievement_Inputing project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Achievement_Inputing.settings')
application = get_wsgi_application()

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libjpeg62-turbo-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
&& apt-get purge -y --auto-remove build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY . /app
EXPOSE 8000
CMD ["sh","-c","python manage.py migrate && python manage.py collectstatic --noinput && gunicorn Achievement_Inputing.wsgi:application --bind 0.0.0.0:8000 --workers 3"]

View File

@@ -1,223 +0,0 @@
from elasticsearch import Elasticsearch
import os
import json
import hashlib
import requests
import json
# Elasticsearch连接配置
ES_URL = "http://localhost:9200"
AUTH = None # 如需认证则改为("用户名","密码")
# document=os.open('results/output.json', os.O_RDONLY)
# 创建Elasticsearch客户端实例连接到本地Elasticsearch服务
es = Elasticsearch(["http://localhost:9200"])
# 定义索引名称和类型名称
index_name = "wordsearch2666"
def create_index_with_mapping():
"""修正后的索引映射配置"""
# 修正映射结构移除keyword字段的非法参数
mapping = {
"mappings": {
"properties": {
"data": {
"type": "text", # 存储转换后的字符串,支持分词搜索
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"image": {"type": "keyword"}, # 存储图片路径或标识
}
}
}
# 检查索引是否存在,不存在则创建
if not es.indices.exists(index=index_name):
es.indices.create(index=index_name, body=mapping)
print(f"创建索引 {index_name} 并设置映射")
else:
print(f"索引 {index_name} 已存在")
def update_document(es, index_name, doc_id=None, updated_doc=None):
"""更新指定ID的文档"""
es.update(index=index_name, id=doc_id, body={"doc": updated_doc})
def get_doc_id(data):
"""
根据数据内容生成唯一ID用于去重
参数:
data (dict): 包含文档数据的字典
返回:
str: 基于数据内容生成的MD5哈希值作为唯一ID
"""
# 使用data字段的内容生成唯一字符串
data_str = data.get('data', '')
image_str = data.get('image', '')
unique_str = f"{data_str}{image_str}"
# 使用MD5哈希生成唯一ID
return hashlib.md5(unique_str.encode('utf-8')).hexdigest()
def insert_data(data):
"""
向Elasticsearch插入数据
参数:
data (dict): 要插入的数据
返回:
bool: 插入成功返回True失败返回False
"""
# 生成文档唯一ID
return batch_write_data(data)
def search_data(query):
"""
在Elasticsearch中搜索数据
参数:
query (str): 搜索关键词
返回:
list: 包含搜索结果的列表,每个元素是一个文档的源数据
"""
# 执行多字段匹配搜索
result = es.search(index=index_name, body={"query": {"multi_match": {"query": query, "fields": ["*"]}}})
# 返回搜索结果的源数据部分
return [hit["_source"] for hit in result['hits']['hits']]
def search_all():
"""
获取所有文档
返回:
list: 包含所有文档的列表每个元素包含文档ID和源数据
"""
# 执行匹配所有文档的查询
result = es.search(index=index_name, body={"query": {"match_all": {}}})
# 返回包含文档ID和源数据的列表
return [{
"_id": hit["_id"],
**hit["_source"]
} for hit in result['hits']['hits']]
def delete_by_id(doc_id):
"""
根据 doc_id 删除文档
参数:
doc_id (str): 要删除的文档ID
返回:
bool: 删除成功返回True失败返回False
"""
try:
# 执行删除操作
es.delete(index=index_name, id=doc_id)
return True
except Exception as e:
print("删除失败:", str(e))
return False
def update_by_id(doc_id, updated_data):
"""
根据文档ID更新数据
参数:
doc_id (str): 要更新的文档ID
updated_data (dict): 更新的数据内容
返回:
bool: 更新成功返回True失败返回False
"""
try:
# 执行更新操作
es.update(index=index_name, id=doc_id, body={"doc": updated_data})
print(f"文档 {doc_id} 更新成功")
return True
except Exception as e:
print(f"更新失败: {str(e)}")
return False
def get_by_id(doc_id):
"""
根据文档ID获取单个文档
参数:
doc_id (str): 要获取的文档ID
返回:
dict or None: 成功返回文档数据失败返回None
"""
try:
# 执行获取操作
result = es.get(index=index_name, id=doc_id)
if result['found']:
return {
"_id": result['_id'],
**result['_source']
}
return None
except Exception as e:
print(f"获取文档失败: {str(e)}")
return None
def search_by_any_field(keyword):
"""全字段模糊搜索(支持拼写错误)"""
try:
# update_mapping()
response = requests.post(
f"{ES_URL}/{index_name}/_search",
auth=AUTH,
json={
"query": {
"multi_match": {
"query": keyword,
"fields": ["*"], # 匹配所有字段
"fuzziness": "AUTO", # 启用模糊匹配
}
}
}
)
response.raise_for_status()
results = response.json()["hits"]["hits"]
print(f"\n模糊搜索 '{keyword}' 找到 {len(results)} 条结果:")
for doc in results:
print(f"\n文档ID: {doc['_id']}")
if '_source' in doc:
max_key_len = max(len(k) for k in doc['_source'].keys())
for key, value in doc['_source'].items():
# 提取高亮部分
highlight = doc.get('highlight', {}).get(key, [value])[0]
print(f"{key:>{max_key_len + 2}} : {highlight}")
else:
print("无_source数据")
return results
except requests.exceptions.HTTPError as e:
print(f"搜索失败: {e.response.text}")
return []
def batch_write_data(data):
"""批量写入获奖数据"""
try:
response = requests.post(
f"{ES_URL}/{index_name}/_doc",
json=data,
auth=AUTH,
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
doc_id = response.json()["_id"]
print(f"文档写入成功ID: {doc_id}, 内容: {data}")
return True
except requests.exceptions.HTTPError as e:
print(f"文档写入失败: {e.response.text}, 数据: {data}")
return False

View File

@@ -1,10 +0,0 @@
from elasticsearch import Elasticsearch
# 连接本地的 Elasticsearch 实例
es = Elasticsearch(["http://localhost:9200"])
# 检查连接是否成功
if es.ping():
print("连接成功!")
else:
print("连接失败!")

196
README.md Normal file
View File

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

1
accounts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Accounts app for secure login flow."""

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

115
accounts/crypto.py Normal file
View File

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

14
accounts/es_client.py Normal file
View File

@@ -0,0 +1,14 @@
import base64
from elastic.es_connect import get_user_by_username as es_get_user_by_username
def get_user_by_username(username: str):
es_user = es_get_user_by_username(username)
if es_user:
return {
'user_id': es_user.get('user_id', 0),
'username': es_user.get('username', ''),
'password_hash': es_user.get('password_hash'),
'password_salt': es_user.get('password_salt'),
'permission': es_user.get('permission', 1),
}
return None

View File

@@ -0,0 +1,136 @@
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
function base64ToArrayBuffer(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
async function importRsaPublicKey(spkiBytes) {
return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
}
async function rsaOaepEncrypt(publicKey, dataBytes) {
const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, dataBytes);
return new Uint8Array(encrypted);
}
async function importAesKey(keyBytes) {
return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
}
async function aesGcmEncrypt(aesKey, ivBytes, dataBytes) {
const ct = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, aesKey, dataBytes);
return new Uint8Array(ct);
}
let needCaptcha = false;
async function loadCaptcha() {
const csrftoken = getCookie('csrftoken');
const resp = await fetch('/accounts/captcha/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
const data = await resp.json();
if (resp.ok && data.ok) {
const img = document.getElementById('captchaImg');
const box = document.getElementById('captchaBox');
img.src = 'data:image/png;base64,' + data.image_b64;
box.style.display = 'block';
}
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('error');
errorEl.textContent = '';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; }
const btn = document.getElementById('loginBtn');
btn.disabled = true;
try {
const csrftoken = getCookie('csrftoken');
const pkResp = await fetch('/accounts/pubkey/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
if (!pkResp.ok) throw new Error('获取公钥失败');
const pkJson = await pkResp.json();
const spkiBytes = new Uint8Array(base64ToArrayBuffer(pkJson.public_key_spki));
const pubKey = await importRsaPublicKey(spkiBytes);
const aesKeyRaw = new Uint8Array(32); window.crypto.getRandomValues(aesKeyRaw);
const encAesKey = await rsaOaepEncrypt(pubKey, aesKeyRaw);
const encAesKeyB64 = arrayBufferToBase64(encAesKey);
const setKeyResp = await fetch('/accounts/session-key/', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 })
});
const setKeySnapshot = await (async () => {
const clone = setKeyResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await setKeyResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!setKeySnapshot.parsed) {
const msg = (setKeySnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
throw new Error(mapped);
}
const setKeyJson = setKeySnapshot.parsed;
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
const aesKey = await importAesKey(aesKeyRaw);
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
const obj = { username, password };
if (needCaptcha) obj.captcha = (document.getElementById('captcha').value || '').trim();
const payload = new TextEncoder().encode(JSON.stringify(obj));
const ct = await aesGcmEncrypt(aesKey, iv, payload);
const ctB64 = arrayBufferToBase64(ct);
const ivB64 = arrayBufferToBase64(iv);
const submitResp = await fetch('/accounts/login/secure-submit/', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
});
const submitSnapshot = await (async () => {
const clone = submitResp.clone();
const txt = await clone.text();
let parsed = null;
try { parsed = await submitResp.json(); } catch (_) {}
return { txt, parsed };
})();
if (!submitSnapshot.parsed) {
const msg = (submitSnapshot.txt || '').trim();
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
throw new Error(mapped);
}
const submitJson = submitSnapshot.parsed;
if (!submitResp.ok || !submitJson.ok) {
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
throw new Error(submitJson.message || '登录失败');
}
window.location.href = submitJson.redirect_url;
} catch (err) {
console.error(err);
errorEl.textContent = err.message || '发生错误';
} finally {
btn.disabled = false;
}
});
document.getElementById('refreshCaptcha').addEventListener('click', async () => {
needCaptcha = true;
await loadCaptcha();
});

View File

@@ -0,0 +1,53 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全登录</title>
<link rel="preload" href="{% static 'accounts/login.js' %}" as="script">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #f5f6fa; }
.container { max-width: 360px; margin: 12vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
h1 { font-size: 20px; margin: 0 0 16px; }
label { display: block; margin: 12px 0 6px; color: #333; }
input { width: 100%; padding: 10px 0px; border: 1px solid #dcdde1; border-radius: 6px; }
button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
button:disabled { background: #9bbcf0; cursor: not-allowed; }
.error { color: #d93025; margin-top: 10px; min-height: 20px; }
.hint { color: #888; font-size: 12px; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<h1>登录到系统</h1>
<form id="loginForm">
{% csrf_token %}
<label for="username">账户</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<div id="captchaBox" style="display:none; margin-top:12px;">
<label for="captcha">验证码</label>
<div style="display:flex; gap:8px; align-items:center;">
<input id="captcha" name="captcha" type="text" autocomplete="off" style="flex:1;" />
<img id="captchaImg" alt="验证码" style="height:40px; border:1px solid #dcdde1; border-radius:6px;" />
<button id="refreshCaptcha" type="button" style="width:auto;">刷新</button>
</div>
</div>
<button id="loginBtn" type="submit">登录</button>
<div id="error" class="error"></div>
</form>
<div class="hint" style="text-align:center; margin-top:12px;">
还没有账号?
<a href="/accounts/register/" style="color:#2d8cf0; text-decoration:none;">去注册</a>
</div>
</div>
<script src="{% static 'accounts/login.js' %}"></script>
</body>
</html>

View 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()">&times;</span>
<img id="modalImg" class="image-modal-content">
</div>
<script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
if(!confirm('确定要退出登录吗?')) return;
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
headers: { 'X-CSRFToken': csrftoken || '' }
});
const data = await resp.json();
if (data.ok) window.location.href = data.redirect_url;
} catch (e) { alert('登出失败'); }
});
// 图片放大功能
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>

View File

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

View File

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

26
accounts/urls.py Normal file
View File

@@ -0,0 +1,26 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("login/", views.login_page, name="login"),
path("pubkey/", views.pubkey, name="pubkey"),
path("captcha/", views.captcha, name="captcha"),
path("session-key/", views.set_session_key, name="set_session_key"),
path("login/secure-submit/", views.secure_login_submit, name="secure_login_submit"),
path("logout/", views.logout, name="logout"),
path("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"),
]

551
accounts/views.py Normal file
View File

@@ -0,0 +1,551 @@
import base64
import json
import os
import io
import random
import string
import time
import smtplib
from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
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, 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"])
@ensure_csrf_cookie
def login_page(request):
return render(request, "accounts/login.html")
@require_http_methods(["GET"])
@ensure_csrf_cookie
def pubkey(request):
pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
pem_b64 = generate_rsa_private_pem_b64()
request.session["rsa_private_pem_b64"] = pem_b64
pk_b64 = public_spki_b64_from_private_pem_b64(pem_b64)
return JsonResponse({"public_key_spki": pk_b64})
@require_http_methods(["GET"])
@ensure_csrf_cookie
def captcha(request):
try:
from captcha.image import ImageCaptcha
except Exception:
return JsonResponse({"ok": False, "message": "captcha unavailable"}, status=500)
code = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(5))
request.session["captcha_code"] = code
img = ImageCaptcha(width=160, height=60)
image = img.generate_image(code)
buf = io.BytesIO()
image.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return JsonResponse({"ok": True, "image_b64": b64})
@require_http_methods(["POST"])
@csrf_protect
def set_session_key(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
enc_key_b64 = payload.get("encrypted_key", "")
if not enc_key_b64:
return HttpResponseBadRequest("Missing fields")
try:
pem_b64 = request.session.get("rsa_private_pem_b64")
if not pem_b64:
return HttpResponseBadRequest("Decrypt error")
key_bytes = rsa_oaep_decrypt_b64_with_private_pem(pem_b64, enc_key_b64)
except Exception:
return HttpResponseBadRequest("Decrypt error")
request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii")
return JsonResponse({"ok": True})
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):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
iv_b64 = payload.get("iv", "")
ct_b64 = payload.get("ciphertext", "")
if not iv_b64 or not ct_b64:
return HttpResponseBadRequest("Missing fields")
key_b64 = request.session.get("session_enc_key_b64")
if not key_b64:
return HttpResponseBadRequest("Session key missing")
try:
key_bytes = base64.b64decode(key_b64)
pt = aes_gcm_decrypt_b64(key_bytes, iv_b64, ct_b64)
obj = json.loads(pt.decode("utf-8"))
except Exception:
return HttpResponseBadRequest("Decrypt error")
username = (obj.get("username") or "").strip()
password = (obj.get("password") or "")
if not username or not password:
return HttpResponseBadRequest("Missing credentials")
if bool(request.session.get("login_failed_once")):
ans = (obj.get("captcha") or "").strip()
code = request.session.get("captcha_code")
if not ans or not code or ans.lower() != str(code).lower():
return JsonResponse({"ok": False, "message": "验证码错误", "captcha_required": True}, status=401)
user = get_user_by_username(username)
if not user:
request.session["login_failed_once"] = True
return JsonResponse({"ok": False, "message": "用户不存在", "captcha_required": True}, status=401)
if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""):
request.session["login_failed_once"] = True
return JsonResponse({"ok": False, "message": "账户或密码错误", "captcha_required": True}, status=401)
try:
request.session.cycle_key()
except Exception:
pass
request.session["user_id"] = user["user_id"]
request.session["username"] = user["username"]
try:
request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1
except Exception:
request.session["permission"] = 1
if "session_enc_key_b64" in request.session:
del request.session["session_enc_key_b64"]
if "rsa_private_pem_b64" in request.session:
del request.session["rsa_private_pem_b64"]
if "login_failed_once" in request.session:
del request.session["login_failed_once"]
if "captcha_code" in request.session:
del request.session["captcha_code"]
return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})
@require_http_methods(["GET"])
def home(request):
# Minimal placeholder page per requirement
# Ensure user_id is passed via query and session contains id
user_id = request.GET.get("user_id")
session_user_id = request.session.get("user_id")
context = {
"user_id": user_id or session_user_id,
}
return render(request, "accounts/home.html", context)
@require_http_methods(["POST"])
@csrf_protect
def logout(request):
# Flush the session to clear all data and rotate the key
try:
request.session.flush()
except Exception:
pass
# Return a response that also deletes cookies client-side
resp = JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
try:
# Delete session cookie
resp.delete_cookie(
settings.SESSION_COOKIE_NAME,
path='/',
samesite=settings.SESSION_COOKIE_SAMESITE,
secure=settings.SESSION_COOKIE_SECURE,
)
# Optionally delete CSRF cookie to satisfy "清除cookie" 的要求
resp.delete_cookie(
settings.CSRF_COOKIE_NAME,
path='/',
samesite=settings.CSRF_COOKIE_SAMESITE,
secure=settings.CSRF_COOKIE_SECURE,
)
except Exception:
pass
return resp
@require_http_methods(["GET"])
@ensure_csrf_cookie
def register_page(request):
return render(request, "accounts/register.html")
@require_http_methods(["POST"])
@csrf_protect
def register_submit(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip()
email = (payload.get("email") or "").strip()
email_code = (payload.get("email_code") or "").strip()
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "")
if not email or not email_code or not username or not password:
return HttpResponseBadRequest("Missing fields")
v = request.session.get("email_verify") or {}
if (v.get("email") or "") != email:
return JsonResponse({"ok": False, "message": "请先验证邮箱"}, status=400)
try:
exp_ts = int(v.get("expires_at") or 0)
except Exception:
exp_ts = 0
if exp_ts < int(time.time()):
return JsonResponse({"ok": False, "message": "验证码已过期"}, status=400)
if (v.get("code") or "") != email_code:
return JsonResponse({"ok": False, "message": "邮箱验证码错误"}, status=400)
rc = None
if code:
rc = get_registration_code(code)
if not rc:
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
try:
exp = rc.get("expires_at")
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = __import__("datetime").datetime.fromisoformat(str(exp))
if exp_dt <= now:
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
except Exception:
pass
existing = es_get_user_by_username(username)
if existing:
return JsonResponse({"ok": False, "message": "用户名已存在"}, status=409)
users = es_get_all_users()
next_id = (max([int(u.get("user_id", 0)) for u in users]) + 1) if users else 1
ok = write_user_data({
"user_id": next_id,
"username": username,
"password": password,
"permission": 1,
"email": email,
"key": (rc.get("keys") if rc else []) or [],
"manage_key": (rc.get("manage_keys") if rc else []) or [],
"registration_code": (rc.get("code") if rc else None),
})
if not ok:
return JsonResponse({"ok": False, "message": "注册失败"}, status=500)
try:
if "email_verify" in request.session:
del request.session["email_verify"]
except Exception:
pass
return JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
@require_http_methods(["POST"])
@csrf_protect
def replace_registration_code_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"ok": False, "message": "未登录"}, status=401)
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON")
code = (payload.get("code") or "").strip()
if not code:
return JsonResponse({"ok": False, "message": "请输入注册码"}, status=400)
rc = get_registration_code(code)
if not rc:
return JsonResponse({"ok": False, "message": "注册码无效"}, status=400)
try:
exp = rc.get("expires_at")
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
if hasattr(exp, 'isoformat'):
exp_dt = exp
else:
exp_dt = __import__("datetime").datetime.fromisoformat(str(exp))
if exp_dt <= now:
return JsonResponse({"ok": False, "message": "注册码已过期"}, status=400)
except Exception:
pass
keys = list(rc.get("keys") or [])
manage_keys = list(rc.get("manage_keys") or [])
ok = update_user_by_id(session_user_id, key=keys, manage_key=manage_keys, registration_code=code)
if not ok:
return JsonResponse({"ok": False, "message": "替换失败"}, status=500)
return JsonResponse({"ok": True})
@require_http_methods(["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)

428
app.py
View File

@@ -1,428 +0,0 @@
import base64
from flask import Flask, request, render_template, redirect, url_for, jsonify
import os
import uuid
from PIL import Image
import re
import json
from ESConnect import *
from json_converter import json_to_string, string_to_json
from openai import OpenAI
# import config
# 创建Flask应用实例
app = Flask(__name__)
# app.config.from_object(config.Config)
# OCR和信息提取函数使用大模型API处理图片并提取结构化信息
def ocr_and_extract_info(image_path):
"""
使用大模型API进行OCR识别并提取图片中的结构化信息
参数:
image_path (str): 图片文件路径
返回:
dict: 包含提取信息的字典,格式为 {'id': '', 'name': '', 'students': '', 'teacher': ''}
"""
def encode_image(image_path):
"""
将图片编码为base64格式
参数:
image_path (str): 图片文件路径
返回:
str: base64编码的图片字符串
"""
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
# 将图片转换为base64编码
base64_image = encode_image(image_path)
# 初始化OpenAI客户端使用百度AI Studio的API
client = OpenAI(
api_key="188f57db3766e02ed2c7e18373996d84f4112272",
# 含有 AI Studio 访问令牌的环境变量https://aistudio.baidu.com/account/accessToken,
base_url="https://aistudio.baidu.com/llm/lmapi/v3", # aistudio 大模型 api 服务域名
)
# 调用大模型API进行图片识别和信息提取
chat_completion = client.chat.completions.create(
messages=[
{'role': 'system', 'content': '你是一个能理解图片和文本的助手,请根据用户提供的信息进行回答。'},
{'role': 'user', "content": [
{"type": "text", "text": "请识别这张图片中的信息将你认为重要的数据转换为不包含嵌套的json不要显示其它信息以便于解析"
"直接输出json结果即可"
"你可以自行决定使用哪些json字段"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}"
}
}
]}
],
model="ernie-4.5-turbo-vl-32k", # 使用百度文心大模型
)
# 获取API返回的文本内容
response_text = chat_completion.choices[0].message.content
# 添加调试信息:输出模型返回的原始字符串
print("=" * 50)
print("模型返回的原始字符串:")
print(response_text)
print("=" * 50)
def parse_respound(text):
"""
解析API返回的文本提取JSON数据
参数:
text (str): API返回的文本
返回:
dict or None: 解析成功返回字典失败返回None
"""
# 尝试直接解析标准JSON
try:
result=json.loads(text)
if result:
print("✓ 成功解析标准JSON格式")
return result
except json.JSONDecodeError:
print("✗ 无法解析标准JSON格式")
pass
# 提取markdown代码块中的内容
code_block = re.search(r'```json\n(.*?)```', text, re.DOTALL)
if code_block:
try:
result=json.loads(code_block.group(1))
if result:
print("✓ 成功解析markdown代码块中的JSON")
return result
except json.JSONDecodeError:
print("✗ 无法解析markdown代码块中的JSON")
pass
# 尝试替换单引号并解析
try:
fixed_json = text.replace("'", "\"")
result=json.loads(fixed_json)
if(result):
print("✓ 成功解析替换单引号后的JSON")
return result
except json.JSONDecodeError:
print("✗ 无法解析替换单引号后的JSON")
pass
# 解析API返回的文本
result_data = parse_respound(response_text)
# 添加调试信息:输出解析结果
print("解析结果:")
if result_data:
print(f"✓ 解析成功: {result_data}")
else:
print("✗ 解析失败返回None")
print("=" * 50)
return result_data
"""
模拟大模型识别图像并返回结构化JSON。
实际应调用Qwen-VL或其他OCR+解析服务。
"""
# 首页路由
@app.route('/')
def index():
"""
渲染首页模板
返回:
str: 渲染后的HTML页面
"""
return render_template('index.html')
# 图片上传路由
@app.route('/upload', methods=['POST'])
def upload_image():
"""
处理图片上传请求调用OCR识别并存储结果
返回:
JSON: 上传成功或失败的响应
"""
# 获取上传的文件
file = request.files.get('file')
if not file:
return jsonify({"error": "No file uploaded"}), 400
# 保存上传的图片
filename = f"{uuid.uuid4()}_{file.filename}"
image_path = os.path.join("image", filename)
file.save(image_path)
# 调用大模型进行识别
try:
print(f"开始处理图片: {image_path}")
original_data = ocr_and_extract_info(image_path) # 获取原始JSON数据
if original_data:
# 使用json_converter将JSON数据转换为字符串
data_string = json_to_string(original_data)
print(f"转换后的数据字符串: {data_string}")
# 构造新的数据结构只包含data和image字段
processed_data = {
"data": data_string,
"image": filename # 存储图片文件名
}
print(f"准备存储的数据: {processed_data}")
insert_data(processed_data) # 存入ES
print("✓ 数据成功存储到Elasticsearch")
return jsonify({"message": "成功录入", "data": original_data, "processed": processed_data})
else:
print("✗ 无法识别图片内容")
return jsonify({"error": "无法识别图片内容"}), 400
except Exception as e:
print(f"✗ 处理过程中发生错误: {str(e)}")
return jsonify({"error": str(e)}), 500
# 搜索路由
@app.route('/search')
def search():
"""
处理搜索请求从Elasticsearch中检索匹配的数据
返回:
JSON: 搜索结果列表
"""
keyword = request.args.get('q')
if not keyword:
return jsonify([])
results = search_by_any_field(keyword)
# 处理搜索结果将data字段转换回JSON格式
processed_results = []
for result in results:
if '_source' in result and 'data' in result['_source']:
try:
# 将data字段的字符串转换回JSON
original_data = string_to_json(result['_source']['data'])
# 构造新的结果格式
processed_result = {
'_id': result.get('_id', ''),
'_source': {
'image': result['_source'].get('image', ''),
**original_data # 展开原始数据字段
}
}
processed_results.append(processed_result)
except Exception as e:
# 如果转换失败,保持原始格式
processed_results.append(result)
else:
processed_results.append(result)
print(processed_results)
return jsonify(processed_results)
# 结果页面路由
@app.route('/results')
def results_page():
"""
渲染搜索结果页面
返回:
str: 渲染后的HTML页面
"""
return render_template('results.html')
# 显示所有数据路由
@app.route('/all')
def show_all():
"""
获取所有数据并渲染到页面
返回:
str: 渲染后的HTML页面包含所有数据
"""
all_data = search_all()
# 将data字段从字符串转换回JSON格式以便显示
processed_data = []
for item in all_data:
if 'data' in item and item['data']:
try:
# 将data字段的字符串转换回JSON
original_data = string_to_json(item['data'])
# 合并原始数据和其他字段
display_item = {
'_id': item['_id'],
'image': item.get('image', ''),
**original_data # 展开原始数据字段
}
processed_data.append(display_item)
except Exception as e:
# 如果转换失败,保持原始格式
processed_data.append(item)
else:
processed_data.append(item)
return render_template('all.html', data=processed_data)
# 编辑数据页面路由
@app.route('/edit/<doc_id>')
def edit_entry(doc_id):
"""
渲染编辑页面
参数:
doc_id (str): 要编辑的文档ID
返回:
str: 渲染后的编辑页面或错误信息
"""
# 获取要编辑的文档数据
document = get_by_id(doc_id)
if not document:
return "文档不存在", 404
# 保持原始数据格式不进行JSON转换
# 直接传递包含data字段的原始文档
return render_template('edited.html', document=document)
# 更新数据路由
@app.route('/update/<doc_id>', methods=['POST'])
def update_entry(doc_id):
"""
处理数据更新请求
参数:
doc_id (str): 要更新的文档ID
返回:
重定向到所有数据页面或错误信息
"""
# 获取原文档的图片信息
original_doc = get_by_id(doc_id)
if not original_doc:
return "文档不存在", 404
# 从表单中获取所有字段数据
data_parts = []
i = 1
while True:
key_name = request.form.get(f'key_{i}')
field_value = request.form.get(f'field_{i}')
if not key_name or not field_value:
break
# 处理字段值(如果是列表格式,用|##|分隔)
if ',' in field_value:
# 如果是逗号分隔的值,转换为列表格式
items = [item.strip() for item in field_value.split(',') if item.strip()]
if len(items) > 1:
field_value = f"[{'|##|'.join(items)}]"
data_parts.append(f"{key_name}:{field_value}")
i += 1
# 验证是否有数据
if not data_parts:
return "没有可更新的数据", 400
# 构建新的数据字符串
data_value = "|###|".join(data_parts)
# 构造更新数据
updated_data = {
'data': data_value,
'image': original_doc.get('image', '') # 保持原图片
}
# 更新文档
if update_by_id(doc_id, updated_data):
return redirect(url_for('show_all'))
else:
return "更新失败", 500
# 删除数据路由
@app.route('/delete/<doc_id>', methods=['POST'])
def delete_entry(doc_id):
"""
根据文档ID删除数据
参数:
doc_id (str): 要删除的文档ID
返回:
重定向到所有数据页面或错误信息
"""
if delete_by_id(doc_id):
return redirect(url_for('show_all'))
else:
return "删除失败", 500
# 批量删除数据路由
@app.route('/batch_delete', methods=['POST'])
def batch_delete():
"""
批量删除数据
返回:
重定向到所有数据页面或错误信息
"""
doc_ids = request.form.getlist('doc_ids')
if not doc_ids:
return "没有选择要删除的文档", 400
success_count = 0
for doc_id in doc_ids:
if delete_by_id(doc_id):
success_count += 1
if success_count == len(doc_ids):
return redirect(url_for('show_all'))
else:
return f"成功删除 {success_count} 条记录,失败 {len(doc_ids) - success_count}", 500
# 提供图片访问的路由
@app.route('/image/<filename>')
def serve_image(filename):
"""
提供image目录下图片的访问服务
参数:
filename (str): 图片文件名
返回:
图片文件或404错误
"""
import os
from flask import send_from_directory
# 确保文件存在
image_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'image')
if not os.path.exists(os.path.join(image_dir, filename)):
return "图片不存在", 404
# 发送图片文件
return send_from_directory(image_dir, filename)
# 主程序入口
if __name__ == '__main__':
# 创建Elasticsearch索引
create_index_with_mapping()
# 创建图片存储目录
os.makedirs("image", exist_ok=True)
# 启动Flask应用
app.run(use_reloader=False)

BIN
db.sqlite3 Normal file

Binary file not shown.

0
elastic/__init__.py Normal file
View File

3
elastic/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

24
elastic/apps.py Normal file
View File

@@ -0,0 +1,24 @@
from django.apps import AppConfig
import os
import sys
class ElasticConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "elastic"
def ready(self):
# 避免在 migrate、collectstatic 等管理命令中执行
if os.environ.get('RUN_MAIN') != 'true':
# Django 开发服务器会启动两个进程,只在主进程执行
return
# 避免在 manage.py 命令(除 runserver 外)中执行
if 'runserver' not in sys.argv:
return
# 延迟导入,避免循环导入或过早加载
from .es_connect import create_index_with_mapping
try:
create_index_with_mapping()
except Exception as e:
print(f"❌ ES 初始化失败: {e}")

70
elastic/documents.py Normal file
View File

@@ -0,0 +1,70 @@
from django_elasticsearch_dsl import Document, fields, Index
from .models import AchievementData, User, ElasticNews
from .indexes import *
ACHIEVEMENT_INDEX = Index(ACHIEVEMENT_INDEX_NAME)
ACHIEVEMENT_INDEX.settings(number_of_shards=1, number_of_replicas=0)
USER_INDEX = Index(USER_INDEX_NAME)
USER_INDEX.settings(number_of_shards=1, number_of_replicas=0)
GLOBAL_INDEX = Index(GLOBAL_INDEX_NAME)
GLOBAL_INDEX.settings(number_of_shards=1, number_of_replicas=0)
@ACHIEVEMENT_INDEX.doc_type
class AchievementDocument(Document):
"""获奖数据文档映射"""
writer_id = fields.TextField(fields={'keyword': {'type': 'keyword'}})
time = fields.DateField()
data = fields.TextField(
analyzer='ik_max_word',
search_analyzer='ik_smart',
fields={'keyword': {'type': 'keyword'}}
)
image = fields.KeywordField()
class Django:
model = AchievementData
# fields列表应该只包含需要特殊处理的字段或者可以完全省略
# 因为我们已经显式定义了所有字段
@USER_INDEX.doc_type
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字段匹配就显示否则不显示
key = fields.KeywordField(multi=True) #表示该用户的关键字举个例子学生A的key为"2024届人工智能1班","2024届""计算机与人工智能学院" 班导师B的key为"计算机与人工智能学院"
manage_key = fields.KeywordField(multi=True) #表示该用户管理的关键字非管理员班导师B的manage_key为"2024届人工智能1班"
#那么学生A就可以在数据管理页面搜索到自己的获奖数据而班导师B就可以在数据管理页面搜索到所有人工智能1班的获奖数据。也就是说学生A和班导师B都其实只有用户权限
class Django:
model = User
# fields列表应该只包含需要特殊处理的字段或者可以完全省略
# 因为我们已经显式定义了所有字段
@GLOBAL_INDEX.doc_type
class GlobalDocument(Document):
type_list = fields.KeywordField()
keys_list = fields.KeywordField(multi=True)
class Django:
model = ElasticNews
@GLOBAL_INDEX.doc_type
class RegistrationCodeDocument(Document):
code = fields.KeywordField() #具体值
keys = fields.KeywordField(multi=True) #对应的key
manage_keys = fields.KeywordField(multi=True) #对应的manage_key
created_at = fields.DateField() #创建时间
expires_at = fields.DateField() #过期时间
created_by = fields.LongField() #创建者id
class Django:
model = ElasticNews

1026
elastic/es_connect.py Normal file

File diff suppressed because it is too large Load Diff

5
elastic/indexes.py Normal file
View File

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

View File

41
elastic/models.py Normal file
View File

@@ -0,0 +1,41 @@
from django.db import models
class AchievementData(models.Model):
"""获奖数据模型对应Flask项目中的wordsearch266666索引"""
writer_id = models.CharField(max_length=100, verbose_name="作者ID")
data = models.TextField(verbose_name="数据内容")
image = models.CharField(max_length=500, blank=True, null=True, verbose_name="图片路径")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "获奖数据"
verbose_name_plural = verbose_name
def __str__(self):
return f"{self.writer_id} - {self.data[:50]}"
class User(models.Model):
"""用户模型对应Flask项目中的users索引"""
user_id = models.BigIntegerField(unique=True, verbose_name="用户ID")
username = models.CharField(max_length=100, unique=True, verbose_name="用户名")
password = models.CharField(max_length=100, verbose_name="密码")
permission = models.IntegerField(default=1, verbose_name="权限级别")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
verbose_name = "用户"
verbose_name_plural = verbose_name
def __str__(self):
return self.username
# 保留原有的ElasticNews模型用于兼容
class ElasticNews(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
class Meta:
verbose_name = "新闻"
verbose_name_plural = verbose_name

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>注册码管理</title>
<style>
body { margin:0; font-family: system-ui,-apple-system, Segoe UI, Roboto, sans-serif; background:#fafafa; }
.sidebar { position:fixed; top:0; left:0; width:180px; height:100vh; background:#1e1e2e; color:#fff; padding:20px; box-shadow:2px 0 5px rgba(0,0,0,0.1); z-index:1000; display:flex; flex-direction:column; align-items:center; }
.sidebar h3 { margin:0; font-size:18px; color:#add8e6; text-align:center; margin-bottom: 20px; }
.navigation-links { width:100%; margin-top:60px; }
.sidebar a, .sidebar button { display:block; color:#8be9fd; text-decoration:none; margin:10px 0; font-size:16px; padding:15px; border-radius:4px; background:transparent; border:none; cursor:pointer; width:calc(100% - 40px); text-align:left; transition:all .2s ease; }
.sidebar a:hover, .sidebar button:hover { color:#ff79c6; background-color:rgba(139,233,253,.2); }
.main { margin-left:200px; padding:20px; color:#333; }
.card { background:#fff; border-radius:14px; box-shadow:0 10px 24px rgba(31,35,40,.08); padding:20px; margin-bottom:20px; }
.row { display:flex; gap:16px; }
.col { flex:1; }
label { display:block; margin-bottom:6px; font-weight:600; }
input[type=text], input[type=number], select { width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:6px; box-sizing:border-box; }
.btn { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; margin:0 4px; }
.btn-primary { background:#4f46e5; color:#fff; }
.btn-secondary { background:#64748b; color:#fff; }
.btn-danger { background:#ff4d4f; color:#fff; }
.btn-danger:hover { background:#ff7875 !important; }
.btn-primary:hover { background:#6366f1 !important; }
.btn-secondary:hover { background:#94a3b8 !important; }
.notice { padding:10px; border-radius:6px; margin-top:10px; display:none; }
.notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
.notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
.code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); display:flex; align-items:center; justify-content:center; z-index:2000; }
.spinner { width:42px; height:42px; border:4px solid #cbd5e1; border-top-color:#4f46e5; border-radius:50%; animation:spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeUp 0.25s ease-out; }
@keyframes fadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
table tr:hover { background-color:#f3f4f6; transition: background-color 0.2s ease; }
.btn { transition: transform 0.1s ease, box-shadow 0.2s ease; }
.btn:hover { transform: translateY(-1px); box-shadow:0 6px 16px rgba(31,35,40,0.12); }
</style>
{% csrf_token %}
<script>
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
const CAN_MANAGE_REG = {{ can_manage_registration_codes|yesno:"true,false" }};
const MY_KEYS_RAW = JSON.parse('{{ my_keys_json|default:"[]"|escapejs }}');
const MY_KEYS_SET = new Set((Array.isArray(MY_KEYS_RAW) ? MY_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
const MY_MANAGE_KEYS_RAW = JSON.parse('{{ manage_keys_json|default:"[]"|escapejs }}');
const MY_MANAGE_KEYS_SET = new Set((Array.isArray(MY_MANAGE_KEYS_RAW) ? MY_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
const ALLOWED_MANAGE_KEYS_RAW = JSON.parse('{{ allowed_manage_keys_json|default:"[]"|escapejs }}');
const ALLOWED_MANAGE_KEYS_SET = new Set((Array.isArray(ALLOWED_MANAGE_KEYS_RAW) ? ALLOWED_MANAGE_KEYS_RAW : []).map(v => String(v || '').trim()).filter(Boolean));
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
async function loadKeys(){
const resp=await fetch('/elastic/registration-codes/keys/');
const data=await resp.json();
const opts=(data.data||[]);
const keySel=document.getElementById('keys');
const mkeySel=document.getElementById('manageKeys');
keySel.innerHTML=''; mkeySel.innerHTML='';
opts.forEach(k=>{
const o=document.createElement('option'); o.value=k; o.textContent=k; keySel.appendChild(o);
const o2=document.createElement('option'); o2.value=k; o2.textContent=k;
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const v = String(k || '').trim();
if (v && !ALLOWED_MANAGE_KEYS_SET.has(v)) o2.disabled = true;
}
mkeySel.appendChild(o2);
});
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
Array.from(keySel.options).forEach(o => { if (MY_KEYS_SET.has(String(o.value || '').trim())) o.selected = true; });
Array.from(mkeySel.options).forEach(o => { o.selected = false; });
}
}
async function addKey(){
const keyName=(document.getElementById('newKey').value||'').trim();
if(!keyName) return;
const csrftoken=getCookie('csrftoken');
const resp=await fetch('/elastic/registration-codes/keys/add/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({key:keyName})});
const data=await resp.json();
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
ALLOWED_MANAGE_KEYS_SET.add(keyName);
}
msg.textContent='新增key成功'; msg.className='notice success'; msg.style.display='block'; document.getElementById('newKey').value=''; loadKeys();
}
else{msg.textContent=data.message||'新增失败'; msg.className='notice error'; msg.style.display='block';}
}
async function deleteSelectedKey(){
const keySel = document.getElementById('keys');
const mkeySel = document.getElementById('manageKeys');
// 优先获取左侧选中的,如果没有则获取右侧选中的
const selectedKey = keySel.value || mkeySel.value;
if(!selectedKey){
alert('请先在下方列表中选择一个要删除的Key');
return;
}
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const v = String(selectedKey || '').trim();
if (!v || !ALLOWED_MANAGE_KEYS_SET.has(v)) {
const msg=document.getElementById('msg');
msg.textContent='只能删除自己新增的 key';
msg.className='notice error';
msg.style.display='block';
return;
}
}
if(!confirm(`确定要全局删除Key \"${selectedKey}\" 吗?\n该操作将:\n1. 从全局可选Key列表中移除\n2. 从所有包含此Key的注册码中同步清除\n此操作不可恢复!`)) return;
const ov=document.getElementById('overlay'); ov.style.display='flex';
const csrftoken=getCookie('csrftoken');
const url = '/elastic/registration-codes/keys/remove/';
const resp=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({key:selectedKey})});
const data=await resp.json();
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
ALLOWED_MANAGE_KEYS_SET.delete(String(selectedKey||'').trim());
}
msg.textContent = data.message || '删除成功';
msg.className='notice success';
msg.style.display='block';
loadKeys(); // 重新加载keys列表
loadCodes(); // 重新加载注册码列表
} else {
msg.textContent=data.message||'删除失败';
msg.className='notice error';
msg.style.display='block';
}
ov.style.display='none';
}
function selectedValues(sel){return Array.from(sel.selectedOptions).map(o=>o.value);}
function enableToggleSelect(sel){
sel.addEventListener('mousedown', function(e){
if(e.target && e.target.tagName==='OPTION'){
e.preventDefault();
const op=e.target;
if (op.disabled) return;
op.selected = !op.selected;
this.dispatchEvent(new Event('change',{bubbles:true}));
}
});
}
function clearSelection(id){
const sel=document.getElementById(id);
Array.from(sel.options).forEach(o=>{ o.selected = false; });
}
async function generateCode(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const csrftoken=getCookie('csrftoken');
const keySel = document.getElementById('keys');
let keys=selectedValues(keySel);
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const selected = new Set(keys.map(k=>String(k||'').trim()).filter(Boolean));
const missing = Array.from(MY_KEYS_SET).filter(k => !selected.has(k));
if (missing.length) {
const msg=document.getElementById('msg');
msg.textContent = `必须选择导师原有的 key${missing.join('、')}`;
msg.className='notice error';
msg.style.display='block';
ov.style.display='none';
return;
}
}
let manageKeys=selectedValues(document.getElementById('manageKeys'));
if ((!IS_ADMIN) && HAS_MANAGE_KEY) {
const hasForbidden = manageKeys.some(k => !ALLOWED_MANAGE_KEYS_SET.has(String(k || '').trim()));
if (hasForbidden) {
const msg=document.getElementById('msg');
msg.textContent='manage_key 只能选择本页新增的 key';
msg.className='notice error';
msg.style.display='block';
ov.style.display='none';
return;
}
}
const mode=document.getElementById('expireMode').value;
let days=30; if(mode==='month') days=30; else if(mode==='fouryears') days=1460; else { const d=parseInt(document.getElementById('customDays').value||'30'); days=isNaN(d)?30:Math.max(1,d);}
const resp=await fetch('/elastic/registration-codes/generate/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({keys,manage_keys:manageKeys,expires_in_days:days})});
const data=await resp.json();
const out=document.getElementById('codeOut');
const msg=document.getElementById('msg');
if(resp.ok && data.status==='success'){out.textContent=data.data.code; msg.textContent='生成成功'; msg.className='notice success'; msg.style.display='block';}
else{msg.textContent=data.message||'生成失败'; msg.className='notice error'; msg.style.display='block';}
ov.style.display='none';
}
async function loadCodes(){
const ov=document.getElementById('overlay'); ov.style.display='flex';
const resp=await fetch('/elastic/registration-codes/list/');
const data=await resp.json();
const tbody=document.getElementById('codesBody');
if(!tbody) return;
tbody.innerHTML='';
if(resp.ok && data.status==='success'){
(data.data||[]).forEach(it=>{
const tr=document.createElement('tr');
const status = it.active? '有效' : '失效';
const ka = Array.isArray(it.keys)? it.keys.join('、') : '';
const mka = Array.isArray(it.manage_keys)? it.manage_keys.join('、') : '';
tr.innerHTML = `<td>${it.code||''}</td><td>${ka}</td><td>${mka}</td><td>${formatDate(it.created_at)}</td><td>${formatDate(it.expires_at)}</td><td>${status}</td><td>${it.active? '<button class=\"btn btn-secondary\" data-code=\"'+it.code+'\">作废</button>':''}</td>`;
tbody.appendChild(tr);
});
}
ov.style.display='none';
}
function formatDate(t){ if(!t) return ''; try{ const d = new Date(t); if(String(d)!='Invalid Date'){ const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;} }catch(e){} return ''; }
async function revokeCode(code){ const csrftoken=getCookie('csrftoken'); const resp=await fetch('/elastic/registration-codes/revoke/',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrftoken||''},body:JSON.stringify({code})}); const msg=document.getElementById('msg'); const data=await resp.json(); if(resp.ok && data.status==='success'){ msg.textContent='已作废'; msg.className='notice success'; msg.style.display='block'; loadCodes(); } else { msg.textContent=data.message||'作废失败'; msg.className='notice error'; msg.style.display='block'; } }
document.addEventListener('click',function(e){ const btn=e.target; if(btn && btn.matches('button[data-code]')){ revokeCode(btn.getAttribute('data-code')); }});
document.addEventListener('DOMContentLoaded',()=>{
loadKeys();
enableToggleSelect(document.getElementById('keys'));
enableToggleSelect(document.getElementById('manageKeys'));
loadCodes();
});
</script>
</head>
<body>
<div id="overlay" class="overlay" style="display:none"><div class="spinner"></div></div>
<div class="sidebar">
<h3>你好,{{ username|default:"访客" }}</h3>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
</div>
<div class="main">
<div class="card fade-in">
<h2>管理注册码</h2>
{% if is_admin or has_manage_key or can_manage_registration_codes %}
<div class="row">
<div class="col">
<label>管理 Key</label>
<div style="display:flex; gap:8px;">
<input id="newKey" type="text" placeholder="输入新的key进行新增或在下方选择后删除" style="flex: 1;" />
<button class="btn btn-secondary" onclick="addKey()">新增 Key</button>
{% if is_admin or has_manage_key %}
<button class="btn btn-danger" onclick="deleteSelectedKey()">删除选中 Key</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="row" style="margin-top:12px;">
<div class="col">
<label>选择 keys</label>
<select id="keys" multiple size="10"></select>
<div style="margin-top:8px;"><button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('keys')">清空 keys 选择</button></div>
</div>
<div class="col">
<label>选择 manage_keys</label>
<select id="manageKeys" multiple size="10"></select>
<div style="margin-top:8px;">
<button class="btn btn-secondary" style="width: 100%;" onclick="clearSelection('manageKeys')">清空 manage_keys 选择</button>
</div>
</div>
</div>
<div class="row" style="margin-top:12px;">
<div class="col">
<label>有效期</label>
<select id="expireMode">
<option value="month">一个月</option>
<option value="fouryears">四年</option>
<option value="custom">自定义天数</option>
</select>
<input id="customDays" type="number" min="1" placeholder="自定义天数" />
</div>
<div class="col" style="display:flex; align-items:flex-end;">
<button class="btn btn-primary" onclick="generateCode()">生成注册码</button>
</div>
</div>
<div id="msg" class="notice"></div>
<div class="code-box" id="codeOut"></div>
<div class="row" style="margin-top:12px;">
<div class="col">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3>已生成的注册码</h3>
<div>
<button class="btn btn-secondary" onclick="loadCodes()">刷新列表</button>
</div>
</div>
<table style="width:100%; border-collapse:collapse; margin-top:10px;">
<thead>
<tr>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">code</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">manage_keys</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">创建时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">过期时间</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">状态</th>
<th style="text-align:left; border-bottom:1px solid #e5e7eb; padding:8px;">操作</th>
</tr>
</thead>
<tbody id="codesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// 获取CSRF令牌的函数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 导航点击处理函数提供备用URL
function handleNavClick(element, fallbackUrl) {
// 尝试使用Django模板生成的URL如果失败则使用备用URL
try {
// 如果模板渲染正常直接返回true让默认行为处理
return true;
} catch (e) {
// 如果模板渲染有问题使用备用URL
window.location.href = fallbackUrl;
return false;
}
}
// 修复用户管理链接跳转问题
document.addEventListener('DOMContentLoaded', function() {
// 为用户管理链接添加事件监听器,确保正确跳转
const userManagementLink = document.querySelector('a[href*="get_users"]');
if (userManagementLink) {
userManagementLink.addEventListener('click', function(e) {
// 阻止默认行为
e.preventDefault();
// 获取备用URL
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
// 直接跳转到用户管理页面
window.location.href = fallbackUrl;
});
}
});
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
const trendChart = echarts.init(document.getElementById('chartTrend'));
const typesChart = echarts.init(document.getElementById('chartTypes'));
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
async function loadTrend(){
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const x = buckets.map(b=>b.key_as_string||'');
const y = buckets.map(b=>b.doc_count||0);
trendChart.setOption({
tooltip:{trigger:'axis'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
});
}
async function loadTypes(){
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const data = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
typesChart.setOption({
tooltip:{trigger:'item'},
legend:{type:'scroll'},
series:[{ type:'pie', radius:['40%','70%'], data }]
});
}
async function loadTypesTrend(){
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const rows = res.data || [];
const x = rows.map(r=>r.key_as_string||'');
const typeSet = new Set();
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
const types = Array.from(typeSet);
const series = types.map(tp=>({
name: tp,
type:'line',
smooth:true,
data: rows.map(r=>{
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
return b? b.doc_count||0 : 0;
})
}));
typesTrendChart.setOption({
tooltip:{trigger:'axis'},
legend:{type:'scroll'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series
});
}
function formatTime(t){
try{
const d = new Date(t);
if(String(d) !== 'Invalid Date'){
const pad = n=> String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
}catch(e){}
return t||'';
}
async function loadRecent(){
const listEl = document.getElementById('recentList');
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const items = res.data || [];
listEl.innerHTML = '';
items.forEach(it=>{
const li = document.createElement('li');
const t = formatTime(it.time);
const u = it.username || '';
const ty = it.type || '未知';
li.textContent = `${t}${u}${ty}`;
listEl.appendChild(li);
});
}
loadTrend();
loadTypes();
loadTypesTrend();
loadRecent();
</script>
</body>
</html>

View File

@@ -0,0 +1,615 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>图片上传与识别</title>
<style>
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
/* 导航栏样式 */
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
flex-direction: column;align-items: center;}
.user-id {text-align: center;margin-bottom: 0px;}
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
.navigation-links {width: 100%;margin-top: 60px;}
.sidebar a,
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
.sidebar a:hover,
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
/* 主内容区 - 改进后的样式 */
.main-content {margin-left: 200px;padding: 20px;color: #333;}
.container { max-width: 1200px;margin: 0 auto;background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);
padding: 24px;}
.header {display: flex;align-items: center;justify-content: space-between;margin-bottom: 12px;}
.header h2 {margin: 0; color: #1e293b;}
.header p {margin: 5px 0 0 0;color: #64748b;font-size: 14px;}
.upload-section { background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;padding: 32px; text-align: center;transition: all 0.3s ease;
margin-bottom: 24px;}
.upload-section:hover {border-color: #4f46e5; background: #f1f5f9; }
.upload-section.drag-over {border-color: #4f46e5; background: #e0e7ff; }
.upload-section input[type="file"] {margin: 15px 0;}
.btn {padding: 10px 16px;border: none;border-radius: 8px;cursor: pointer;margin: 0 4px;font-size: 14px;transition: all 0.2s ease; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-primary:hover { background: #4338ca;}
.btn-secondary {background: #e2e8f0;color: #334155; }
.btn-secondary:hover { background: #cbd5e1;}
.btn-danger { background: #ef4444;color: #fff;}
.btn-danger:hover { background: #dc2626;}
.preview-container {display: flex; gap: 24px; margin: 24px 0;}
@media (max-width: 768px) {
.preview-container {flex-direction: column;}
}
.preview-box {flex: 1; text-align: center; }
.preview-box h3 {margin-top: 0;color: #334155; }
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
.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;}
.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>
<!-- 左侧固定栏目 -->
<div class="sidebar">
<div class="user-id">
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<div class="container">
<div class="header">
<div>
<h2>图片与PDF上传识别</h2>
<p>选择图片或PDF文件后上传服务端调用大模型解析为可编辑的 JSON再确认入库。</p>
</div>
</div>
<div class="upload-section" id="dropArea">
<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/*,.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="result-box">
<h3>待处理文件列表</h3>
<div id="pendingItems" class="pending-list">
<!-- 这里将动态生成每个文件的预览和编辑区域 -->
</div>
</div>
</div>
<div class="action-buttons">
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
<span id="confirmMsg" class="muted"></span>
</div>
</div>
</div>
<script>
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
const uploadForm = document.getElementById('uploadForm');
const fileInput = document.getElementById('fileInput');
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 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 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 => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropArea.classList.add('drag-over');
}
function unhighlight() {
dropArea.classList.remove('drag-over');
}
dropArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length) {
addFiles(files);
}
}
function setPreviewList(urls) {
previewList.innerHTML = '';
(urls || []).forEach((url, index) => {
if (!url) return;
const item = document.createElement('div');
item.className = 'preview-item';
item.dataset.index = String(index);
const img = document.createElement('img');
img.src = url;
img.alt = '预览';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'preview-remove';
btn.textContent = '×';
btn.onclick = () => {
const idx = Number(item.dataset.index);
if (!Number.isNaN(idx)) {
selectedFiles.splice(idx, 1);
const urls = selectedFiles.map(f => {
if (f.name.toLowerCase().endsWith('.pdf')) {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZjQ0NDQiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQgMmgyYTIgMiAwIDAgMSAyIDJ2MTZhMiAyIDAgMCAxLTIgMmgtMTJhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJoMiIvPjxwYXRoIGQ9Ik0xNCAydjRjMCAxLjEgLjkgMiAyIDJoNCIvPjxwYXRoIGQ9Ik03IDloNSIvPjxwYXRoIGQ9Ik03IDEzaDUiLz48cGF0aCBkPSJNNyAxN2g4Ii8+PC9zdmc+';
}
return URL.createObjectURL(f);
});
setPreviewList(urls);
updateFileHint();
setTimeout(() => urls.forEach(u => { if (u.startsWith('blob:')) URL.revokeObjectURL(u); }), 0);
}
};
item.appendChild(img);
item.appendChild(btn);
previewList.appendChild(item);
});
}
function updateFileHint() {
const count = selectedFiles.length;
fileHint.textContent = count ? `已选择 ${count} 个文件` : '未选择文件';
}
function addFiles(files) {
const incoming = Array.from(files || []).filter(f => f && (f.type.startsWith('image/') || f.name.toLowerCase().endsWith('.pdf')));
const existingKeys = new Set(selectedFiles.map(f => `${f.name}|${f.size}|${f.lastModified}`));
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 createKvRow(k = '', v = '', onInput) {
const row = document.createElement('div');
row.className = 'form-row';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.placeholder = '字段名';
keyInput.value = k;
const valInput = document.createElement('input');
valInput.type = 'text';
valInput.placeholder = '字段值';
valInput.value = typeof v === 'object' ? JSON.stringify(v) : (v ?? '');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-danger';
delBtn.textContent = '删除';
delBtn.onclick = () => {
const container = row.parentElement;
if (container.querySelectorAll('.form-row').length > 1) {
container.removeChild(row);
} else {
keyInput.value = '';
valInput.value = '';
}
if (onInput) onInput();
};
keyInput.oninput = onInput;
valInput.oninput = onInput;
row.appendChild(keyInput);
row.appendChild(valInput);
row.appendChild(delBtn);
return row;
}
function renderPendingItems(items) {
pendingItems.innerHTML = '';
currentItems = items;
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);
});
confirmBtn.disabled = items.length === 0;
}
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
uploadMsg.textContent = '';
confirmMsg.textContent = '';
confirmBtn.disabled = true;
previewList.innerHTML = '';
pendingItems.innerHTML = '';
currentItems = [];
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();
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';
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 = '正在录入...';
try {
const payload = {
items: currentItems.map(it => ({
data: it.data,
image: it.images
}))
};
const resp = await fetch('/elastic/confirm/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') || ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '录入失败');
}
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';
}
});
clearBtn.addEventListener('click', () => {
fileInput.value = '';
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');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,961 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>用户管理</title>
<style>
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
/* 导航栏样式 */
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
flex-direction: column;align-items: center;}
.user-id {text-align: center;margin-bottom: 0px;}
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
.navigation-links {width: 100%;margin-top: 60px;}
.sidebar a,
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
.sidebar a:hover,
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
/* 主内容区 */
.main-content {
margin-left: 200px;
padding: 20px;
color: #333;
}
.card {
background: #fff;
border-radius: 14px;
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
padding: 20px;
margin-bottom: 20px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 8px;
cursor: pointer;
margin: 0 4px;
}
.btn-primary {
background: #4f46e5;
color: #fff;
}
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-success {
background: #22c55e;
color: #fff;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background-color: #f9fafb;
font-weight: bold;
color: #374151;
}
tr:hover {
background-color: #f3f4f6;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
box-sizing: border-box;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.action-buttons {
display: flex;
gap: 8px;
}
.search-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-container input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
}
.search-container select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
}
.search-container button {
padding: 8px 15px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 6% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.notification {
padding: 10px;
margin-bottom: 15px;
border-radius: 4px;
display: none;
}
.notification.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.notification.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.nav-error {
color: #ef4444;
font-size: 12px;
margin-top: 5px;
text-align: center;
}
.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>
<!-- 左侧固定栏目 -->
<div class="sidebar">
<div class="user-id">
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">返回主页</a>
<a id="logoutBtn">退出登录</a>
<div id="logoutMsg"></div>
{% csrf_token %}
</div>
</div>
<div class="main-content">
<div class="card">
<div class="header">
<h2>用户管理</h2>
{% if is_admin %}<button id="addUserBtn" class="btn btn-primary">添加用户</button>{% endif %}
</div>
<div class="notification success" id="successNotification">操作成功!</div>
<div class="notification error" id="errorNotification">操作失败!</div>
<div class="search-container">
<input type="text" id="searchInput" placeholder="搜索用户名...">
<select id="keyFilter"></select>
<button id="searchBtn">搜索</button>
<button id="resetBtn">重置</button>
<button id="clearKeyBtn">清空Key</button>
</div>
<div class="table-container">
<table id="usersTable">
<thead>
<tr>
<th>用户ID</th>
<th>用户名</th>
<th>Key</th>
<th>Manage Key</th>
<th>权限</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- 添加/编辑用户模态框 -->
<div id="userModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2 id="modalTitle">添加用户</h2>
<form id="userForm">
<input type="hidden" id="userId" name="user_id">
<div class="form-row">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group" id="permissionGroup">
<label for="permission">权限</label>
<select id="permission" name="permission" required>
<option value="0">管理员</option>
<option value="1">普通用户</option>
</select>
</div>
</div>
<div class="form-group">
<label>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>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" name="confirmPassword" required>
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<!-- 确认删除模态框 -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>确认删除</h2>
<p>确定要删除用户 <strong id="deleteUserName"></strong> 吗?此操作不可撤销。</p>
<input type="hidden" id="deleteUserId">
<button id="confirmDeleteBtn" class="btn btn-danger">确认删除</button>
<button class="btn">取消</button>
</div>
</div>
<script>
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}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 导航点击处理函数提供备用URL
function handleNavClick(element, fallbackUrl) {
// 尝试使用Django模板生成的URL如果失败则使用备用URL
try {
// 如果模板渲染正常直接返回true让默认行为处理
return true;
} catch (e) {
// 如果模板渲染有问题使用备用URL
window.location.href = fallbackUrl;
return false;
}
}
// 显示通知
function showNotification(message, isSuccess = true) {
const notification = isSuccess ?
document.getElementById('successNotification') :
document.getElementById('errorNotification');
notification.textContent = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// 获取所有用户
async function loadUsers(searchTerm = '', key = '') {
try {
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();
if (result.status === 'success') {
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = '';
// 处理不同格式的API响应
const users = result.data || result.users || [];
users.forEach(user => {
const row = document.createElement('tr');
// 根据权限值显示权限名称
const permissionText = Number(user.permission) === 0 ? '管理员' : '普通用户';
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>
<button class="btn btn-danger delete-btn" data-username="${user.username}" data-userid="${user.user_id}">删除</button>
</td>
`;
tbody.appendChild(row);
});
} else {
showNotification('获取用户列表失败', false);
}
} catch (error) {
console.error('加载用户列表失败:', error);
showNotification('获取用户列表失败', false);
}
}
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);
}
// 打开添加用户模态框
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';
}
// 打开编辑用户模态框
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';
}
// 打开删除确认模态框
function openDeleteModal(username, userId) {
document.getElementById('deleteUserName').textContent = username;
document.getElementById('deleteUserId').value = userId;
document.getElementById('deleteModal').style.display = 'block';
}
// 保存用户(添加或编辑)
async function saveUser(event) {
event.preventDefault();
const formData = new FormData(event.target);
const userId = formData.get('user_id');
const username = formData.get('username');
const permission = formData.get('permission');
const password = formData.get('password');
const confirmPassword = formData.get('confirmPassword');
// 验证密码
if (password !== confirmPassword) {
showNotification('密码和确认密码不匹配', false);
return;
}
// 验证密码长度(如果提供了密码)
if (password && password.length < 6) {
showNotification('密码长度至少为6位', false);
return;
}
const data = {};
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;
}
try {
const csrftoken = getCookie('csrftoken');
let response;
if (userId) {
response = await fetch(`/elastic/users/${userId}/update/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(data)
});
} else {
response = await fetch('/elastic/users/add/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(data)
});
}
const result = await response.json();
if (result.status === 'success') {
showNotification(userId ? '用户更新成功' : '用户添加成功');
document.getElementById('userModal').style.display = 'none';
const searchTerm = (document.getElementById('searchInput') || {}).value || '';
const key = (document.getElementById('keyFilter') || {}).value || '';
loadUsers(searchTerm, key);
} else {
showNotification(result.message || '操作失败', false);
}
} catch (error) {
console.error('保存用户失败:', error);
showNotification('保存用户失败', false);
}
}
// 删除用户
async function deleteUser() {
const userId = document.getElementById('deleteUserId').value;
try {
const csrftoken = getCookie('csrftoken');
const response = await fetch(`/elastic/users/${userId}/delete/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
}
});
const result = await response.json();
if (result.status === 'success') {
showNotification('用户删除成功');
document.getElementById('deleteModal').style.display = 'none';
loadUsers();
} else {
showNotification(result.message || '删除失败', false);
}
} catch (error) {
console.error('删除用户失败:', error);
showNotification('删除用户失败', false);
}
}
// 事件监听器
const addBtn = document.getElementById('addUserBtn');
if (addBtn) {
addBtn.addEventListener('click', openAddModal);
}
document.getElementById('userForm').addEventListener('submit', saveUser);
document.getElementById('confirmDeleteBtn').addEventListener('click', deleteUser);
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', function() {
this.parentElement.parentElement.style.display = 'none';
});
});
const searchBtn = document.getElementById('searchBtn');
if (searchBtn) {
searchBtn.addEventListener('click', function() {
const searchTerm = document.getElementById('searchInput').value;
const key = (document.getElementById('keyFilter') || {}).value || '';
loadUsers(searchTerm, key);
});
}
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) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
});
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
// 页面加载时获取用户列表
document.addEventListener('DOMContentLoaded', function() {
initKeyFilter();
const tbody = document.getElementById('usersTableBody');
if (tbody) {
const select = document.getElementById('keyFilter');
loadUsers('', select ? select.value : '');
}
});
// 为表格中的编辑和删除按钮添加事件监听器
document.addEventListener('click', function(e) {
if (e.target.classList.contains('edit-btn')) {
const user = JSON.parse(e.target.getAttribute('data-user'));
openEditModal(user);
}
if (e.target.classList.contains('delete-btn')) {
const username = e.target.getAttribute('data-username');
const userId = e.target.getAttribute('data-userid');
openDeleteModal(username, userId);
}
});
</script>
</body>
</html>

3
elastic/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

56
elastic/urls.py Normal file
View File

@@ -0,0 +1,56 @@
from django.urls import path
from . import views
app_name = 'elastic'
urlpatterns = [
# ES索引管理
# path('init-index/', views.init_index, name='init_index'),
# 数据操作
path('data/', views.add_data, name='add_data'),
path('data/<str:doc_id>/', views.get_data, name='get_data'),
path('data/<str:doc_id>/update/', views.update_data, name='update_data'),
path('data/<str:doc_id>/delete/', views.delete_data, name='delete_data'),
# 搜索功能
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('export_achievements_csv/', views.export_achievements_csv, name='export_achievements_csv'),
# 用户管理
path('users/', views.get_users, name='get_users'),
path('users/add/', views.add_user, name='add_user'),
path('users/<int:user_id>/update/', views.update_user_by_id_view, name='update_user_by_id'),
path('users/<int:user_id>/delete/', views.delete_user_by_id_view, name='delete_user_by_id'),
# 图片上传与确认
path('upload-page/', views.upload_page, name='upload_page'),
path('upload/', views.upload, name='upload'),
path('confirm/', views.confirm, name='confirm'),
# 管理页面
path('manage/', views.manage_page, name='manage_page'),
path('user_manage/', views.user_manage, name='user_manage'),
path('registration-codes/manage/', views.registration_code_manage_page, name='registration_code_manage_page'),
path('registration-codes/keys/', views.get_keys_list_view, name='get_keys_list'),
path('registration-codes/keys/add/', views.add_key_view, name='add_key'),
path('registration-codes/keys/remove/', views.remove_key_view, name='remove_key'),
path('registration-codes/keys/unallow/', views.unallow_tutor_added_key_view, name='unallow_tutor_added_key'),
path('registration-codes/generate/', views.generate_registration_code_view, name='generate_registration_code'),
path('registration-codes/list/', views.list_registration_codes_view, name='list_registration_codes'),
path('registration-codes/revoke/', views.revoke_registration_code_view, name='revoke_registration_code'),
# 分析接口
path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),
path('analytics/types/', views.analytics_types_view, name='analytics_types'),
path('analytics/types_trend/', views.analytics_types_trend_view, name='analytics_types_trend'),
path('analytics/recent/', views.analytics_recent_view, name='analytics_recent'),
]

2006
elastic/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +0,0 @@
import json
def json_to_string(json_data):
"""
将JSON数据转换为使用指定分隔符的字符串
使用 |###| 作为键值对分隔符
使用 |##| 作为列表元素分隔符
Args:
json_data (dict): 要转换的JSON数据
Returns:
str: 转换后的字符串
"""
if not isinstance(json_data, dict):
raise ValueError("输入必须是字典类型")
result_parts = []
for key, value in json_data.items():
if isinstance(value, list):
# 处理列表:使用 |##| 分隔列表元素
list_str = "|##|".join(str(item) for item in value)
result_parts.append(f"{key}:[{list_str}]")
else:
# 处理普通值
result_parts.append(f"{key}:{value}")
# 使用 |###| 分隔键值对
return "|###|".join(result_parts)
def string_to_json(data_string):
"""
将使用指定分隔符的字符串转换回JSON格式
解析使用 |###| 分隔的键值对
解析使用 |##| 分隔的列表元素
Args:
data_string (str): 要转换的字符串
Returns:
dict: 转换后的JSON数据
"""
if not isinstance(data_string, str):
raise ValueError("输入必须是字符串类型")
if not data_string.strip():
return {}
result = {}
# 使用 |###| 分割键值对
pairs = data_string.split("|###|")
for pair in pairs:
if ":" not in pair:
continue
# 分割键和值
key, value = pair.split(":", 1)
key = key.strip()
value = value.strip()
# 检查是否是列表格式 [...]
if value.startswith("[") and value.endswith("]"):
# 处理列表
list_content = value[1:-1] # 去掉方括号
if list_content:
# 使用 |##| 分割列表元素
items = list_content.split("|##|")
# 尝试转换为适当的数据类型
converted_items = []
for item in items:
item = item.strip()
# 尝试转换为数字
try:
if "." in item:
converted_items.append(float(item))
else:
converted_items.append(int(item))
except ValueError:
# 如果不是数字,保持为字符串
converted_items.append(item)
result[key] = converted_items
else:
result[key] = []
else:
# 处理普通值,尝试转换为适当的数据类型
try:
if "." in value:
result[key] = float(value)
else:
result[key] = int(value)
except ValueError:
# 如果不是数字,保持为字符串
result[key] = value
return result

1
main/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Main app for home page (login required)."""

6
main/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MainConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'main'

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,397 @@
<!DOCTYPE html>
{% load static %}
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>数据管理系统</title>
<script src="{% static 'vendor/echarts.min.js' %}"></script>
<style>
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
/* 导航栏样式 */
.sidebar {position: fixed;top: 0;left: 0;width: 180px;height: 100vh;background: #1e1e2e;color: white;padding: 20px;box-shadow: 2px 0 5px rgba(0,0,0,0.1);z-index: 1000;display: flex;
flex-direction: column;align-items: center;}
.user-id {text-align: center;margin-bottom: 0px;}
.sidebar h3 {margin-top: 0;font-size: 18px;color: #add8e6;text-align: center; margin-bottom: 20px;}
.navigation-links {width: 100%;margin-top: 60px;}
.sidebar a,
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
.sidebar a:hover,
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
/* 主内容区 */
.main-content {margin-left: 200px;padding: 20px;color: #333;}
.card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
.grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
.grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
.header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.legend {display: flex;gap: 12px;align-items: center;}
.legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
.muted {color: #6b7280;font-size: 12px;}
.btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
.btn-primary {background: #4f46e5;color: #fff;}
</style>
</head>
<body>
<!-- 左侧固定栏目 -->
<div class="sidebar">
<div class="user-id">
<h3>你好,{{ username|default:"访客" }}</h3>
</div>
<div class="navigation-links">
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
<a 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>
{% 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 %}
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<div class="card">
<div class="header">
<h2>师生共创系统</h2>
<span class="badge">用户:{{ user_id }}</span>
</div>
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
</div>
<div class="grid" style="margin-top:16px;">
<div class="card">
<div class="header"><h3>录入量变化近90天</h3></div>
<div id="chartTrend" style="width:100%;height:320px;"></div>
</div>
<div class="card">
<div class="header">
<h3>类型占比近30天</h3>
<button id="toggleTypesChartBtn" class="btn btn-primary" style="font-size: 12px; padding: 4px 8px;">切换图表</button>
</div>
<div id="chartTypes" style="width:100%;height:320px;"></div>
</div>
<div class="card">
<div class="header"><h3>类型变化近180天按周</h3></div>
<div id="chartTypesTrend" style="width:100%;height:320px;"></div>
</div>
<div class="card">
<div class="header"><h3>最近活动近7天</h3></div>
<ul id="recentList" style="list-style:none;padding:0;margin:0;"></ul>
</div>
</div>
</div>
<div id="applyRegModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:3000; align-items:center; justify-content:center;">
<div class="card" style="width:min(560px, calc(100vw - 40px));">
<div class="header">
<h3 style="margin:0;">申请注册码管理权限</h3>
<button id="applyRegClose" class="btn" type="button" style="background:#e5e7eb;">关闭</button>
</div>
<div class="muted" style="margin-bottom:10px;">填写申请理由,管理员同意后可进入“注册码管理”页面。</div>
<div style="margin-top:10px;">
<label for="applyReason" style="display:block; margin-bottom:6px; font-weight:600;">申请理由</label>
<textarea id="applyReason" rows="5" style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px; box-sizing:border-box; resize: vertical;"></textarea>
</div>
<div id="applyRegMsg" class="muted" style="margin-top:10px;"></div>
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:14px;">
<button id="applyRegSubmit" class="btn btn-primary" type="button">提交申请</button>
</div>
</div>
</div>
<script>
// 获取CSRF令牌的函数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 导航点击处理函数提供备用URL
function handleNavClick(element, fallbackUrl) {
// 尝试使用Django模板生成的URL如果失败则使用备用URL
try {
// 如果模板渲染正常直接返回true让默认行为处理
return true;
} catch (e) {
// 如果模板渲染有问题使用备用URL
window.location.href = fallbackUrl;
return false;
}
}
// 修复用户管理链接跳转问题
document.addEventListener('DOMContentLoaded', function() {
// 为用户管理链接添加事件监听器,确保正确跳转
const userManagementLink = document.querySelector('a[href*="get_users"]');
if (userManagementLink) {
userManagementLink.addEventListener('click', function(e) {
// 阻止默认行为
e.preventDefault();
// 获取备用URL
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
// 直接跳转到用户管理页面
window.location.href = fallbackUrl;
});
}
});
// 登出功能
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error('登出失败');
}
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window.location.href = data.redirect_url;
} catch (e) {
msg.textContent = e.message || '发生错误';
}
});
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(); }
const trendChart = echarts.init(document.getElementById('chartTrend'));
const typesChart = echarts.init(document.getElementById('chartTypes'));
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
async function loadTrend(){
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const buckets = res.data || [];
const x = buckets.map(b=>b.key_as_string||'');
const y = buckets.map(b=>b.doc_count||0);
trendChart.setOption({
tooltip:{trigger:'axis'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
});
}
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 || [];
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);
if(res.status!=='success') return;
const rows = res.data || [];
const x = rows.map(r=>r.key_as_string||'');
const typeSet = new Set();
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
const types = Array.from(typeSet);
const series = types.map(tp=>({
name: tp,
type:'line',
smooth:true,
data: rows.map(r=>{
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
return b? b.doc_count||0 : 0;
})
}));
typesTrendChart.setOption({
tooltip:{trigger:'axis'},
legend:{type:'scroll'},
xAxis:{type:'category', data:x},
yAxis:{type:'value'},
series
});
}
function formatTime(t){
try{
const d = new Date(t);
if(String(d) !== 'Invalid Date'){
const pad = n=> String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
}catch(e){}
return t||'';
}
async function loadRecent(){
const listEl = document.getElementById('recentList');
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
const res = await fetchJSON(url);
if(res.status!=='success') return;
const items = res.data || [];
listEl.innerHTML = '';
items.forEach(it=>{
const li = document.createElement('li');
const t = formatTime(it.time);
const u = it.username || '';
const ty = it.type || '未知';
const de = it.detail ? `${it.detail}` : '';
li.textContent = `${t}${u}${ty}${de}`;
listEl.appendChild(li);
});
}
loadTrend();
loadTypes();
loadTypesTrend();
loadRecent();
</script>
</body>
</html>

9
main/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = "main"
urlpatterns = [
path("home/", views.home, name="home"),
]

38
main/views.py Normal file
View File

@@ -0,0 +1,38 @@
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from elastic.es_connect import get_user_by_id
@require_http_methods(["GET"])
def home(request):
# Enforce login: require session user_id
session_user_id = request.session.get("user_id")
if session_user_id is None:
return redirect("/accounts/login/")
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:
try:
perm = int((u or {}).get("permission", 1))
except Exception:
perm = 1
request.session["permission"] = perm
else:
try:
perm = int(perm)
except Exception:
perm = 1
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)

22
manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Achievement_Inputing.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1 @@

22
minio_storage/apps.py Normal file
View File

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

View File

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

View File

@@ -1,6 +1,17 @@
flask==3.1.1
pillow==11.1.0
openai==1.88.0
elasticsearch==7.17.0
pandas==2.2.3
requests
Django==5.2.8
elasticsearch==7.17.9
django-elasticsearch-dsl==7.4.0
django-elasticsearch-dsl-drf==0.22
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
pymupdf==1.25.3

View File

@@ -1,353 +0,0 @@
{% extends "base.html" %}
{% block title %}数据操作 - 紫金·稷下薪火·云枢智海师生成果共创系统{% endblock %}
{% block content %}
<style>
/* 基础样式重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* 容器样式 - 调整为靠左靠上 */
.container {
max-width: 1200px;
margin: 0; /* 移除自动居中 */
padding: 20px 0 0 20px; /* 顶部和左侧留白 */
}
/* 标题样式 - 减少底部边距 */
h2 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
margin-bottom: 15px; /* 减少间距 */
}
/* 描述文字样式 */
p {
margin-bottom: 15px;
}
/* 卡片容器样式 */
.data-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* 卡片样式 */
.data-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
border: 1px solid #e0e0e0;
transition: transform 0.3s, box-shadow 0.3s;
}
.data-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* 卡片头部样式 */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
color: #333;
font-size: 18px;
}
.card-actions {
display: flex;
gap: 8px;
}
/* 卡片内容样式 */
.card-content {
margin-bottom: 15px;
}
.field-item {
display: flex;
margin-bottom: 10px;
line-height: 1.5;
}
.field-key {
font-weight: 600;
color: #333;
min-width: 120px;
margin-right: 10px;
}
.field-value {
color: #666;
flex: 1;
word-break: break-word;
}
/* 卡片图片样式 */
.card-image {
text-align: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
}
.card-image img {
max-width: 100%;
max-height: 200px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* 操作按钮样式 */
.action-button {
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
margin: 0 2px;
}
.edit-btn {
background: linear-gradient(to right, #4CAF50, #45a049);
color: white;
}
.edit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3);
}
.delete-btn {
background: linear-gradient(to right, #ff416c, #ff4b2b);
color: white;
}
.delete-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(255, 75, 43, 0.3);
}
/* 返回按钮样式 */
.back-btn {
display: inline-block;
padding: 10px 20px;
background: linear-gradient(to right, #0066cc, #003399);
color: white;
text-decoration: none;
border-radius: 6px;
margin-top: 15px; /* 减少顶部间距 */
margin-left: 20px; /* 左侧对齐 */
transition: transform 0.3s;
}
.back-btn:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 102, 204, 0.4);
}
/* 空数据提示 */
.no-data {
text-align: center;
padding: 40px 0;
color: #a0aec0;
font-style: italic;
grid-column: 1 / -1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.data-cards {
grid-template-columns: 1fr;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.card-actions {
align-self: flex-end;
}
.field-item {
flex-direction: column;
}
.field-key {
min-width: auto;
margin-bottom: 5px;
}
}
</style>
<div class="container">
<h2>所有已录入的奖项信息</h2>
<p>在此页面可以查看所有已录入的成果信息,并进行编辑和删除操作</p>
<!-- 批量操作区域 -->
<div class="batch-operations" style="margin-bottom: 20px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this.checked)">
<label for="select-all" style="font-weight: 600; color: #333;">全选</label>
</div>
<button type="button" class="batch-delete-btn" onclick="batchDelete()" style="padding: 8px 16px; background-color: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background-color 0.3s;">
批量删除选中项
</button>
<span id="selected-count" style="color: #666; font-size: 14px;">已选择 0 项</span>
</div>
</div>
<div class="data-cards">
{% if data %}
{% for item in data %}
<div class="data-card">
<div class="card-header">
<div style="display: flex; align-items: center; gap: 15px;">
<input type="checkbox" class="doc-checkbox" value="{{ item._id }}" onchange="updateSelectedCount()">
<h3>记录 {{ loop.index }}</h3>
</div>
<div class="card-actions">
<a href="{{ url_for('edit_entry', doc_id=item._id) }}" class="action-button edit-btn">编辑</a>
</div>
</div>
<div class="card-content">
{% if item.data %}
{# 从原始数据中解析字段 #}
{% set data_string = item.data %}
{% set pairs = data_string.split('|###|') %}
{% for pair in pairs %}
{% if ':' in pair %}
{% set key_value = pair.split(':', 1) %}
{% set field_key = key_value[0].strip() %}
{% set field_value = key_value[1].strip() %}
{# 处理列表格式 [item1|##|item2] #}
{% if field_value.startswith('[') and field_value.endswith(']') %}
{% set list_content = field_value[1:-1] %}
{% set field_value = list_content.split('|##|')|join(', ') %}
{% endif %}
<div class="field-item">
<span class="field-key">{{ field_key }}</span>
<span class="field-value">{{ field_value or '无' }}</span>
</div>
{% endif %}
{% endfor %}
{% else %}
{# 如果没有data字段显示解析后的字段 #}
{% for key, value in item.items() %}
{% if key not in ['_id', 'image'] %}
<div class="field-item">
<span class="field-key">{{ key }}</span>
<span class="field-value">
{% if value is sequence and value is not string %}
{{ value|join(', ') if value else '无' }}
{% else %}
{{ value or '无' }}
{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-data">暂无数据</div>
{% endif %}
</div>
<a href="{{ url_for('index') }}" class="back-btn">返回首页</a>
</div>
<script>
// 全选/取消全选功能
function toggleSelectAll(checked) {
const checkboxes = document.querySelectorAll('.doc-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = checked;
});
updateSelectedCount();
}
// 更新选择计数
function updateSelectedCount() {
const checkboxes = document.querySelectorAll('.doc-checkbox');
const selectedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
document.getElementById('selected-count').textContent = `已选择 ${selectedCount}`;
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById('select-all');
if (selectedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (selectedCount === checkboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// 批量删除功能
function batchDelete() {
const checkboxes = document.querySelectorAll('.doc-checkbox:checked');
if (checkboxes.length === 0) {
alert('请至少选择一条记录进行删除');
return;
}
const confirmMessage = `确定要删除选中的 ${checkboxes.length} 条记录吗?此操作不可撤销。`;
if (!confirm(confirmMessage)) {
return;
}
// 收集选中的文档ID
const docIds = Array.from(checkboxes).map(cb => cb.value);
// 创建表单并提交
const form = document.createElement('form');
form.method = 'POST';
form.action = '/batch_delete';
docIds.forEach(docId => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'doc_ids';
input.value = docId;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
updateSelectedCount();
});
</script>
{% endblock %}

View File

@@ -1,177 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}紫金·稷下薪火·云枢智海师生成果共创系统{% endblock %}</title>
<style>
:root {
--primary: #4361ee;
--primary-light: #4895ef;
--secondary: #3f37c9;
--accent: #f72585;
--light: #f8f9fa;
--dark: #212529;
--success: #4cc9f0;
--warning: #fcaa18;
--radius: 8px;
--shadow: 0 4px 12px rgba(0,0,0,0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f7fb;
color: #333;
line-height: 1.6;
}
.header {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
padding: 15px 20px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.header:before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
pointer-events: none;
}
.header h1 {
margin: 0;
display: flex;
align-items: center;
font-size: 24px;
position: relative;
z-index: 1;
}
.header h1 span {
color: #ffcc00;
text-shadow: 0 0 5px rgba(0,0,0,0.2);
}
.sidebar {
width: 240px;
height: calc(100vh - 60px);
float: left;
background: linear-gradient(to bottom, #ffffff, #f5f7fb);
padding: 20px 0;
box-shadow: 2px 0 10px rgba(0,0,0,0.05);
transition: var(--transition);
}
.sidebar a {
display: flex;
align-items: center;
padding: 15px 25px;
text-decoration: none;
color: var(--dark);
font-size: 16px;
transition: var(--transition);
border-left: 3px solid transparent;
}
.sidebar a:hover {
background-color: rgba(67, 97, 238, 0.08);
border-left-color: var(--primary);
}
.sidebar a.active {
background-color: rgba(67, 97, 238, 0.15);
border-left-color: var(--primary);
font-weight: 500;
}
.sidebar a i {
margin-right: 12px;
font-size: 18px;
}
.content {
margin-left: 240px;
padding: 30px;
background-color: white;
min-height: calc(100vh - 60px);
box-shadow: -2px 0 10px rgba(0,0,0,0.05);
}
.card {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 25px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
height: auto;
position: fixed;
bottom: 0;
left: 0;
display: flex;
justify-content: space-around;
padding: 10px 0;
z-index: 100;
}
.sidebar a {
padding: 12px 15px;
border-left: none;
text-align: center;
font-size: 14px;
}
.sidebar a i {
display: block;
margin-right: 0;
margin-bottom: 5px;
}
.content {
margin-left: 0;
padding: 20px 15px;
min-height: calc(100vh - 110px);
}
}
</style>
</head>
<body>
<div class="header">
<h1><span>紫金</span> 稷下薪火·云枢智海师生成果共创系统</h1>
</div>
<div class="sidebar">
<a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>
<i>📊</i> 录入成果
</a>
<a href="{{ url_for('results_page') }}" {% if request.endpoint == 'results_page' %}class="active"{% endif %}>
<i>📈</i> 查询统计
</a>
<a href="{{ url_for('show_all') }}" {% if request.endpoint == 'show_all' %}class="active"{% endif %}>
<i>📁</i> 数据操作
</a>
</div>
<div class="content">
{% block content %}
{% endblock %}
</div>
<!-- 添加字体图标库 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</body>
</html>

View File

@@ -1,256 +0,0 @@
{% extends "base.html" %}
{% block title %}编辑成果信息 - 紫金·稷下薪火·云枢智海师生成果共创系统{% endblock %}
{% block content %}
<style>
/* 基础样式重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* 容器样式 */
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* 标题样式 */
h2 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
margin-bottom: 20px;
}
/* 表单样式 */
.form-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #2c3e50;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 12px;
border: 2px solid #e1e8ed;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-hint {
font-size: 12px;
color: #7f8c8d;
margin-top: 5px;
}
/* 按钮样式 */
.button-group {
display: flex;
gap: 15px;
margin-top: 30px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: linear-gradient(to right, #3498db, #2980b9);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.btn-secondary {
background: linear-gradient(to right, #95a5a6, #7f8c8d);
color: white;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(149, 165, 166, 0.3);
}
.btn-danger {
background: linear-gradient(to right, #e74c3c, #c0392b);
color: white;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(231, 76, 60, 0.3);
}
/* 图片预览样式 */
.image-preview {
margin-top: 10px;
text-align: center;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* 错误提示样式 */
.error-message {
color: #e74c3c;
font-size: 12px;
margin-top: 5px;
}
/* 必填字段标记 */
.required {
color: #e74c3c;
}
</style>
<div class="container">
<h2>编辑成果信息</h2>
<div class="form-container">
<form action="{{ url_for('update_entry', doc_id=document._id) }}" method="POST" id="editForm">
{% if document.data %}
{# 从原始数据中解析字段 #}
{% set data_string = document.data %}
{% set pairs = data_string.split('|###|') %}
{% for pair in pairs %}
{% if ':' in pair %}
{% set key_value = pair.split(':', 1) %}
{% set field_key = key_value[0].strip() %}
{% set field_value = key_value[1].strip() %}
{# 处理列表格式 [item1|##|item2] #}
{% if field_value.startswith('[') and field_value.endswith(']') %}
{% set list_content = field_value[1:-1] %}
{% set field_value = list_content.split('|##|')|join(', ') %}
{% endif %}
<div class="form-group">
<label for="field_{{ loop.index }}">{{ field_key }} <span class="required">*</span></label>
<input type="text" id="field_{{ loop.index }}" name="field_{{ loop.index }}" value="{{ field_value }}" required>
<input type="hidden" name="key_{{ loop.index }}" value="{{ field_key }}">
</div>
{% endif %}
{% endfor %}
{% else %}
{# 如果没有data字段显示提示信息 #}
<div class="form-group">
<p style="color: #e74c3c; text-align: center;">该记录没有可编辑的数据</p>
</div>
{% endif %}
{% if document.image %}
<div class="form-group">
<label>原图片预览</label>
<div class="image-preview">
<img src="{{ url_for('serve_image', filename=document.image) }}" alt="原图片" onerror="this.style.display='none'">
</div>
<div class="form-hint">当前关联的图片,编辑时无法修改图片</div>
</div>
{% endif %}
<div class="button-group">
<button type="submit" class="btn btn-primary">保存修改</button>
<a href="{{ url_for('show_all') }}" class="btn btn-secondary">取消返回</a>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">删除记录</button>
</div>
</form>
</div>
</div>
<script>
// 表单验证
document.getElementById('editForm').addEventListener('submit', function(e) {
// 检查所有字段是否都有值
const inputs = document.querySelectorAll('input[type="text"]');
let hasEmptyField = false;
inputs.forEach(input => {
if (!input.value.trim()) {
hasEmptyField = true;
input.style.borderColor = '#e74c3c';
} else {
input.style.borderColor = '#e1e8ed';
}
});
if (hasEmptyField) {
e.preventDefault();
alert('所有字段都必须填写!');
return false;
}
return true;
});
// 删除确认
function confirmDelete() {
if (confirm('确定要删除这条记录吗?此操作不可撤销!')) {
// 创建删除表单并提交
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("delete_entry", doc_id=document._id) }}';
document.body.appendChild(form);
form.submit();
}
}
// 自动格式化逗号分隔的值
document.querySelectorAll('input[type="text"]').forEach(input => {
input.addEventListener('blur', function(e) {
const value = e.target.value.trim();
if (value && value.includes(',')) {
// 格式化逗号分隔的值
const formatted = value
.split(',')
.map(item => item.trim())
.filter(item => item)
.join(', ');
e.target.value = formatted;
}
});
});
</script>
{% endblock %}

View File

@@ -1,617 +0,0 @@
{% extends "base.html" %}
{% block title %}录入成果 - 紫金·稷下薪火·云枢智海师生成果共创系统{% endblock %}
{% block content %}
<div class="card">
<h2 style="color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 10px;">
<i class="fas fa-cloud-upload-alt"></i> 成果录入
</h2>
<p class="mb-4">请上传包含成果信息的图片(如获奖证书、论文封面等),系统将自动识别关键信息</p>
<form id="upload-form" enctype="multipart/form-data" class="mb-4">
<div class="mb-3">
<label for="file" class="form-label">选择图片文件</label>
<input type="file" name="file" accept="image/*" id="file" class="form-control" required>
<div class="form-text">支持JPG、PNG、GIF等格式文件大小不超过10MB</div>
</div>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-upload"></i> 上传图片
</button>
</form>
<!-- 编辑确认区域 -->
<div id="edit-section" class="mt-4" style="display: none;">
<div class="card">
<h3 style="color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 10px;">
<i class="fas fa-edit"></i> 识别结果 - 请确认并编辑数据
</h3>
<p class="mb-4">系统已识别出以下信息,您可以修改字段名和对应的数据值,确认无误后点击录入按钮</p>
<form id="edit-form">
<div id="edit-fields"></div>
<div class="mt-4 text-center">
<button type="button" id="confirm-btn" class="btn btn-success btn-lg">
<i class="fas fa-check"></i> 确认录入
</button>
<button type="button" id="cancel-btn" class="btn btn-secondary btn-lg ml-3">
<i class="fas fa-times"></i> 取消
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let currentData = null;
let currentImage = null;
document.getElementById("upload-form").addEventListener("submit", function (e) {
e.preventDefault();
let formData = new FormData(this);
const editSection = document.getElementById("edit-section");
// 显示上传进度动画
editSection.innerHTML = `
<div class="card">
<div class="progress-container">
<div class="progress-bar"></div>
<p class="progress-text">正在处理图片,请稍候...</p>
</div>
</div>
`;
editSection.style.display = "block";
fetch("/upload", { method: "POST", body: formData })
.then(res => res.json())
.then(data => {
if(data.error) {
editSection.innerHTML = `
<div class="card">
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i> 错误: ${data.error}
</div>
</div>
`;
} else {
// 存储当前数据
currentData = data.data;
currentImage = data.image;
// 生成编辑表单
generateEditForm(data.data);
}
})
.catch(error => {
editSection.innerHTML = `
<div class="card">
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i> 上传失败: ${error}
</div>
</div>
`;
});
});
function generateEditForm(data) {
const editSection = document.getElementById("edit-section");
let fieldsHtml = "";
Object.entries(data).forEach(([key, value], index) => {
fieldsHtml += `
<div class="field-row mb-3">
<div class="row">
<div class="col-md-4">
<label class="form-label">字段名</label>
<input type="text" class="form-control field-name" value="${key}" data-original-key="${key}">
</div>
<div class="col-md-6">
<label class="form-label">数据值</label>
<input type="text" class="form-control field-value" value="${value}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm delete-field" title="删除此字段">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`;
});
editSection.innerHTML = `
<div class="card">
<h3 style="color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 10px;">
<i class="fas fa-edit"></i> 识别结果 - 请确认并编辑数据
</h3>
<p class="mb-4">系统已识别出以下信息,您可以修改字段名和对应的数据值,确认无误后点击录入按钮</p>
<form id="edit-form">
<div id="edit-fields">
${fieldsHtml}
</div>
<div class="mb-3">
<button type="button" id="add-field-btn" class="btn btn-outline-primary">
<i class="fas fa-plus"></i> 添加字段
</button>
</div>
<div class="mt-4 text-center">
<button type="button" id="confirm-btn" class="btn btn-success btn-lg">
<i class="fas fa-check"></i> 确认录入
</button>
<button type="button" id="cancel-btn" class="btn btn-secondary btn-lg ml-3">
<i class="fas fa-times"></i> 取消
</button>
</div>
</form>
</div>
`;
// 绑定删除按钮事件
document.querySelectorAll('.delete-field').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.field-row').remove();
});
});
// 绑定添加字段按钮事件
document.getElementById('add-field-btn').addEventListener('click', function() {
const editFields = document.getElementById('edit-fields');
const newFieldHtml = `
<div class="field-row mb-3">
<div class="row">
<div class="col-md-4">
<label class="form-label">字段名</label>
<input type="text" class="form-control field-name" value="" data-original-key="">
</div>
<div class="col-md-6">
<label class="form-label">数据值</label>
<input type="text" class="form-control field-value" value="">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm delete-field" title="删除此字段">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`;
editFields.insertAdjacentHTML('beforeend', newFieldHtml);
// 为新添加的删除按钮绑定事件
const newDeleteBtn = editFields.lastElementChild.querySelector('.delete-field');
newDeleteBtn.addEventListener('click', function() {
this.closest('.field-row').remove();
});
});
// 绑定确认和取消按钮事件
bindConfirmCancelEvents();
}
function bindConfirmCancelEvents() {
// 确认录入按钮事件
document.getElementById("confirm-btn").addEventListener("click", function() {
const fieldRows = document.querySelectorAll('.field-row');
const editedData = {};
// 收集编辑后的数据
fieldRows.forEach(row => {
const fieldName = row.querySelector('.field-name').value.trim();
const fieldValue = row.querySelector('.field-value').value.trim();
if (fieldName && fieldValue) {
editedData[fieldName] = fieldValue;
}
});
if (Object.keys(editedData).length === 0) {
alert('请至少保留一个有效的字段!');
return;
}
// 发送确认请求
fetch("/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
data: editedData,
image: currentImage
})
})
.then(res => res.json())
.then(data => {
const editSection = document.getElementById("edit-section");
if(data.error) {
editSection.innerHTML = `
<div class="card">
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i> 录入失败: ${data.error}
</div>
</div>
`;
} else {
editSection.innerHTML = `
<div class="card">
<div class="alert alert-success">
<i class="fas fa-check-circle"></i> ${data.message}
</div>
</div>
`;
// 重置表单
document.getElementById("upload-form").reset();
// 3秒后隐藏成功消息
setTimeout(() => {
editSection.style.display = "none";
}, 3000);
}
})
.catch(error => {
const editSection = document.getElementById("edit-section");
editSection.innerHTML = `
<div class="card">
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i> 录入失败: ${error}
</div>
</div>
`;
});
});
// 取消按钮事件
document.getElementById("cancel-btn").addEventListener("click", function() {
const editSection = document.getElementById("edit-section");
editSection.style.display = "none";
currentData = null;
currentImage = null;
});
}
</script>
<style>
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
border: none;
border-radius: 30px;
padding: 12px 24px;
font-weight: 500;
transition: var(--transition);
box-shadow: 0 4px 8px rgba(67, 97, 238, 0.2);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-light), var(--primary));
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(67, 97, 238, 0.3);
}
.form-control {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 16px;
transition: var(--transition);
}
.form-control:hover {
border-color: var(--primary);
}
.progress-container {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
margin-bottom: 15px;
position: relative;
overflow: hidden;
}
.progress-bar::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 80%;
height: 100%;
background: linear-gradient(90deg, transparent, #4cc9f0, transparent);
animation: progress 1.5s infinite;
}
@keyframes progress {
0% { left: -100%; }
100% { left: 200%; }
}
.progress-text {
color: #6c757d;
font-size: 16px;
}
.alert {
padding: 15px;
border-radius: 8px;
}
.alert-danger {
background-color: #ffe3e3;
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.result-card {
background: white;
border-radius: 12px;
box-shadow: var(--shadow);
padding: 20px;
border-left: 4px solid var(--success);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.result-header h3 {
color: var(--success);
margin: 0;
}
.timestamp {
color: #6c757d;
font-size: 14px;
}
.result-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.result-label {
font-weight: 500;
color: #495057;
}
.result-value {
color: #333;
max-width: 70%;
word-break: break-word;
}
.result-footer {
margin-top: 20px;
text-align: center;
}
.success-message {
color: var(--success);
font-weight: 500;
margin-bottom: 15px;
}
.info-message {
color: var(--primary);
font-weight: 500;
margin-bottom: 15px;
}
.btn-outline-primary {
border: 1px solid var(--primary);
color: var(--primary);
border-radius: 6px;
padding: 8px 16px;
transition: var(--transition);
}
.btn-outline-primary:hover {
background-color: var(--primary);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #28a745, #20c997);
border: none;
border-radius: 30px;
padding: 12px 24px;
font-weight: 500;
transition: var(--transition);
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.2);
color: white;
}
.btn-success:hover {
background: linear-gradient(135deg, #20c997, #28a745);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(40, 167, 69, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #6c757d, #495057);
border: none;
border-radius: 30px;
padding: 12px 24px;
font-weight: 500;
transition: var(--transition);
box-shadow: 0 4px 8px rgba(108, 117, 125, 0.2);
color: white;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #495057, #6c757d);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(108, 117, 125, 0.3);
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border-radius: 0.2rem;
}
.ml-3 {
margin-left: 1rem;
}
.btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
background-color: transparent;
padding: 8px 16px;
border-radius: 4px;
border: 1px solid var(--primary);
transition: all 0.3s ease;
}
.btn-outline-primary:hover {
background-color: var(--primary);
border-color: var(--primary);
color: white;
}
.alert-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.25rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid #28a745;
}
#edit-section .card {
border-left: 4px solid var(--primary);
}
.form-label {
font-weight: 500;
color: #495057;
margin-bottom: 8px;
}
.field-row {
background-color: white;
padding: 15px;
border-radius: 5px;
border: 1px solid #e0e0e0;
margin-bottom: 10px;
}
.field-row:hover {
border-color: var(--primary);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.row {
display: flex;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
}
.col-md-4, .col-md-6, .col-md-2 {
position: relative;
width: 100%;
padding-right: 15px;
padding-left: 15px;
}
.col-md-2 {
flex: 0 0 16.666667%;
max-width: 16.666667%;
}
.col-md-4 {
flex: 0 0 33.333333%;
max-width: 33.333333%;
}
.col-md-6 {
flex: 0 0 50%;
max-width: 50%;
}
.d-flex {
display: flex;
}
.align-items-end {
align-items: flex-end;
}
.text-center {
text-align: center;
}
.mb-3 {
margin-bottom: 1rem;
}
.mb-4 {
margin-bottom: 1.5rem;
}
.mt-4 {
margin-top: 1.5rem;
}
.btn-lg {
padding: 0.5rem 1rem;
font-size: 1.25rem;
border-radius: 0.3rem;
}
@media (max-width: 768px) {
.col-md-2, .col-md-4, .col-md-6 {
flex: 0 0 100%;
max-width: 100%;
margin-bottom: 10px;
}
.field-row .row {
flex-direction: column;
}
.btn-lg {
width: 100%;
margin-bottom: 10px;
}
.ml-3 {
margin-left: 0;
}
}
</style>
{% endblock %}

View File

@@ -1,362 +0,0 @@
{% extends "base.html" %}
{% block title %}查询统计 - 紫金·稷下薪火·云枢智海师生成果共创系统{% endblock %}
{% block content %}
<style>
/* 基础样式重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* 主体布局 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* 标题样式 */
h2 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-bottom: 20px;
}
/* 搜索区域样式 */
.search-container {
background: #f8f9fa;
padding: 25px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.search-form {
display: flex;
gap: 10px;
}
.search-input {
flex: 1;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}
.search-input:focus {
border-color: #3498db;
outline: none;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.search-button {
padding: 12px 25px;
background: linear-gradient(135deg, #3498db, #1a5276);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s;
}
.search-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.4);
}
/* 结果区域样式 */
.results-container {
min-height: 300px;
}
.result-item {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border-left: 4px solid #3498db;
transition: transform 0.3s;
cursor: pointer;
}
.result-item:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.result-preview {
margin-bottom: 15px;
}
.result-preview .field-item {
display: inline-block;
margin-right: 20px;
margin-bottom: 8px;
padding: 5px 10px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.result-preview .field-label {
font-weight: bold;
color: #2c3e50;
margin-right: 5px;
}
.result-preview .field-value {
color: #34495e;
}
.result-details {
display: none;
border-top: 1px solid #e9ecef;
padding-top: 15px;
margin-top: 15px;
}
.result-details.expanded {
display: block;
}
.result-details .field-item {
margin-bottom: 10px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.result-details .field-label {
font-weight: bold;
color: #2c3e50;
display: inline-block;
min-width: 120px;
}
.result-details .field-value {
color: #34495e;
}
.expand-indicator {
float: right;
color: #3498db;
font-size: 14px;
transition: all 0.3s;
}
.result-item.expanded .expand-indicator {
color: #2c3e50;
}
.image-container {
margin-top: 15px;
text-align: center;
}
.result-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.3s;
}
.result-image:hover {
transform: scale(1.05);
}
.image-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
cursor: pointer;
}
.image-modal img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90%;
max-height: 90%;
border-radius: 8px;
}
.close-modal {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 30px;
font-weight: bold;
cursor: pointer;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px;
color: #7f8c8d;
font-style: italic;
}
/* 错误信息 */
.error {
color: #e74c3c;
padding: 20px;
text-align: center;
background: rgba(231, 76, 60, 0.1);
border-radius: 6px;
}
</style>
<div class="container">
<h2>奖项成果查询</h2>
<p>输入关键词(如姓名、奖项名等)搜索已录入的成果信息</p>
<div class="search-container">
<form id="search-form" class="search-form">
<input type="text" name="q" class="search-input" placeholder="输入关键词(如姓名、奖项名等)" required>
<button type="submit" class="search-button">搜索</button>
</form>
</div>
<div id="results" class="results-container">
<!-- 结果将通过JS动态加载 -->
</div>
</div>
<script>
document.getElementById("search-form").addEventListener("submit", function (e) {
e.preventDefault();
const q = this.q.value;
const resultsContainer = document.getElementById("results");
// 显示加载状态
resultsContainer.innerHTML = '<div class="loading">正在搜索,请稍候...</div>';
fetch(`/search?q=${encodeURIComponent(q)}`)
.then(res => res.json())
.then(data => {
const realData = data.hits?.hits || data;
if (!Array.isArray(realData) || realData.length === 0) {
resultsContainer.innerHTML = '<div class="error">未找到相关结果</div>';
return;
}
const html = realData.map((item, index) => {
const source = item._source || {};
const allFields = Object.entries(source).filter(([key, value]) => key !== 'image' && value);
// 获取前3个字段作为预览
const previewFields = allFields.slice(0, 3);
const hasMoreFields = allFields.length > 3;
// 生成预览字段HTML
const previewHtml = previewFields.map(([key, value]) => `
<div class="field-item">
<span class="field-label">${key}:</span>
<span class="field-value">${Array.isArray(value) ? value.join(', ') : value}</span>
</div>
`).join('');
// 生成详细字段HTML
const detailsHtml = allFields.map(([key, value]) => `
<div class="field-item">
<span class="field-label">${key}:</span>
<span class="field-value">${Array.isArray(value) ? value.join(', ') : value}</span>
</div>
`).join('');
// 图片HTML
const imageHtml = source.image ? `
<div class="image-container">
<img src="/image/${source.image}" alt="相关图片" class="result-image" onclick="openImageModal('/image/${source.image}')">
</div>
` : '';
return `
<div class="result-item" onclick="toggleDetails(${index})" data-index="${index}">
<div class="result-preview">
${previewHtml}
${hasMoreFields ? '<span class="expand-indicator">▼ 点击查看更多</span>' : ''}
</div>
<div class="result-details" id="details-${index}">
${detailsHtml}
${imageHtml}
</div>
</div>
`;
}).join('');
resultsContainer.innerHTML = html;
})
.catch(err => {
resultsContainer.innerHTML = '<div class="error">搜索过程中发生错误</div>';
});
});
function toggleDetails(index) {
const resultItem = document.querySelector(`[data-index="${index}"]`);
const detailsDiv = document.getElementById(`details-${index}`);
if (detailsDiv.classList.contains('expanded')) {
detailsDiv.classList.remove('expanded');
resultItem.classList.remove('expanded');
} else {
detailsDiv.classList.add('expanded');
resultItem.classList.add('expanded');
}
}
function openImageModal(imageSrc) {
event.stopPropagation(); // 阻止事件冒泡
// 创建模态框
const modal = document.createElement('div');
modal.className = 'image-modal';
modal.innerHTML = `
<span class="close-modal" onclick="closeImageModal()">&times;</span>
<img src="${imageSrc}" alt="图片预览">
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// 点击模态框背景关闭
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeImageModal();
}
});
}
function closeImageModal() {
const modal = document.querySelector('.image-modal');
if (modal) {
modal.remove();
}
}
// ESC键关闭模态框
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeImageModal();
}
});
</script>
{% endblock %}