Compare commits
191 Commits
2bc7dad44c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e725db79a9 | |||
| 1389ec7434 | |||
| 14b506e8a1 | |||
| 8c0403e0ff | |||
| 7d42f96e42 | |||
| 7627885b1b | |||
| cb35df5f01 | |||
| 7007f5f6f5 | |||
| ebfac7266b | |||
| f645a0f8ea | |||
| 08864e51ba | |||
| 7997a97a9a | |||
| 122e12728c | |||
| 225abbe7b6 | |||
| 855b1e340b | |||
| ed0b74eae3 | |||
| 4874aa37f6 | |||
| c285d1e348 | |||
| 06fe7f6f68 | |||
| 99ecbcf55e | |||
| 43f1867033 | |||
| 9d1d4d701e | |||
| b84a5abb8a | |||
| 6a9858cdec | |||
| 52dd7ac9e5 | |||
| dd7f03ef94 | |||
| ff67a9bbab | |||
| d77e439712 | |||
| e93db6fbf1 | |||
| 592d48dde0 | |||
| a50294933b | |||
| 04b94d7fb2 | |||
| 21c537da10 | |||
| 2d5be0feb2 | |||
| 462e12ca0d | |||
| ce96b17392 | |||
| 69e3e4c45c | |||
| 12e07962c7 | |||
| dd9cc5a76d | |||
| f7832c3c15 | |||
| d097571f62 | |||
| 121251a1f2 | |||
| 6eb0651e23 | |||
| 9991834ccd | |||
| bf29ac1924 | |||
| fae21329d7 | |||
| f46f325518 | |||
| 156d3a97b2 | |||
|
|
f90d840dfe | ||
|
|
dfc93bce2e | ||
| 10be464265 | |||
| e65485cb1e | |||
| 42609651bd | |||
|
|
072fbcbe18 | ||
|
|
3702026f9a | ||
|
|
00597fb3b7 | ||
|
|
42e88f4924 | ||
|
|
56b3ca4e59 | ||
|
|
92d4e2ba58 | ||
|
|
67340f75be | ||
|
|
803fe436d3 | ||
|
|
7e9a772104 | ||
| 722dccdc78 | |||
| 606cad43dc | |||
| 6313631b09 | |||
| 09188b8765 | |||
| b2b0dfd500 | |||
| 2ff9e18842 | |||
| 1bd49f804c | |||
| f8bbe3da0d | |||
| 1b85f34a0f | |||
| f04c5c1c8f | |||
|
|
b1484e9c03 | ||
|
|
7f5e9d5244 | ||
|
|
0ca6dfe5a7 | ||
|
|
15fe2093c2 | ||
| 30b6e3144b | |||
| da71b0ac5e | |||
| 3c6799ce53 | |||
| a3060e4cd9 | |||
| be9ec5e9a2 | |||
| b0fe325154 | |||
| 13544867aa | |||
| 5fd13fa152 | |||
| a36fa3370b | |||
| 2ec0c7cb58 | |||
| 3841358bc2 | |||
| 6bd00021b8 | |||
| f2652af8fb | |||
| 950af0efda | |||
| 45cbdc51b4 | |||
| 703a62b6ad | |||
| ad847888f8 | |||
| 8c150dcb7c | |||
| ec90404194 | |||
| 6af8f46129 | |||
| 19f083df7b | |||
| a04cbfa55f | |||
| b78bc7935f | |||
| d8897f893a | |||
| 95d7cbc825 | |||
| 6b416dc5f1 | |||
| 28e86b1147 | |||
| 1e216ea356 | |||
| 3bae759afc | |||
| 5137addacc | |||
| 4c492122dd | |||
| 82acc3470f | |||
| 2e44afde30 | |||
| 28f4e807a9 | |||
| 3b04f04a31 | |||
| ea1c7e8a00 | |||
| d269a99d3c | |||
| 7c3d0a0947 | |||
| 7cf39f80bc | |||
| d108f3b51d | |||
| 41ead4bead | |||
| 2d98ab66a3 | |||
| 70e09c83a8 | |||
| 01ffaf96a3 | |||
| 1b20478a08 | |||
| 3d9dba272e | |||
| 89f3fd9759 | |||
| 4ba933b001 | |||
| 759d99e8af | |||
| 46253337eb | |||
| e3b0c35da6 | |||
| f90b0a4f11 | |||
|
|
3214cbbd91 | ||
|
|
c32f7b797d | ||
| ec84903745 | |||
| 151fc3e09f | |||
| 1d44859857 | |||
| 79a571e58d | |||
| 5f4fb50001 | |||
| c5f019be5a | |||
| 433a3cb806 | |||
| 5851120f87 | |||
| 9028f7e272 | |||
| 2ef0a10d6f | |||
| 2f5940d339 | |||
| db4201a269 | |||
| 5393648d21 | |||
| 3f667dd21b | |||
| 6827d11f40 | |||
| 4c9bed0f0b | |||
|
|
4571a83492 | ||
| 0e587ca497 | |||
| 1b707fdace | |||
| 2abb9ab494 | |||
| e699f4fb94 | |||
| d650c6f584 | |||
| 9aa28f5b9e | |||
| 738eaa5211 | |||
| 8846945208 | |||
| 49897081b6 | |||
| a8b9f225bd | |||
| de6d269fb4 | |||
| 0d1397debe | |||
| 809c735498 | |||
| 10d5363e29 | |||
| a619e96e73 | |||
| dab29347bd | |||
| 1ec0d80f6c | |||
| dac06d187e | |||
| a346509a5f | |||
| e01a7b5235 | |||
| 949ff1e53a | |||
| cbddaf810e | |||
| a3497f2921 | |||
| 94ceb959c7 | |||
| b408e78c74 | |||
| 4400ded86a | |||
| 4510fbba83 | |||
| a9c8aac48f | |||
| 2ec88eac3b | |||
| 8931ad5d7f | |||
| b4c7a3eac9 | |||
| 757de96fd9 | |||
| 69443c2de6 | |||
| 54cf6fe538 | |||
| 9798ed52c3 | |||
| 324e0d614a | |||
| 34f44eed3e | |||
| 39153cc946 | |||
| 56d2cadd81 | |||
| 6bff5e4d97 | |||
| 85a60009f3 | |||
| 49bc8b83b1 | |||
| 235ba28cc8 | |||
| 3c31f30ee4 |
27
.env.example
27
.env.example
@@ -13,11 +13,36 @@ FLASK_DEBUG=false
|
|||||||
|
|
||||||
# Session配置
|
# Session配置
|
||||||
SESSION_LIFETIME_HOURS=24
|
SESSION_LIFETIME_HOURS=24
|
||||||
SESSION_COOKIE_SECURE=false # 使用HTTPS时设为true
|
SESSION_COOKIE_SECURE=true # 生产环境HTTPS必须为true,本地HTTP调试可临时设为false
|
||||||
|
HTTPS_ENABLED=true
|
||||||
|
# 是否信任 X-Forwarded-* 代理头(默认关闭,建议仅在可信反代后开启)
|
||||||
|
TRUST_PROXY_HEADERS=false
|
||||||
|
# TRUST_PROXY_HEADERS=true 时生效,按需配置你的反向代理网段
|
||||||
|
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||||
|
# 可选:首次启动时指定默认管理员密码(避免控制台输出明文密码)
|
||||||
|
# DEFAULT_ADMIN_PASSWORD=your-strong-admin-password
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DB_FILE=data/app_data.db
|
DB_FILE=data/app_data.db
|
||||||
DB_POOL_SIZE=5
|
DB_POOL_SIZE=5
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS=10
|
||||||
|
DB_BUSY_TIMEOUT_MS=10000
|
||||||
|
DB_CACHE_SIZE_KB=8192
|
||||||
|
DB_WAL_AUTOCHECKPOINT_PAGES=1000
|
||||||
|
DB_MMAP_SIZE_MB=256
|
||||||
|
DB_LOCK_RETRY_COUNT=3
|
||||||
|
DB_LOCK_RETRY_BASE_MS=50
|
||||||
|
DB_SLOW_QUERY_MS=120
|
||||||
|
DB_SLOW_QUERY_SQL_MAX_LEN=240
|
||||||
|
DB_SLOW_SQL_WINDOW_SECONDS=86400
|
||||||
|
DB_SLOW_SQL_TOP_LIMIT=12
|
||||||
|
DB_SLOW_SQL_RECENT_LIMIT=50
|
||||||
|
DB_SLOW_SQL_MAX_EVENTS=20000
|
||||||
|
DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS=21600
|
||||||
|
DB_ANALYZE_INTERVAL_SECONDS=86400
|
||||||
|
DB_WAL_CHECKPOINT_INTERVAL_SECONDS=43200
|
||||||
|
DB_WAL_CHECKPOINT_MODE=PASSIVE
|
||||||
|
SYSTEM_CONFIG_CACHE_TTL_SECONDS=30
|
||||||
|
|
||||||
# ==================== 并发控制配置 ====================
|
# ==================== 并发控制配置 ====================
|
||||||
MAX_CONCURRENT_GLOBAL=2
|
MAX_CONCURRENT_GLOBAL=2
|
||||||
|
|||||||
168
.gitignore
vendored
168
.gitignore
vendored
@@ -1,58 +1,152 @@
|
|||||||
# 浏览器二进制文件
|
# Python
|
||||||
playwright/
|
|
||||||
ms-playwright/
|
|
||||||
|
|
||||||
# 数据库文件(敏感数据)
|
|
||||||
data/*.db
|
|
||||||
data/*.db-shm
|
|
||||||
data/*.db-wal
|
|
||||||
data/*.backup*
|
|
||||||
data/secret_key.txt
|
|
||||||
|
|
||||||
# Cookies(敏感用户凭据)
|
|
||||||
data/cookies/
|
|
||||||
|
|
||||||
# 日志文件
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# 截图文件
|
|
||||||
截图/
|
|
||||||
|
|
||||||
# Python缓存
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.class
|
*$py.class
|
||||||
*.so
|
*.so
|
||||||
.Python
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Test and tool directories
|
||||||
|
tests/
|
||||||
|
tools/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
# 环境变量文件(包含敏感信息)
|
# Spyder project settings
|
||||||
.env
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
# Docker volumes
|
# Rope project settings
|
||||||
volumes/
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
screenshots/
|
||||||
|
截图/
|
||||||
|
ruff_cache/
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.bmp
|
||||||
|
*.ico
|
||||||
|
*.pdf
|
||||||
|
qr_code_*.png
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
test_*.py
|
||||||
|
start_*.bat
|
||||||
|
temp_*.py
|
||||||
|
kdocs_*test*.py
|
||||||
|
simple_test.py
|
||||||
|
tools/
|
||||||
|
*.sh
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# 系统文件
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# 临时文件
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.temp
|
||||||
*.backup
|
|
||||||
|
|
||||||
# 部署脚本(含服务器信息)
|
# Allow committed test cases
|
||||||
deploy_*.sh
|
!tests/
|
||||||
verify_*.sh
|
!tests/**/*.py
|
||||||
|
|
||||||
# 内部文档
|
|
||||||
docs/
|
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -1,14 +1,18 @@
|
|||||||
# 使用国内镜像源加速
|
# 使用国内镜像源加速
|
||||||
FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy
|
FROM python:3.10-slim-bullseye
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 设置环境变量
|
# 设置环境变量
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 安装 wkhtmltopdf(包含 wkhtmltoimage)与中文字体
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends wkhtmltopdf curl fonts-noto-cjk && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 配置 pip 使用国内镜像源
|
# 配置 pip 使用国内镜像源
|
||||||
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set install.trusted-host mirrors.aliyun.com
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set install.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
@@ -18,16 +22,15 @@ COPY requirements.txt .
|
|||||||
# 安装Python依赖
|
# 安装Python依赖
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 安装 Playwright 浏览器依赖与 Chromium
|
||||||
|
RUN python -m playwright install --with-deps chromium
|
||||||
|
|
||||||
# 复制应用程序文件
|
# 复制应用程序文件
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY database.py .
|
COPY database.py .
|
||||||
COPY db_pool.py .
|
COPY db_pool.py .
|
||||||
COPY playwright_automation.py .
|
|
||||||
COPY api_browser.py .
|
COPY api_browser.py .
|
||||||
COPY browser_pool.py .
|
|
||||||
COPY browser_pool_worker.py .
|
COPY browser_pool_worker.py .
|
||||||
COPY screenshot_worker.py .
|
|
||||||
COPY browser_installer.py .
|
|
||||||
COPY password_utils.py .
|
COPY password_utils.py .
|
||||||
COPY crypto_utils.py .
|
COPY crypto_utils.py .
|
||||||
COPY task_checkpoint.py .
|
COPY task_checkpoint.py .
|
||||||
@@ -37,8 +40,11 @@ COPY email_service.py .
|
|||||||
COPY app_config.py .
|
COPY app_config.py .
|
||||||
COPY app_logger.py .
|
COPY app_logger.py .
|
||||||
COPY app_security.py .
|
COPY app_security.py .
|
||||||
COPY app_state.py .
|
COPY routes/ ./routes/
|
||||||
COPY app_utils.py .
|
COPY services/ ./services/
|
||||||
|
COPY realtime/ ./realtime/
|
||||||
|
COPY db/ ./db/
|
||||||
|
COPY security/ ./security/
|
||||||
|
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
|
|||||||
352
README.md
352
README.md
@@ -1,59 +1,107 @@
|
|||||||
# 知识管理平台自动化工具 - Docker部署版
|
# 知识管理平台自动化工具 - Docker部署版
|
||||||
|
|
||||||
这是一个基于 Docker 的知识管理平台自动化工具,支持多用户、定时任务、代理IP、VIP管理等功能。
|
这是一个基于 Docker 的知识管理平台自动化工具,支持多用户、定时任务、代理IP、VIP管理、金山文档集成等功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 近期更新(2026-02)
|
||||||
|
|
||||||
|
- Socket.IO 运行模式已切换为 `eventlet`(生产优先)。
|
||||||
|
- 管理端前端增加请求缓存/去重,降低报表页重复请求压力。
|
||||||
|
- 默认 Docker 端口映射更新为 `51232 -> 51233`。
|
||||||
|
- 已清理仓库中的历史清理报告与明显冗余文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
本项目是一个 **Docker 容器化应用**,使用 Flask + Playwright + SQLite 构建,提供:
|
本项目是一个 **Docker 容器化应用**,使用 Flask + Vue 3 + Requests + wkhtmltoimage + SQLite 构建,提供:
|
||||||
|
|
||||||
- 多用户注册登录系统
|
### 核心功能
|
||||||
- 浏览器自动化任务
|
- 多用户注册登录系统(支持邮箱绑定与验证)
|
||||||
- 定时任务调度
|
- 自动化浏览任务(纯 HTTP API 模拟,速度快)
|
||||||
- 截图管理
|
- 智能截图系统(wkhtmltoimage,支持线程池)
|
||||||
- VIP用户管理
|
- 用户自定义定时任务(支持随机延迟)
|
||||||
- 代理IP支持
|
- VIP 用户管理(账号数量限制、优先队列)
|
||||||
- 后台管理系统
|
|
||||||
|
### 集成功能
|
||||||
|
- **金山文档集成** - 自动上传截图到在线表格,支持姓名搜索匹配
|
||||||
|
- **邮件通知** - 任务完成通知、密码重置、邮箱验证
|
||||||
|
- **代理IP支持** - 动态代理API集成
|
||||||
|
|
||||||
|
### 安全功能
|
||||||
|
- 威胁检测引擎(JNDI/SQL注入/XSS/命令注入检测)
|
||||||
|
- IP/用户风险评分系统
|
||||||
|
- 自动黑名单机制
|
||||||
|
- 登录设备指纹追踪
|
||||||
|
|
||||||
|
### 管理功能
|
||||||
|
- 现代化 Vue 3 SPA 后台管理界面
|
||||||
|
- 公告系统(支持图片)
|
||||||
|
- Bug 反馈系统
|
||||||
|
- 任务日志与统计
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**: Python 3.8+, Flask
|
- **后端**: Python 3.10+, Flask, Flask-SocketIO
|
||||||
- **数据库**: SQLite
|
- **前端**: Vue 3 + Vite + Element Plus (SPA)
|
||||||
- **自动化**: Playwright (Chromium)
|
- **数据库**: SQLite + 连接池
|
||||||
|
- **自动化**: Requests + BeautifulSoup (浏览)
|
||||||
|
- **截图**: wkhtmltoimage
|
||||||
|
- **金山文档**: Playwright (表格操作/上传)
|
||||||
- **容器化**: Docker + Docker Compose
|
- **容器化**: Docker + Docker Compose
|
||||||
- **前端**: HTML + JavaScript + Socket.IO
|
- **实时通信**: Socket.IO (WebSocket)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
zsgpt2/
|
zsglpt/
|
||||||
├── app.py # 主应用程序
|
├── app.py # 启动/装配入口
|
||||||
├── database.py # 数据库模块
|
├── routes/ # 路由层(Blueprint)
|
||||||
├── playwright_automation.py # 浏览器自动化
|
│ ├── api_*.py # API 路由
|
||||||
├── browser_installer.py # 浏览器安装检查
|
│ ├── admin_api/ # 管理后台 API
|
||||||
|
│ └── pages.py # 页面路由
|
||||||
|
├── services/ # 业务服务层
|
||||||
|
│ ├── tasks.py # 任务调度器
|
||||||
|
│ ├── screenshots.py # 截图服务
|
||||||
|
│ ├── kdocs_uploader.py # 金山文档上传服务
|
||||||
|
│ └── schedule_*.py # 定时任务相关
|
||||||
|
├── security/ # 安全防护模块
|
||||||
|
│ ├── threat_detector.py # 威胁检测引擎
|
||||||
|
│ ├── risk_scorer.py # 风险评分
|
||||||
|
│ ├── blacklist.py # 黑名单管理
|
||||||
|
│ └── middleware.py # 安全中间件
|
||||||
|
├── realtime/ # SocketIO 事件与推送
|
||||||
|
├── database.py # 数据库稳定门面(对外 API)
|
||||||
|
├── db/ # DB 分域实现 + schema/migrations
|
||||||
|
├── db_pool.py # 数据库连接池
|
||||||
|
├── api_browser.py # Requests 自动化(主浏览流程)
|
||||||
|
├── browser_pool_worker.py # wkhtmltoimage 截图线程池
|
||||||
├── app_config.py # 配置管理
|
├── app_config.py # 配置管理
|
||||||
├── app_logger.py # 日志系统
|
├── app_logger.py # 日志系统
|
||||||
├── app_security.py # 安全模块
|
├── app_security.py # 安全工具函数
|
||||||
├── app_state.py # 状态管理
|
├── password_utils.py # 密码哈希工具
|
||||||
├── app_utils.py # 工具函数
|
├── crypto_utils.py # 加解密工具
|
||||||
├── db_pool.py # 数据库连接池
|
├── email_service.py # 邮件服务(SMTP)
|
||||||
├── password_utils.py # 密码工具
|
|
||||||
├── requirements.txt # Python依赖
|
├── requirements.txt # Python依赖
|
||||||
|
├── requirements-dev.txt # 开发依赖(不进生产镜像)
|
||||||
|
├── pyproject.toml # ruff/pytest 配置
|
||||||
├── Dockerfile # Docker镜像构建文件
|
├── Dockerfile # Docker镜像构建文件
|
||||||
├── docker-compose.yml # Docker编排文件
|
├── docker-compose.yml # Docker编排文件
|
||||||
├── templates/ # HTML模板
|
├── templates/ # HTML模板(SPA 入口)
|
||||||
│ ├── index.html # 主页面
|
│ ├── app.html # 用户端 SPA 入口
|
||||||
│ ├── login.html # 登录页
|
│ ├── admin.html # 管理端 SPA 入口
|
||||||
│ ├── register.html # 注册页
|
│ └── email/ # 邮件模板
|
||||||
│ ├── admin.html # 后台管理
|
├── app-frontend/ # 用户端 Vue 源码
|
||||||
│ └── ...
|
├── admin-frontend/ # 管理端 Vue 源码
|
||||||
└── static/ # 静态资源
|
├── static/ # 前端构建产物
|
||||||
└── js/ # JavaScript文件
|
│ ├── app/ # 用户端 SPA 资源
|
||||||
|
│ └── admin/ # 管理端 SPA 资源
|
||||||
|
└── scripts/ # 维护脚本(例如健康监控)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,20 +134,56 @@ ssh -i /path/to/key root@your-server-ip
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 3. 配置加密密钥(重要!)
|
||||||
|
|
||||||
|
系统使用 Fernet 对称加密保护用户账号密码。**首次部署或迁移时必须正确配置加密密钥!**
|
||||||
|
|
||||||
|
#### 方式一:使用 .env 文件(推荐)
|
||||||
|
|
||||||
|
在项目根目录创建 `.env` 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/zsglpt
|
||||||
|
|
||||||
|
# 生成随机密钥
|
||||||
|
python3 -c "from cryptography.fernet import Fernet; print(f'ENCRYPTION_KEY_RAW={Fernet.generate_key().decode()}')" > .env
|
||||||
|
|
||||||
|
# 设置权限(仅 root 可读)
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方式二:已有密钥迁移
|
||||||
|
|
||||||
|
如果从其他服务器迁移,需要复制原有的密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从旧服务器复制 .env 文件
|
||||||
|
scp root@old-server:/www/wwwroot/zsglpt/.env /www/wwwroot/zsglpt/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ 重要警告
|
||||||
|
|
||||||
|
- **密钥丢失 = 所有加密密码无法解密**,必须重新录入所有账号密码
|
||||||
|
- `.env` 文件已在 `.gitignore` 中,不会被提交到 Git
|
||||||
|
- 建议将密钥备份到安全的地方(如密码管理器)
|
||||||
|
- 系统启动时会检测密钥,如果密钥丢失但存在加密数据,将拒绝启动并报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速部署
|
## 快速部署
|
||||||
|
|
||||||
### 步骤1: 上传项目文件
|
### 步骤1: 上传项目文件
|
||||||
|
|
||||||
将整个 `zsgpt2` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
|
将整个 `zsglpt` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在本地执行(Windows PowerShell 或 Git Bash)
|
# 在本地执行(Windows PowerShell 或 Git Bash)
|
||||||
scp -r C:\Users\Administrator\Desktop\zsgpt2 root@your-server-ip:/www/wwwroot/
|
scp -r C:\Users\Administrator\Desktop\zsglpt root@your-server-ip:/www/wwwroot/
|
||||||
|
|
||||||
# 或者使用 FileZilla、WinSCP 等工具上传
|
# 或者使用 FileZilla、WinSCP 等工具上传
|
||||||
```
|
```
|
||||||
|
|
||||||
上传后,服务器上的路径应该是:`/www/wwwroot/zsgpt2/`
|
上传后,服务器上的路径应该是:`/www/wwwroot/zsglpt/`
|
||||||
|
|
||||||
### 步骤2: SSH登录服务器
|
### 步骤2: SSH登录服务器
|
||||||
|
|
||||||
@@ -110,16 +194,19 @@ ssh root@your-server-ip
|
|||||||
### 步骤3: 进入项目目录
|
### 步骤3: 进入项目目录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/zsgpt2
|
cd /www/wwwroot/zsglpt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 步骤4: 创建必要的目录
|
### 步骤4: 创建必要的目录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data logs 截图 playwright
|
mkdir -p data logs 截图
|
||||||
chmod 777 data logs 截图 playwright
|
chown -R 1000:1000 data logs 截图
|
||||||
|
chmod 750 data logs 截图
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 说明:避免使用 `chmod 777`。如容器内运行用户不是 `1000:1000`,请改为实际 UID/GID。
|
||||||
|
|
||||||
### 步骤5: 构建并启动Docker容器
|
### 步骤5: 构建并启动Docker容器
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -127,7 +214,7 @@ chmod 777 data logs 截图 playwright
|
|||||||
docker build -t knowledge-automation .
|
docker build -t knowledge-automation .
|
||||||
|
|
||||||
# 启动容器
|
# 启动容器
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 查看容器状态
|
# 查看容器状态
|
||||||
docker ps | grep knowledge-automation
|
docker ps | grep knowledge-automation
|
||||||
@@ -142,8 +229,8 @@ docker logs -f knowledge-automation-multiuser
|
|||||||
如果看到以下信息,说明启动成功:
|
如果看到以下信息,说明启动成功:
|
||||||
```
|
```
|
||||||
服务器启动中...
|
服务器启动中...
|
||||||
用户访问地址: http://0.0.0.0:5000
|
用户访问地址: http://0.0.0.0:51233
|
||||||
后台管理地址: http://0.0.0.0:5000/yuyx
|
后台管理地址: http://0.0.0.0:51233/yuyx
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -177,7 +264,7 @@ server {
|
|||||||
|
|
||||||
# 反向代理
|
# 反向代理
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:5001;
|
proxy_pass http://127.0.0.1:51232;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -219,15 +306,15 @@ certbot renew --dry-run
|
|||||||
|
|
||||||
### 用户端
|
### 用户端
|
||||||
|
|
||||||
- **HTTP**: `http://your-server-ip:5001`
|
- **HTTP**: `http://your-server-ip:51232`
|
||||||
- **域名**: `http://your-domain.com` (配置Nginx后)
|
- **域名**: `http://your-domain.com` (配置Nginx后)
|
||||||
- **HTTPS**: `https://your-domain.com` (配置SSL后)
|
- **HTTPS**: `https://your-domain.com` (配置SSL后)
|
||||||
|
|
||||||
### 后台管理
|
### 后台管理
|
||||||
|
|
||||||
- **路径**: `/yuyx`
|
- **后台地址**: `/yuyx`
|
||||||
- **默认账号**: `admin`
|
- **管理员账号**: 以数据库现有账号为准(首次运行默认创建 `admin`)
|
||||||
- **默认密码**: `admin`
|
- **管理员密码**: 首次运行随机生成,请查看容器启动日志
|
||||||
|
|
||||||
**首次登录后请立即修改密码!**
|
**首次登录后请立即修改密码!**
|
||||||
|
|
||||||
@@ -244,7 +331,7 @@ certbot renew --dry-run
|
|||||||
### 2. 定时任务
|
### 2. 定时任务
|
||||||
- **启用定时浏览**: 是/否
|
- **启用定时浏览**: 是/否
|
||||||
- **执行时间**: 02:00 (CST时间)
|
- **执行时间**: 02:00 (CST时间)
|
||||||
- **浏览类型**: 应读/注册前未读/未读
|
- **浏览类型**: 应读/注册前未读
|
||||||
- **执行日期**: 周一到周日
|
- **执行日期**: 周一到周日
|
||||||
|
|
||||||
### 3. 代理配置
|
### 3. 代理配置
|
||||||
@@ -285,7 +372,7 @@ docker logs -f knowledge-automation-multiuser
|
|||||||
docker logs --tail 100 knowledge-automation-multiuser
|
docker logs --tail 100 knowledge-automation-multiuser
|
||||||
|
|
||||||
# 查看应用日志文件
|
# 查看应用日志文件
|
||||||
tail -f /www/wwwroot/zsgpt2/logs/app.log
|
tail -f /www/wwwroot/zsglpt/logs/app.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### 进入容器
|
### 进入容器
|
||||||
@@ -303,14 +390,14 @@ docker exec knowledge-automation-multiuser python -c "print('Hello')"
|
|||||||
如果修改了代码,需要重新构建:
|
如果修改了代码,需要重新构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/zsgpt2
|
cd /www/wwwroot/zsglpt
|
||||||
|
|
||||||
# 停止并删除旧容器
|
# 停止并删除旧容器
|
||||||
docker-compose down
|
docker compose down
|
||||||
|
|
||||||
# 重新构建并启动
|
# 重新构建并启动
|
||||||
docker-compose build
|
docker compose build
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -323,13 +410,13 @@ docker-compose up -d
|
|||||||
cd /www/wwwroot
|
cd /www/wwwroot
|
||||||
|
|
||||||
# 备份整个项目
|
# 备份整个项目
|
||||||
tar -czf zsgpt2_backup_$(date +%Y%m%d).tar.gz zsgpt2/
|
tar -czf zsglpt_backup_$(date +%Y%m%d).tar.gz zsglpt/
|
||||||
|
|
||||||
# 仅备份数据库
|
# 仅备份数据库
|
||||||
cp /www/wwwroot/zsgpt2/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
|
cp /www/wwwroot/zsglpt/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
|
||||||
|
|
||||||
# 备份截图
|
# 备份截图
|
||||||
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsgpt2/截图/
|
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsglpt/截图/
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 恢复数据
|
### 2. 恢复数据
|
||||||
@@ -340,10 +427,10 @@ docker stop knowledge-automation-multiuser
|
|||||||
|
|
||||||
# 恢复整个项目
|
# 恢复整个项目
|
||||||
cd /www/wwwroot
|
cd /www/wwwroot
|
||||||
tar -xzf zsgpt2_backup_20251027.tar.gz
|
tar -xzf zsglpt_backup_20251027.tar.gz
|
||||||
|
|
||||||
# 恢复数据库
|
# 恢复数据库
|
||||||
cp /backup/app_data_20251027.db /www/wwwroot/zsgpt2/data/app_data.db
|
cp /backup/app_data_20251027.db /www/wwwroot/zsglpt/data/app_data.db
|
||||||
|
|
||||||
# 重启容器
|
# 重启容器
|
||||||
docker start knowledge-automation-multiuser
|
docker start knowledge-automation-multiuser
|
||||||
@@ -361,7 +448,7 @@ crontab -e
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 每天凌晨3点备份
|
# 每天凌晨3点备份
|
||||||
0 3 * * * tar -czf /backup/zsgpt2_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsgpt2/data
|
0 3 * * * tar -czf /backup/zsglpt_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsglpt/data
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -370,19 +457,19 @@ crontab -e
|
|||||||
|
|
||||||
### 1. 容器启动失败
|
### 1. 容器启动失败
|
||||||
|
|
||||||
**问题**: `docker-compose up -d` 失败
|
**问题**: `docker compose up -d` 失败
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**:
|
||||||
```bash
|
```bash
|
||||||
# 查看详细错误
|
# 查看详细错误
|
||||||
docker-compose logs
|
docker compose logs
|
||||||
|
|
||||||
# 检查端口占用
|
# 检查端口占用
|
||||||
netstat -tlnp | grep 5001
|
netstat -tlnp | grep 51232
|
||||||
|
|
||||||
# 重新构建
|
# 重新构建
|
||||||
docker-compose build --no-cache
|
docker compose build --no-cache
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 502 Bad Gateway
|
### 2. 502 Bad Gateway
|
||||||
@@ -395,10 +482,10 @@ docker-compose up -d
|
|||||||
docker ps | grep knowledge-automation
|
docker ps | grep knowledge-automation
|
||||||
|
|
||||||
# 检查端口是否监听
|
# 检查端口是否监听
|
||||||
netstat -tlnp | grep 5001
|
netstat -tlnp | grep 51232
|
||||||
|
|
||||||
# 测试直接访问
|
# 测试直接访问
|
||||||
curl http://127.0.0.1:5001
|
curl http://127.0.0.1:51232
|
||||||
|
|
||||||
# 检查Nginx配置
|
# 检查Nginx配置
|
||||||
nginx -t
|
nginx -t
|
||||||
@@ -414,7 +501,7 @@ nginx -t
|
|||||||
docker restart knowledge-automation-multiuser
|
docker restart knowledge-automation-multiuser
|
||||||
|
|
||||||
# 如果问题持续,优化数据库
|
# 如果问题持续,优化数据库
|
||||||
cd /www/wwwroot/zsgpt2
|
cd /www/wwwroot/zsglpt
|
||||||
cp data/app_data.db data/app_data.db.backup
|
cp data/app_data.db data/app_data.db.backup
|
||||||
sqlite3 data/app_data.db "VACUUM;"
|
sqlite3 data/app_data.db "VACUUM;"
|
||||||
```
|
```
|
||||||
@@ -437,23 +524,23 @@ services:
|
|||||||
然后重启:
|
然后重启:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
docker compose down
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 浏览器下载失败
|
### 5. 截图工具未安装
|
||||||
|
|
||||||
**问题**: Playwright浏览器下载失败
|
**问题**: wkhtmltoimage 命令不存在
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**:
|
||||||
```bash
|
```bash
|
||||||
# 进入容器手动安装
|
# 进入容器手动安装
|
||||||
docker exec -it knowledge-automation-multiuser bash
|
docker exec -it knowledge-automation-multiuser bash
|
||||||
playwright install chromium
|
apt-get update
|
||||||
|
apt-get install -y wkhtmltopdf
|
||||||
|
|
||||||
# 或使用国内镜像
|
# 验证安装
|
||||||
export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/
|
wkhtmltoimage --version
|
||||||
playwright install chromium
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -480,13 +567,13 @@ playwright install chromium
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 清理7天前的截图
|
# 清理7天前的截图
|
||||||
find /www/wwwroot/zsgpt2/截图 -name "*.jpg" -mtime +7 -delete
|
find /www/wwwroot/zsglpt/截图 -name "*.jpg" -mtime +7 -delete
|
||||||
|
|
||||||
# 清理旧日志
|
# 清理旧日志
|
||||||
find /www/wwwroot/zsgpt2/logs -name "*.log" -mtime +30 -delete
|
find /www/wwwroot/zsglpt/logs -name "*.log" -mtime +30 -delete
|
||||||
|
|
||||||
# 优化数据库
|
# 优化数据库
|
||||||
sqlite3 /www/wwwroot/zsgpt2/data/app_data.db "VACUUM;"
|
sqlite3 /www/wwwroot/zsglpt/data/app_data.db "VACUUM;"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -507,9 +594,9 @@ firewall-cmd --permanent --add-port=80/tcp
|
|||||||
firewall-cmd --permanent --add-port=443/tcp
|
firewall-cmd --permanent --add-port=443/tcp
|
||||||
firewall-cmd --reload
|
firewall-cmd --reload
|
||||||
|
|
||||||
# 禁止直接访问5001端口(仅Nginx可访问)
|
# 禁止直接访问51232端口(仅Nginx可访问)
|
||||||
iptables -A INPUT -p tcp --dport 5001 -s 127.0.0.1 -j ACCEPT
|
iptables -A INPUT -p tcp --dport 51232 -s 127.0.0.1 -j ACCEPT
|
||||||
iptables -A INPUT -p tcp --dport 5001 -j DROP
|
iptables -A INPUT -p tcp --dport 51232 -j DROP
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 启用HTTPS
|
### 3. 启用HTTPS
|
||||||
@@ -550,13 +637,13 @@ systemctl restart sshd
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 统计今日任务数
|
# 统计今日任务数
|
||||||
grep "浏览完成" /www/wwwroot/zsgpt2/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
|
grep "浏览完成" /www/wwwroot/zsglpt/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
|
||||||
|
|
||||||
# 查看错误日志
|
# 查看错误日志
|
||||||
grep "ERROR" /www/wwwroot/zsgpt2/logs/app.log | tail -20
|
grep "ERROR" /www/wwwroot/zsglpt/logs/app.log | tail -20
|
||||||
|
|
||||||
# 查看最近的登录
|
# 查看最近的登录
|
||||||
grep "登录成功" /www/wwwroot/zsgpt2/logs/app.log | tail -10
|
grep "登录成功" /www/wwwroot/zsglpt/logs/app.log | tail -10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 数据库维护
|
### 3. 数据库维护
|
||||||
@@ -580,7 +667,7 @@ EOF
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 停止容器
|
# 停止容器
|
||||||
docker-compose down
|
docker compose down
|
||||||
|
|
||||||
# 备份数据
|
# 备份数据
|
||||||
cp -r data data.backup
|
cp -r data data.backup
|
||||||
@@ -590,8 +677,8 @@ cp -r 截图 截图.backup
|
|||||||
# 使用 scp 或 FTP 工具上传
|
# 使用 scp 或 FTP 工具上传
|
||||||
|
|
||||||
# 重新构建并启动
|
# 重新构建并启动
|
||||||
docker-compose build
|
docker compose build
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 数据库迁移
|
### 2. 数据库迁移
|
||||||
@@ -610,8 +697,8 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
| 端口 | 说明 | 映射 |
|
| 端口 | 说明 | 映射 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 5000 | 容器内应用端口 | - |
|
| 51233 | 容器内应用端口 | - |
|
||||||
| 5001 | 主机映射端口 | 容器5000 → 主机5001 |
|
| 51232 | 主机映射端口 | 容器51233 → 主机51232 |
|
||||||
| 80 | HTTP端口 | Nginx |
|
| 80 | HTTP端口 | Nginx |
|
||||||
| 443 | HTTPS端口 | Nginx |
|
| 443 | HTTPS端口 | Nginx |
|
||||||
|
|
||||||
@@ -623,9 +710,23 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
| 变量名 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
|
| ENCRYPTION_KEY_RAW | 加密密钥(Fernet格式,优先级最高) | 从 .env 文件读取 |
|
||||||
|
| ENCRYPTION_KEY | 加密密钥(会通过PBKDF2派生) | - |
|
||||||
| TZ | 时区 | Asia/Shanghai |
|
| TZ | 时区 | Asia/Shanghai |
|
||||||
| PYTHONUNBUFFERED | Python输出缓冲 | 1 |
|
| PYTHONUNBUFFERED | Python输出缓冲 | 1 |
|
||||||
| PLAYWRIGHT_BROWSERS_PATH | 浏览器路径 | /ms-playwright |
|
| WKHTMLTOIMAGE_PATH | wkhtmltoimage 可执行文件路径 | 自动探测 |
|
||||||
|
| WKHTMLTOIMAGE_JS_DELAY_MS | JS 等待时间(毫秒) | 3000 |
|
||||||
|
| WKHTMLTOIMAGE_WIDTH | 截图宽度 | 1920 |
|
||||||
|
| WKHTMLTOIMAGE_HEIGHT | 截图高度(视口高度) | 1080 |
|
||||||
|
| WKHTMLTOIMAGE_FULL_PAGE | 是否输出全页截图(忽略视口高度/裁剪) | 0 |
|
||||||
|
| WKHTMLTOIMAGE_ZOOM | 渲染缩放比例 | 1.0 |
|
||||||
|
| WKHTMLTOIMAGE_CROP_WIDTH | 裁剪宽度(0 表示不裁剪) | 默认跟随截图宽度 |
|
||||||
|
| WKHTMLTOIMAGE_CROP_HEIGHT | 裁剪高度(0 表示不裁剪) | 默认跟随截图高度 |
|
||||||
|
| WKHTMLTOIMAGE_CROP_X | 裁剪起点 X | 0 |
|
||||||
|
| WKHTMLTOIMAGE_CROP_Y | 裁剪起点 Y | 0 |
|
||||||
|
| WKHTMLTOIMAGE_QUALITY | JPG截图质量 | 95 |
|
||||||
|
| WKHTMLTOIMAGE_TIMEOUT_SECONDS | 截图超时时间(秒) | 60 |
|
||||||
|
| WKHTMLTOIMAGE_USER_AGENT | 截图使用的 UA | Chrome 120 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -635,20 +736,20 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
- **项目名称**: 知识管理平台自动化工具
|
- **项目名称**: 知识管理平台自动化工具
|
||||||
- **版本**: Docker 多用户版
|
- **版本**: Docker 多用户版
|
||||||
- **技术栈**: Python + Flask + Playwright + SQLite + Docker
|
- **技术栈**: Python + Flask + Requests + wkhtmltopdf + SQLite + Docker
|
||||||
|
|
||||||
### 常用文档链接
|
### 常用文档链接
|
||||||
|
|
||||||
- [Docker 官方文档](https://docs.docker.com/)
|
- [Docker 官方文档](https://docs.docker.com/)
|
||||||
- [Flask 官方文档](https://flask.palletsprojects.com/)
|
- [Flask 官方文档](https://flask.palletsprojects.com/)
|
||||||
- [Playwright 官方文档](https://playwright.dev/python/)
|
- [wkhtmltopdf 官方文档](https://wkhtmltopdf.org/)
|
||||||
|
|
||||||
### 故障排查
|
### 故障排查
|
||||||
|
|
||||||
遇到问题时,请按以下顺序检查:
|
遇到问题时,请按以下顺序检查:
|
||||||
|
|
||||||
1. **容器日志**: `docker logs knowledge-automation-multiuser`
|
1. **容器日志**: `docker logs knowledge-automation-multiuser`
|
||||||
2. **应用日志**: `cat /www/wwwroot/zsgpt2/logs/app.log`
|
2. **应用日志**: `cat /www/wwwroot/zsglpt/logs/app.log`
|
||||||
3. **Nginx日志**: `cat /var/log/nginx/zsgpt_error.log`
|
3. **Nginx日志**: `cat /var/log/nginx/zsgpt_error.log`
|
||||||
4. **系统资源**: `docker stats`, `htop`, `df -h`
|
4. **系统资源**: `docker stats`, `htop`, `df -h`
|
||||||
|
|
||||||
@@ -660,9 +761,9 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本**: v1.0
|
**文档版本**: v2.1
|
||||||
**更新日期**: 2025-10-29
|
**更新日期**: 2026-02-07
|
||||||
**适用版本**: Docker多用户版
|
**适用版本**: Docker多用户版 + Vue SPA
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -670,26 +771,73 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 上传文件
|
# 1. 上传文件
|
||||||
scp -r zsgpt2 root@your-ip:/www/wwwroot/
|
scp -r zsglpt root@your-ip:/www/wwwroot/
|
||||||
|
|
||||||
# 2. SSH登录
|
# 2. SSH登录
|
||||||
ssh root@your-ip
|
ssh root@your-ip
|
||||||
|
|
||||||
# 3. 进入目录并创建必要目录
|
# 3. 进入目录并创建必要目录
|
||||||
cd /www/wwwroot/zsgpt2
|
cd /www/wwwroot/zsglpt
|
||||||
mkdir -p data logs 截图 playwright
|
mkdir -p data logs 截图
|
||||||
chmod 777 data logs 截图 playwright
|
chmod 777 data logs 截图
|
||||||
|
|
||||||
# 4. 启动容器
|
# 4. 启动容器
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 5. 查看日志
|
# 5. 查看日志
|
||||||
docker logs -f knowledge-automation-multiuser
|
docker logs -f knowledge-automation-multiuser
|
||||||
|
|
||||||
# 6. 访问系统
|
# 6. 访问系统
|
||||||
# 浏览器打开: http://your-ip:5001
|
# 浏览器打开: http://your-ip:51232
|
||||||
# 后台管理: http://your-ip:5001/yuyx
|
# 后台管理: http://your-ip:51232/yuyx
|
||||||
# 默认账号: admin / admin
|
# 首次管理员密码会写入 data/default_admin_credentials.txt(权限600)
|
||||||
|
# 登录后请立即修改密码并删除该文件
|
||||||
```
|
```
|
||||||
|
|
||||||
完成!🎉
|
完成!🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v2.0 (2026-01-08)
|
||||||
|
|
||||||
|
#### 新功能
|
||||||
|
- **金山文档集成**: 自动上传截图到金山文档表格
|
||||||
|
- 支持姓名搜索匹配单元格
|
||||||
|
- 支持配置有效行范围
|
||||||
|
- 支持覆盖已有图片
|
||||||
|
- 离线状态监控与邮件通知
|
||||||
|
- **Vue 3 SPA 前端**: 用户端和管理端全面升级为现代化单页应用
|
||||||
|
- Element Plus UI 组件库
|
||||||
|
- 实时任务状态更新
|
||||||
|
- 响应式设计
|
||||||
|
- **用户自定义定时任务**: 用户可创建自己的定时任务
|
||||||
|
- 支持多时间段配置
|
||||||
|
- 支持随机延迟
|
||||||
|
- 支持选择指定账号
|
||||||
|
- **安全防护系统**:
|
||||||
|
- 威胁检测引擎(JNDI/SQL注入/XSS/命令注入)
|
||||||
|
- IP/用户风险评分
|
||||||
|
- 自动黑名单机制
|
||||||
|
- **邮件通知系统**:
|
||||||
|
- 任务完成通知
|
||||||
|
- 密码重置邮件
|
||||||
|
- 邮箱验证
|
||||||
|
- **公告系统**: 支持图片的系统公告
|
||||||
|
- **Bug反馈系统**: 用户可提交问题反馈
|
||||||
|
|
||||||
|
#### 优化
|
||||||
|
- **截图线程池**: wkhtmltoimage 截图支持多线程并发
|
||||||
|
- 线程池管理,按需启动
|
||||||
|
- 空闲自动释放资源
|
||||||
|
- **二次登录机制**: 刷新"上次登录时间"显示
|
||||||
|
- **API 预热**: 启动时预热连接,减少首次请求延迟
|
||||||
|
- **数据库连接池**: 提高并发性能
|
||||||
|
|
||||||
|
### v1.0 (2025-10-29)
|
||||||
|
- 初始版本
|
||||||
|
- 多用户系统
|
||||||
|
- 基础自动化任务
|
||||||
|
- 定时任务调度
|
||||||
|
- 代理IP支持
|
||||||
|
|||||||
24
admin-frontend/.gitignore
vendored
Normal file
24
admin-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
13
admin-frontend/index.html
Normal file
13
admin-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>后台管理 - 知识管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1841
admin-frontend/package-lock.json
generated
Normal file
1841
admin-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
admin-frontend/package.json
Normal file
22
admin-frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"element-plus": "^2.11.3",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
admin-frontend/public/vite.svg
Normal file
1
admin-frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
3
admin-frontend/src/App.vue
Normal file
3
admin-frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
46
admin-frontend/src/api/admin.js
Normal file
46
admin-frontend/src/api/admin.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function updateAdminUsername(newUsername) {
|
||||||
|
const { data } = await api.put('/admin/username', { new_username: newUsername })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminPassword(payload = {}) {
|
||||||
|
const currentPassword = String(payload.currentPassword || '')
|
||||||
|
const newPassword = String(payload.newPassword || '')
|
||||||
|
const { data } = await api.put('/admin/password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const { data } = await api.post('/logout')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminPasskeys() {
|
||||||
|
const { data } = await api.get('/admin/passkeys')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminPasskeyOptions(payload = {}) {
|
||||||
|
const { data } = await api.post('/admin/passkeys/register/options', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminPasskeyVerify(payload = {}) {
|
||||||
|
const { data } = await api.post('/admin/passkeys/register/verify', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminPasskey(passkeyId) {
|
||||||
|
const { data } = await api.delete(`/admin/passkeys/${passkeyId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportAdminPasskeyClientError(payload = {}) {
|
||||||
|
const { data } = await api.post('/admin/passkeys/client-error', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
33
admin-frontend/src/api/announcements.js
Normal file
33
admin-frontend/src/api/announcements.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchAnnouncements() {
|
||||||
|
const { data } = await api.get('/announcements')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAnnouncement(payload) {
|
||||||
|
const { data } = await api.post('/announcements', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAnnouncementImage(file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { data } = await api.post('/announcements/upload_image', formData)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateAnnouncement(id) {
|
||||||
|
const { data } = await api.post(`/announcements/${id}/activate`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateAnnouncement(id) {
|
||||||
|
const { data } = await api.post(`/announcements/${id}/deactivate`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAnnouncement(id) {
|
||||||
|
const { data } = await api.delete(`/announcements/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
11
admin-frontend/src/api/browser_pool.js
Normal file
11
admin-frontend/src/api/browser_pool.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const browserPoolStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/browser_pool/stats')
|
||||||
|
return data
|
||||||
|
}, 4_000)
|
||||||
|
|
||||||
|
export async function fetchBrowserPoolStats(options = {}) {
|
||||||
|
return browserPoolStatsGetter.run(options)
|
||||||
|
}
|
||||||
46
admin-frontend/src/api/cache.js
Normal file
46
admin-frontend/src/api/cache.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export function createCachedGetter(fetcher, ttlMs = 0) {
|
||||||
|
let hasValue = false
|
||||||
|
let cachedValue = null
|
||||||
|
let expiresAt = 0
|
||||||
|
let inflight = null
|
||||||
|
|
||||||
|
async function run(options = {}) {
|
||||||
|
const force = Boolean(options?.force)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (!force && hasValue && now < expiresAt) {
|
||||||
|
return cachedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && inflight) {
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
inflight = Promise.resolve()
|
||||||
|
.then(() => fetcher())
|
||||||
|
.then((data) => {
|
||||||
|
cachedValue = data
|
||||||
|
hasValue = true
|
||||||
|
const ttl = Math.max(0, Number(ttlMs) || 0)
|
||||||
|
expiresAt = Date.now() + ttl
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
hasValue = false
|
||||||
|
cachedValue = null
|
||||||
|
expiresAt = 0
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
}
|
||||||
149
admin-frontend/src/api/client.js
Normal file
149
admin-frontend/src/api/client.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
let lastToastKey = ''
|
||||||
|
let lastToastAt = 0
|
||||||
|
|
||||||
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||||
|
const MAX_RETRY_COUNT = 1
|
||||||
|
const RETRY_BASE_DELAY_MS = 300
|
||||||
|
|
||||||
|
function toastErrorOnce(key, message, minIntervalMs = 1500) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
|
||||||
|
lastToastKey = key
|
||||||
|
lastToastAt = now
|
||||||
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
|
||||||
|
return match ? decodeURIComponent(match[1]) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdempotentMethod(method) {
|
||||||
|
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryRequest(error) {
|
||||||
|
const config = error?.config
|
||||||
|
if (!config || config.__no_retry) return false
|
||||||
|
if (!isIdempotentMethod(config.method)) return false
|
||||||
|
|
||||||
|
const retried = Number(config.__retry_count || 0)
|
||||||
|
if (retried >= MAX_RETRY_COUNT) return false
|
||||||
|
|
||||||
|
const code = String(error?.code || '')
|
||||||
|
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
|
||||||
|
|
||||||
|
const status = Number(error?.response?.status || 0)
|
||||||
|
return RETRYABLE_STATUS.has(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryRequestOnce(error, client) {
|
||||||
|
const config = error?.config || {}
|
||||||
|
const retried = Number(config.__retry_count || 0)
|
||||||
|
config.__retry_count = retried + 1
|
||||||
|
|
||||||
|
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
|
||||||
|
await delay(backoffMs)
|
||||||
|
return client.request(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: '/yuyx/api',
|
||||||
|
timeout: 30_000,
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let reauthPromise = null
|
||||||
|
|
||||||
|
async function ensureReauth() {
|
||||||
|
if (reauthPromise) return reauthPromise
|
||||||
|
reauthPromise = ElMessageBox.prompt('请输入管理员密码进行二次确认', '安全确认', {
|
||||||
|
inputType: 'password',
|
||||||
|
inputPlaceholder: '管理员密码',
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputValidator: (v) => Boolean(String(v || '').trim()),
|
||||||
|
inputErrorMessage: '密码不能为空',
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const password = String(res.value || '').trim()
|
||||||
|
await api.post('/admin/reauth', { password })
|
||||||
|
ElMessage.success('已通过安全确认')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
reauthPromise = null
|
||||||
|
})
|
||||||
|
return reauthPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const method = String(config?.method || 'GET').toUpperCase()
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
const token = getCookie('csrf_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['X-CSRF-Token'] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const status = error?.response?.status
|
||||||
|
const payload = error?.response?.data
|
||||||
|
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||||
|
const silent = Boolean(error?.config?.__silent)
|
||||||
|
|
||||||
|
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
|
||||||
|
try {
|
||||||
|
error.config.__reauth_retry = true
|
||||||
|
await ensureReauth()
|
||||||
|
return api.request(error.config)
|
||||||
|
} catch {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRetryRequest(error)) {
|
||||||
|
return retryRequestOnce(error, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
if (!silent) {
|
||||||
|
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||||
|
}
|
||||||
|
const pathname = window.location?.pathname || ''
|
||||||
|
if (!pathname.startsWith('/yuyx')) window.location.href = '/yuyx'
|
||||||
|
} else if (status === 403) {
|
||||||
|
if (!silent) {
|
||||||
|
toastErrorOnce('403', message || '需要管理员权限', 5000)
|
||||||
|
}
|
||||||
|
} else if (status) {
|
||||||
|
if (!silent) {
|
||||||
|
toastErrorOnce(`http:${status}:${message}`, message)
|
||||||
|
}
|
||||||
|
} else if (error?.code === 'ECONNABORTED') {
|
||||||
|
if (!silent) {
|
||||||
|
toastErrorOnce('timeout', '请求超时', 3000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!silent) {
|
||||||
|
toastErrorOnce(`net:${message}`, message, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
33
admin-frontend/src/api/email.js
Normal file
33
admin-frontend/src/api/email.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const emailStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/email/stats')
|
||||||
|
return data
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
export async function fetchEmailSettings() {
|
||||||
|
const { data } = await api.get('/email/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmailSettings(payload) {
|
||||||
|
const { data } = await api.post('/email/settings', payload)
|
||||||
|
emailStatsGetter.clear()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailStats(options = {}) {
|
||||||
|
return emailStatsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailLogs(params) {
|
||||||
|
const { data } = await api.get('/email/logs', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupEmailLogs(days) {
|
||||||
|
const { data } = await api.post('/email/logs/cleanup', { days })
|
||||||
|
emailStatsGetter.clear()
|
||||||
|
return data
|
||||||
|
}
|
||||||
40
admin-frontend/src/api/feedbacks.js
Normal file
40
admin-frontend/src/api/feedbacks.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const FEEDBACK_STATS_TTL_MS = 10_000
|
||||||
|
|
||||||
|
const feedbackStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/feedbacks', { params: { limit: 1, offset: 0 } })
|
||||||
|
return data?.stats
|
||||||
|
}, FEEDBACK_STATS_TTL_MS)
|
||||||
|
|
||||||
|
export async function fetchFeedbacks(status = '') {
|
||||||
|
const { data } = await api.get('/feedbacks', { params: status ? { status } : {} })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeedbackStats(options = {}) {
|
||||||
|
return feedbackStatsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFeedbackStatsCache() {
|
||||||
|
feedbackStatsGetter.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replyFeedback(feedbackId, reply) {
|
||||||
|
const { data } = await api.post(`/feedbacks/${feedbackId}/reply`, { reply })
|
||||||
|
clearFeedbackStatsCache()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeFeedback(feedbackId) {
|
||||||
|
const { data } = await api.post(`/feedbacks/${feedbackId}/close`)
|
||||||
|
clearFeedbackStatsCache()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFeedback(feedbackId) {
|
||||||
|
const { data } = await api.delete(`/feedbacks/${feedbackId}`)
|
||||||
|
clearFeedbackStatsCache()
|
||||||
|
return data
|
||||||
|
}
|
||||||
17
admin-frontend/src/api/kdocs.js
Normal file
17
admin-frontend/src/api/kdocs.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchKdocsStatus(params = {}, requestConfig = {}) {
|
||||||
|
const { data } = await api.get('/kdocs/status', { params, ...requestConfig })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKdocsQr(payload = {}) {
|
||||||
|
const body = { force: true, ...payload }
|
||||||
|
const { data } = await api.post('/kdocs/qr', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearKdocsLogin() {
|
||||||
|
const { data } = await api.post('/kdocs/clear-login', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
17
admin-frontend/src/api/proxy.js
Normal file
17
admin-frontend/src/api/proxy.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchProxyConfig() {
|
||||||
|
const { data } = await api.get('/proxy/config')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProxyConfig(payload) {
|
||||||
|
const { data } = await api.post('/proxy/config', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testProxy(payload) {
|
||||||
|
const { data } = await api.post('/proxy/test', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
63
admin-frontend/src/api/security.js
Normal file
63
admin-frontend/src/api/security.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function getDashboard() {
|
||||||
|
const { data } = await api.get('/admin/security/dashboard')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThreats(params) {
|
||||||
|
const { data } = await api.get('/admin/security/threats', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBannedIps() {
|
||||||
|
const { data } = await api.get('/admin/security/banned-ips')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBannedUsers() {
|
||||||
|
const { data } = await api.get('/admin/security/banned-users')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banIp(payload) {
|
||||||
|
const { data } = await api.post('/admin/security/ban-ip', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanIp(ip) {
|
||||||
|
const { data } = await api.post('/admin/security/unban-ip', { ip })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banUser(payload) {
|
||||||
|
const { data } = await api.post('/admin/security/ban-user', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanUser(userId) {
|
||||||
|
const { data } = await api.post('/admin/security/unban-user', { user_id: userId })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIpRisk(ip) {
|
||||||
|
const safeIp = encodeURIComponent(String(ip || '').trim())
|
||||||
|
const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearIpRisk(ip) {
|
||||||
|
const { data } = await api.post('/admin/security/ip-risk/clear', { ip })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserRisk(userId) {
|
||||||
|
const safeUserId = encodeURIComponent(String(userId || '').trim())
|
||||||
|
const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanup() {
|
||||||
|
const { data } = await api.post('/admin/security/cleanup', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
36
admin-frontend/src/api/smtp.js
Normal file
36
admin-frontend/src/api/smtp.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchSmtpConfigs() {
|
||||||
|
const { data } = await api.get('/smtp/configs')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSmtpConfig(payload) {
|
||||||
|
const { data } = await api.post('/smtp/configs', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSmtpConfig(configId, payload) {
|
||||||
|
const { data } = await api.put(`/smtp/configs/${configId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSmtpConfig(configId) {
|
||||||
|
const { data } = await api.delete(`/smtp/configs/${configId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSmtpConfig(configId, email) {
|
||||||
|
const { data } = await api.post(`/smtp/configs/${configId}/test`, { email })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPrimarySmtpConfig(configId) {
|
||||||
|
const { data } = await api.post(`/smtp/configs/${configId}/primary`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPrimarySmtpConfig() {
|
||||||
|
const { data } = await api.post('/smtp/configs/primary/clear')
|
||||||
|
return data
|
||||||
|
}
|
||||||
17
admin-frontend/src/api/stats.js
Normal file
17
admin-frontend/src/api/stats.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const SYSTEM_STATS_TTL_MS = 15_000
|
||||||
|
|
||||||
|
const systemStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/stats')
|
||||||
|
return data
|
||||||
|
}, SYSTEM_STATS_TTL_MS)
|
||||||
|
|
||||||
|
export async function fetchSystemStats(options = {}) {
|
||||||
|
return systemStatsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSystemStatsCache() {
|
||||||
|
systemStatsGetter.clear()
|
||||||
|
}
|
||||||
22
admin-frontend/src/api/system.js
Normal file
22
admin-frontend/src/api/system.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const systemConfigGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/system/config')
|
||||||
|
return data
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
export async function fetchSystemConfig(options = {}) {
|
||||||
|
return systemConfigGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystemConfig(payload) {
|
||||||
|
const { data } = await api.post('/system/config', payload)
|
||||||
|
systemConfigGetter.clear()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeScheduleNow() {
|
||||||
|
const { data } = await api.post('/schedule/execute', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
68
admin-frontend/src/api/tasks.js
Normal file
68
admin-frontend/src/api/tasks.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import { createCachedGetter } from './cache'
|
||||||
|
|
||||||
|
const serverInfoGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/server/info')
|
||||||
|
return data
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
const dockerStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/docker_stats')
|
||||||
|
return data
|
||||||
|
}, 8_000)
|
||||||
|
|
||||||
|
const requestMetricsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/request_metrics')
|
||||||
|
return data
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const slowSqlMetricsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/slow_sql_metrics')
|
||||||
|
return data
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const taskStatsGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/task/stats')
|
||||||
|
return data
|
||||||
|
}, 4_000)
|
||||||
|
|
||||||
|
const runningTasksGetter = createCachedGetter(async () => {
|
||||||
|
const { data } = await api.get('/task/running')
|
||||||
|
return data
|
||||||
|
}, 2_000)
|
||||||
|
|
||||||
|
export async function fetchServerInfo(options = {}) {
|
||||||
|
return serverInfoGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDockerStats(options = {}) {
|
||||||
|
return dockerStatsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRequestMetrics(options = {}) {
|
||||||
|
return requestMetricsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSlowSqlMetrics(options = {}) {
|
||||||
|
return slowSqlMetricsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTaskStats(options = {}) {
|
||||||
|
return taskStatsGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRunningTasks(options = {}) {
|
||||||
|
return runningTasksGetter.run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTaskLogs(params) {
|
||||||
|
const { data } = await api.get('/task/logs', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearOldTaskLogs(days) {
|
||||||
|
const { data } = await api.post('/task/logs/clear', { days })
|
||||||
|
taskStatsGetter.clear()
|
||||||
|
runningTasksGetter.clear()
|
||||||
|
return data
|
||||||
|
}
|
||||||
42
admin-frontend/src/api/users.js
Normal file
42
admin-frontend/src/api/users.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchAllUsers() {
|
||||||
|
const { data } = await api.get('/users')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPendingUsers() {
|
||||||
|
const { data } = await api.get('/users/pending')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveUser(userId) {
|
||||||
|
const { data } = await api.post(`/users/${userId}/approve`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectUser(userId) {
|
||||||
|
const { data } = await api.post(`/users/${userId}/reject`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId) {
|
||||||
|
const { data } = await api.delete(`/users/${userId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserVip(userId, days) {
|
||||||
|
const { data } = await api.post(`/users/${userId}/vip`, { days })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeUserVip(userId) {
|
||||||
|
const { data } = await api.delete(`/users/${userId}/vip`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminResetUserPassword(userId, newPassword) {
|
||||||
|
const { data } = await api.post(`/users/${userId}/reset_password`, { new_password: newPassword })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
164
admin-frontend/src/components/MetricGrid.vue
Normal file
164
admin-frontend/src/components/MetricGrid.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
minWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 180,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="metric-grid" :style="{ '--metric-min': `${minWidth}px` }">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item?.key || item?.label"
|
||||||
|
class="metric-card"
|
||||||
|
:class="`metric-tone--${item?.tone || 'blue'}`"
|
||||||
|
>
|
||||||
|
<div class="metric-top">
|
||||||
|
<div v-if="item?.icon" class="metric-icon">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="metric-label">{{ item?.label || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-value">
|
||||||
|
<el-skeleton v-if="loading" :rows="1" animated />
|
||||||
|
<template v-else>{{ item?.value ?? 0 }}</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item?.hint || item?.sub" class="metric-hint app-muted">{{ item?.hint || item?.sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--metric-min), 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 252, 255, 0.9));
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
padding: 13px 14px;
|
||||||
|
min-height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--metric-top, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--metric-icon-bg, rgba(59, 130, 246, 0.12));
|
||||||
|
color: var(--metric-icon-color, #1d4ed8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.05;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--blue {
|
||||||
|
--metric-top: linear-gradient(90deg, #3b82f6, #06b6d4);
|
||||||
|
--metric-icon-bg: rgba(59, 130, 246, 0.14);
|
||||||
|
--metric-icon-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--green {
|
||||||
|
--metric-top: linear-gradient(90deg, #10b981, #22c55e);
|
||||||
|
--metric-icon-bg: rgba(16, 185, 129, 0.14);
|
||||||
|
--metric-icon-color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--purple {
|
||||||
|
--metric-top: linear-gradient(90deg, #8b5cf6, #ec4899);
|
||||||
|
--metric-icon-bg: rgba(139, 92, 246, 0.14);
|
||||||
|
--metric-icon-color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--orange {
|
||||||
|
--metric-top: linear-gradient(90deg, #f59e0b, #f97316);
|
||||||
|
--metric-icon-bg: rgba(245, 158, 11, 0.14);
|
||||||
|
--metric-icon-color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--red {
|
||||||
|
--metric-top: linear-gradient(90deg, #ef4444, #f43f5e);
|
||||||
|
--metric-icon-bg: rgba(239, 68, 68, 0.14);
|
||||||
|
--metric-icon-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tone--cyan {
|
||||||
|
--metric-top: linear-gradient(90deg, #06b6d4, #3b82f6);
|
||||||
|
--metric-icon-bg: rgba(6, 182, 212, 0.14);
|
||||||
|
--metric-icon-color: #0e7490;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
418
admin-frontend/src/layouts/AdminLayout.vue
Normal file
418
admin-frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChatLineSquare,
|
||||||
|
Document,
|
||||||
|
List,
|
||||||
|
Lock,
|
||||||
|
Message,
|
||||||
|
Setting,
|
||||||
|
Tools,
|
||||||
|
User,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { fetchFeedbackStats } from '../api/feedbacks'
|
||||||
|
import { fetchSystemStats } from '../api/stats'
|
||||||
|
import { clearCachedKdocsStatus, preloadKdocsStatus } from '../utils/kdocsStatusCache'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const stats = ref({})
|
||||||
|
|
||||||
|
const adminUsername = computed(() => stats.value?.admin_username || '')
|
||||||
|
|
||||||
|
async function refreshStats(options = {}) {
|
||||||
|
stats.value = await fetchSystemStats(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBadges = ref(false)
|
||||||
|
const pendingFeedbackCount = ref(0)
|
||||||
|
|
||||||
|
const BADGE_POLL_ACTIVE_MS = 60_000
|
||||||
|
const BADGE_POLL_HIDDEN_MS = 180_000
|
||||||
|
|
||||||
|
let badgeTimer = null
|
||||||
|
|
||||||
|
async function refreshNavBadges(partial = null) {
|
||||||
|
if (partial && typeof partial === 'object') {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) {
|
||||||
|
pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingBadges.value) return
|
||||||
|
loadingBadges.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feedbackResult = await fetchFeedbackStats()
|
||||||
|
pendingFeedbackCount.value = Number(feedbackResult?.pending || 0)
|
||||||
|
} finally {
|
||||||
|
loadingBadges.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageHidden() {
|
||||||
|
if (typeof document === 'undefined') return false
|
||||||
|
return document.visibilityState === 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBadgePollDelay() {
|
||||||
|
return isPageHidden() ? BADGE_POLL_HIDDEN_MS : BADGE_POLL_ACTIVE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBadgePolling() {
|
||||||
|
if (!badgeTimer) return
|
||||||
|
window.clearTimeout(badgeTimer)
|
||||||
|
badgeTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBadgePolling() {
|
||||||
|
stopBadgePolling()
|
||||||
|
badgeTimer = window.setTimeout(async () => {
|
||||||
|
badgeTimer = null
|
||||||
|
await refreshNavBadges().catch(() => {})
|
||||||
|
scheduleBadgePolling()
|
||||||
|
}, currentBadgePollDelay())
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
scheduleBadgePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
provide('refreshStats', refreshStats)
|
||||||
|
provide('adminStats', stats)
|
||||||
|
provide('refreshNavBadges', refreshNavBadges)
|
||||||
|
|
||||||
|
const isMobile = ref(false)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
let mediaQuery
|
||||||
|
|
||||||
|
function syncIsMobile() {
|
||||||
|
isMobile.value = Boolean(mediaQuery?.matches)
|
||||||
|
if (!isMobile.value) drawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
mediaQuery = window.matchMedia('(max-width: 768px)')
|
||||||
|
mediaQuery.addEventListener?.('change', syncIsMobile)
|
||||||
|
syncIsMobile()
|
||||||
|
|
||||||
|
// 后台登录后预加载金山文档登录状态,系统配置页可直接复用缓存。
|
||||||
|
void preloadKdocsStatus({ maxAgeMs: 60_000, silent: true }).catch(() => {})
|
||||||
|
|
||||||
|
await refreshStats()
|
||||||
|
await refreshNavBadges()
|
||||||
|
scheduleBadgePolling()
|
||||||
|
window.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
mediaQuery?.removeEventListener?.('change', syncIsMobile)
|
||||||
|
stopBadgePolling()
|
||||||
|
window.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/reports', label: '报表', icon: Document },
|
||||||
|
{ path: '/users', label: '用户', icon: User },
|
||||||
|
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
|
||||||
|
{ path: '/logs', label: '任务日志', icon: List },
|
||||||
|
{ path: '/announcements', label: '公告', icon: Bell },
|
||||||
|
{ path: '/email', label: '邮件', icon: Message },
|
||||||
|
{ path: '/security', label: '安全防护', icon: Lock },
|
||||||
|
{ path: '/system', label: '系统配置', icon: Tools },
|
||||||
|
{ path: '/settings', label: '设置', icon: Setting },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
|
||||||
|
function badgeFor(item) {
|
||||||
|
if (!item?.badgeKey) return 0
|
||||||
|
if (item.badgeKey === 'feedbacks') {
|
||||||
|
return Number(pendingFeedbackCount.value || 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
let confirmed = false
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定退出管理员登录吗?', '退出登录', {
|
||||||
|
confirmButtonText: '退出',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
confirmed = true
|
||||||
|
} catch (error) {
|
||||||
|
const reason = String(error || '').toLowerCase()
|
||||||
|
if (reason === 'cancel' || reason === 'close') return
|
||||||
|
try {
|
||||||
|
confirmed = window.confirm('确定退出管理员登录吗?')
|
||||||
|
} catch {
|
||||||
|
confirmed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/logout')
|
||||||
|
} finally {
|
||||||
|
clearCachedKdocsStatus()
|
||||||
|
window.location.href = '/yuyx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function go(path) {
|
||||||
|
await router.push(path)
|
||||||
|
drawerOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="layout-root">
|
||||||
|
<el-aside v-if="!isMobile" width="220px" class="layout-aside">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-title">后台管理</div>
|
||||||
|
<div class="brand-sub app-muted">知识管理平台</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
|
||||||
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<el-badge v-if="badgeFor(item) > 0" :value="badgeFor(item)" :max="99" class="menu-badge">
|
||||||
|
<span class="menu-label">{{ item.label }}</span>
|
||||||
|
</el-badge>
|
||||||
|
<span v-else class="menu-label">{{ item.label }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button v-if="isMobile" text class="header-menu-btn" @click="drawerOpen = true">
|
||||||
|
菜单
|
||||||
|
</el-button>
|
||||||
|
<div class="header-title">后台管理系统</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="admin-name">
|
||||||
|
<span class="app-muted">管理员</span>
|
||||||
|
<strong>{{ adminUsername || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" plain class="logout-btn" @click="logout">退出</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<el-main class="layout-main">
|
||||||
|
<div class="main-shell">
|
||||||
|
<Suspense>
|
||||||
|
<template #default>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
<template #fallback>
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
|
||||||
|
<el-skeleton :rows="5" animated />
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawerOpen" size="min(82vw, 280px)" direction="ltr" :with-header="false">
|
||||||
|
<div class="drawer-brand">
|
||||||
|
<div class="brand-title">后台管理</div>
|
||||||
|
<div class="brand-sub app-muted">知识管理平台</div>
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
|
||||||
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<el-badge v-if="badgeFor(item) > 0" :value="badgeFor(item)" :max="99" class="menu-badge">
|
||||||
|
<span class="menu-label">{{ item.label }}</span>
|
||||||
|
</el-badge>
|
||||||
|
<span v-else class="menu-label">{{ item.label }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-drawer>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||||||
|
border-right: 1px solid var(--app-border);
|
||||||
|
box-shadow: 4px 0 16px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.drawer-brand {
|
||||||
|
padding: 18px 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu {
|
||||||
|
border-right: none;
|
||||||
|
padding: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu :deep(.el-menu-item) {
|
||||||
|
height: 42px;
|
||||||
|
line-height: 42px;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu :deep(.el-menu-item .el-icon) {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu :deep(.el-menu-item:hover) {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu :deep(.el-menu-item.is-active) {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(124, 58, 237, 0.1));
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-card {
|
||||||
|
min-height: 160px;
|
||||||
|
border-radius: var(--app-radius-lg);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
height: 58px;
|
||||||
|
padding: 0 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
backdrop-filter: saturate(180%) blur(10px);
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-btn {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
min-width: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name .app-muted {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
admin-frontend/src/main.js
Normal file
12
admin-frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||||
386
admin-frontend/src/pages/AnnouncementsPage.vue
Normal file
386
admin-frontend/src/pages/AnnouncementsPage.vue
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<script setup>
|
||||||
|
import { h, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
activateAnnouncement,
|
||||||
|
createAnnouncement,
|
||||||
|
deactivateAnnouncement,
|
||||||
|
deleteAnnouncement,
|
||||||
|
fetchAnnouncements,
|
||||||
|
uploadAnnouncementImage,
|
||||||
|
} from '../api/announcements'
|
||||||
|
|
||||||
|
const formTitle = ref('')
|
||||||
|
const formContent = ref('')
|
||||||
|
const formImageUrl = ref('')
|
||||||
|
const imageInputRef = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
list.value = await fetchAnnouncements()
|
||||||
|
} catch {
|
||||||
|
list.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
formTitle.value = ''
|
||||||
|
formContent.value = ''
|
||||||
|
formImageUrl.value = ''
|
||||||
|
if (imageInputRef.value) imageInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImagePicker() {
|
||||||
|
imageInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
formImageUrl.value = ''
|
||||||
|
if (imageInputRef.value) imageInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImageFileChange(event) {
|
||||||
|
const file = event.target?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.type && !file.type.startsWith('image/')) {
|
||||||
|
ElMessage.error('请选择图片文件')
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uploadAnnouncementImage(file)
|
||||||
|
if (!res?.success || !res?.url) {
|
||||||
|
ElMessage.error(res?.error || '上传失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formImageUrl.value = res.url
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(isActive) {
|
||||||
|
const title = formTitle.value.trim()
|
||||||
|
const content = formContent.value.trim()
|
||||||
|
const image_url = formImageUrl.value.trim()
|
||||||
|
if (!title || !content) {
|
||||||
|
ElMessage.error('标题和内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await createAnnouncement({ title, content, image_url, is_active: Boolean(isActive) })
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '保存失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
clearForm()
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function view(row) {
|
||||||
|
const body = h('div', { class: 'announcement-view' }, [
|
||||||
|
row.content ? h('div', { class: 'announcement-view-text' }, row.content) : null,
|
||||||
|
row.image_url
|
||||||
|
? h('img', {
|
||||||
|
class: 'announcement-view-image',
|
||||||
|
src: row.image_url,
|
||||||
|
alt: '公告图片',
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
await ElMessageBox.alert(body, row.title || '公告', {
|
||||||
|
confirmButtonText: '关闭',
|
||||||
|
dangerouslyUseHTMLString: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onActivate(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定启用该公告吗?启用后将自动停用其他公告。', '启用公告', {
|
||||||
|
confirmButtonText: '启用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await activateAnnouncement(row.id)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '启用失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已启用')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeactivate(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定停用该公告吗?', '停用公告', {
|
||||||
|
confirmButtonText: '停用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deactivateAnnouncement(row.id)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '停用失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除该公告吗?删除后无法恢复。', '删除公告', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteAnnouncement(row.id)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '删除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>公告管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<h3 class="section-title">创建公告</h3>
|
||||||
|
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="公告标题">
|
||||||
|
<el-input v-model="formTitle" placeholder="请输入公告标题" maxlength="100" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="公告内容">
|
||||||
|
<el-input
|
||||||
|
v-model="formContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="请输入公告内容(将以弹窗形式展示)"
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="公告图片">
|
||||||
|
<div class="image-upload-row">
|
||||||
|
<el-button :icon="Plus" :loading="uploading" @click="openImagePicker">上传图片</el-button>
|
||||||
|
<el-button v-if="formImageUrl" @click="clearImage">移除</el-button>
|
||||||
|
<span v-if="formImageUrl" class="image-url">{{ formImageUrl }}</span>
|
||||||
|
<input
|
||||||
|
ref="imageInputRef"
|
||||||
|
class="image-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
@change="onImageFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div v-if="formImageUrl" class="image-preview">
|
||||||
|
<img :src="formImageUrl" alt="公告图片预览" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<el-button type="primary" @click="submit(true)">发布并启用</el-button>
|
||||||
|
<el-button @click="submit(false)">保存但不启用</el-button>
|
||||||
|
<el-button @click="clearForm">清空</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help">
|
||||||
|
说明:启用公告后,用户登录进入系统将弹窗提示;用户可选择“当次关闭”或“永久关闭本次公告”。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<h3 class="section-title">公告列表</h3>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="list" v-loading="loading" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column label="标题" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ellipsis" :title="row.title">{{ row.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light">
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="图片" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.image_url" type="success" effect="light">有图</el-tag>
|
||||||
|
<span v-else class="app-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="actions">
|
||||||
|
<el-button size="small" @click="view(row)">查看</el-button>
|
||||||
|
<el-button v-if="row.is_active" size="small" @click="onDeactivate(row)">停用</el-button>
|
||||||
|
<el-button v-else type="success" size="small" @click="onActivate(row)">启用</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 280px;
|
||||||
|
max-height: 160px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-view-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-view-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
924
admin-frontend/src/pages/EmailPage.vue
Normal file
924
admin-frontend/src/pages/EmailPage.vue
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { cleanupEmailLogs, fetchEmailLogs, fetchEmailSettings, fetchEmailStats, updateEmailSettings } from '../api/email'
|
||||||
|
import {
|
||||||
|
createSmtpConfig,
|
||||||
|
clearPrimarySmtpConfig,
|
||||||
|
deleteSmtpConfig,
|
||||||
|
fetchSmtpConfigs,
|
||||||
|
setPrimarySmtpConfig,
|
||||||
|
testSmtpConfig,
|
||||||
|
updateSmtpConfig,
|
||||||
|
} from '../api/smtp'
|
||||||
|
import MetricGrid from '../components/MetricGrid.vue'
|
||||||
|
|
||||||
|
// ========== 全局设置 ==========
|
||||||
|
const emailSettingsLoading = ref(false)
|
||||||
|
const emailSettingsSaving = ref(false)
|
||||||
|
|
||||||
|
const settings = reactive({
|
||||||
|
enabled: false,
|
||||||
|
failover_enabled: true,
|
||||||
|
register_verify_enabled: false,
|
||||||
|
login_alert_enabled: true,
|
||||||
|
task_notify_enabled: false,
|
||||||
|
base_url: '',
|
||||||
|
updated_at: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
let saveTimer = null
|
||||||
|
|
||||||
|
async function loadEmailSettings() {
|
||||||
|
emailSettingsLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchEmailSettings()
|
||||||
|
settings.enabled = Boolean(data.enabled)
|
||||||
|
settings.failover_enabled = Boolean(data.failover_enabled)
|
||||||
|
settings.register_verify_enabled = Boolean(data.register_verify_enabled)
|
||||||
|
settings.login_alert_enabled = data.login_alert_enabled === undefined ? true : Boolean(data.login_alert_enabled)
|
||||||
|
settings.task_notify_enabled = Boolean(data.task_notify_enabled)
|
||||||
|
settings.base_url = data.base_url || ''
|
||||||
|
settings.updated_at = data.updated_at || null
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
emailSettingsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmailSettings() {
|
||||||
|
if (emailSettingsLoading.value) return
|
||||||
|
emailSettingsSaving.value = true
|
||||||
|
try {
|
||||||
|
const res = await updateEmailSettings({
|
||||||
|
enabled: settings.enabled,
|
||||||
|
failover_enabled: settings.failover_enabled,
|
||||||
|
register_verify_enabled: settings.register_verify_enabled,
|
||||||
|
login_alert_enabled: settings.login_alert_enabled,
|
||||||
|
task_notify_enabled: settings.task_notify_enabled,
|
||||||
|
base_url: (settings.base_url || '').trim(),
|
||||||
|
})
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '更新失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('邮件设置已更新')
|
||||||
|
await loadEmailSettings()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
emailSettingsSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSaveEmailSettings() {
|
||||||
|
if (saveTimer) window.clearTimeout(saveTimer)
|
||||||
|
saveTimer = window.setTimeout(saveEmailSettings, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (saveTimer) window.clearTimeout(saveTimer)
|
||||||
|
saveTimer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== SMTP 配置 ==========
|
||||||
|
const smtpLoading = ref(false)
|
||||||
|
const smtpConfigs = ref([])
|
||||||
|
|
||||||
|
const smtpDialogOpen = ref(false)
|
||||||
|
const smtpEditMode = ref(false)
|
||||||
|
const smtpHasPassword = ref(false)
|
||||||
|
const smtpIsPrimary = ref(false)
|
||||||
|
|
||||||
|
const smtpForm = reactive({
|
||||||
|
id: null,
|
||||||
|
name: '默认配置',
|
||||||
|
enabled: true,
|
||||||
|
host: '',
|
||||||
|
port: 465,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
use_ssl: true,
|
||||||
|
use_tls: false,
|
||||||
|
sender_name: '自动化学习',
|
||||||
|
sender_email: '',
|
||||||
|
daily_limit: 0,
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const SMTP_TEMPLATES = [
|
||||||
|
{
|
||||||
|
key: 'custom',
|
||||||
|
label: '自定义(手动填写)',
|
||||||
|
defaults: null,
|
||||||
|
note: '适用于其他邮箱/自建SMTP',
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gmail',
|
||||||
|
label: 'Gmail',
|
||||||
|
defaults: { host: 'smtp.gmail.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '通常需要开启两步验证并创建应用专用密码(App Password)',
|
||||||
|
links: [
|
||||||
|
{ label: 'SMTP 设置说明', url: 'https://support.google.com/mail/answer/7126229?hl=zh-Hans' },
|
||||||
|
{ label: 'App Password', url: 'https://myaccount.google.com/apppasswords' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qq',
|
||||||
|
label: 'QQ 邮箱',
|
||||||
|
defaults: { host: 'smtp.qq.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '需要在邮箱设置中开启 SMTP 并获取授权码(不是QQ登录密码)',
|
||||||
|
links: [{ label: 'QQ邮箱 SMTP 帮助', url: 'https://service.mail.qq.com/cgi-bin/help?subtype=1&id=28&no=1001256' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '163',
|
||||||
|
label: '163 邮箱',
|
||||||
|
defaults: { host: 'smtp.163.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '需要在邮箱设置中开启 SMTP 并使用授权码/客户端授权密码',
|
||||||
|
links: [{ label: '网易邮箱 SMTP 帮助', url: 'https://help.mail.163.com/faqDetail.do?code=d7a5dc8471a22b76' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '126',
|
||||||
|
label: '126 邮箱',
|
||||||
|
defaults: { host: 'smtp.126.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '需要在邮箱设置中开启 SMTP 并使用授权码/客户端授权密码',
|
||||||
|
links: [{ label: '网易邮箱帮助', url: 'https://help.mail.163.com/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'outlook',
|
||||||
|
label: 'Outlook/Hotmail',
|
||||||
|
defaults: { host: 'smtp-mail.outlook.com', port: 587, use_ssl: false, use_tls: true },
|
||||||
|
note: '建议使用 TLS 587(部分账号需开启 SMTP AUTH)',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
label: '微软 SMTP 设置',
|
||||||
|
url: 'https://support.microsoft.com/office/pop-imap-and-smtp-settings-for-outlook-com-d088b0b7-0d38-4f9a-bc5d-509f9e4c6d3d',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'office365',
|
||||||
|
label: 'Microsoft 365/Exchange',
|
||||||
|
defaults: { host: 'smtp.office365.com', port: 587, use_ssl: false, use_tls: true },
|
||||||
|
note: '企业邮箱常用配置(需启用 SMTP AUTH)',
|
||||||
|
links: [{ label: '微软官方说明', url: 'https://learn.microsoft.com/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'icloud',
|
||||||
|
label: 'iCloud',
|
||||||
|
defaults: { host: 'smtp.mail.me.com', port: 587, use_ssl: false, use_tls: true },
|
||||||
|
note: '需要在 Apple ID 中生成“App 专用密码”',
|
||||||
|
links: [{ label: 'Apple 邮件服务器设置', url: 'https://support.apple.com/zh-cn/HT202304' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tencent_exmail',
|
||||||
|
label: '腾讯企业邮箱',
|
||||||
|
defaults: { host: 'smtp.exmail.qq.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '企业邮箱常用配置',
|
||||||
|
links: [{ label: '腾讯企业邮箱帮助', url: 'https://service.exmail.qq.com/cgi-bin/help?subtype=1&id=23&no=1001068' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aliyun_exmail',
|
||||||
|
label: '阿里企业邮箱',
|
||||||
|
defaults: { host: 'smtp.mxhichina.com', port: 465, use_ssl: true, use_tls: false },
|
||||||
|
note: '企业邮箱常用配置',
|
||||||
|
links: [{ label: '阿里云文档', url: 'https://help.aliyun.com/document_detail/50652.html' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const smtpTemplateKey = ref('custom')
|
||||||
|
const currentSmtpTemplate = computed(() => SMTP_TEMPLATES.find((t) => t.key === smtpTemplateKey.value) || SMTP_TEMPLATES[0])
|
||||||
|
|
||||||
|
const smtpPasswordPlaceholder = computed(() =>
|
||||||
|
smtpEditMode.value && smtpHasPassword.value ? '留空保持不变' : 'SMTP密码或授权码',
|
||||||
|
)
|
||||||
|
|
||||||
|
function inferSmtpTemplateKey(row) {
|
||||||
|
const host = String(row?.host || '').trim().toLowerCase()
|
||||||
|
if (!host) return 'custom'
|
||||||
|
const byHost = {
|
||||||
|
'smtp.gmail.com': 'gmail',
|
||||||
|
'smtp.qq.com': 'qq',
|
||||||
|
'smtp.163.com': '163',
|
||||||
|
'smtp.126.com': '126',
|
||||||
|
'smtp-mail.outlook.com': 'outlook',
|
||||||
|
'smtp.office365.com': 'office365',
|
||||||
|
'smtp.mail.me.com': 'icloud',
|
||||||
|
'smtp.exmail.qq.com': 'tencent_exmail',
|
||||||
|
'smtp.mxhichina.com': 'aliyun_exmail',
|
||||||
|
}
|
||||||
|
return byHost[host] || 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySmtpTemplate(key) {
|
||||||
|
const tpl = SMTP_TEMPLATES.find((t) => t.key === key)
|
||||||
|
if (!tpl || !tpl.defaults) return
|
||||||
|
smtpForm.host = tpl.defaults.host
|
||||||
|
smtpForm.port = tpl.defaults.port
|
||||||
|
smtpForm.use_ssl = tpl.defaults.use_ssl
|
||||||
|
smtpForm.use_tls = tpl.defaults.use_tls
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSmtpForm() {
|
||||||
|
smtpForm.id = null
|
||||||
|
smtpForm.name = '默认配置'
|
||||||
|
smtpForm.enabled = true
|
||||||
|
smtpForm.host = ''
|
||||||
|
smtpForm.port = 465
|
||||||
|
smtpForm.username = ''
|
||||||
|
smtpForm.password = ''
|
||||||
|
smtpForm.use_ssl = true
|
||||||
|
smtpForm.use_tls = false
|
||||||
|
smtpForm.sender_name = '自动化学习'
|
||||||
|
smtpForm.sender_email = ''
|
||||||
|
smtpForm.daily_limit = 0
|
||||||
|
smtpForm.priority = 0
|
||||||
|
smtpHasPassword.value = false
|
||||||
|
smtpIsPrimary.value = false
|
||||||
|
smtpTemplateKey.value = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSmtpConfigs() {
|
||||||
|
smtpLoading.value = true
|
||||||
|
try {
|
||||||
|
smtpConfigs.value = await fetchSmtpConfigs()
|
||||||
|
} catch {
|
||||||
|
smtpConfigs.value = []
|
||||||
|
} finally {
|
||||||
|
smtpLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateSmtp() {
|
||||||
|
smtpEditMode.value = false
|
||||||
|
resetSmtpForm()
|
||||||
|
smtpTemplateKey.value = 'custom'
|
||||||
|
smtpDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditSmtp(row) {
|
||||||
|
smtpEditMode.value = true
|
||||||
|
resetSmtpForm()
|
||||||
|
|
||||||
|
smtpForm.id = row.id
|
||||||
|
smtpForm.name = row.name || '默认配置'
|
||||||
|
smtpForm.enabled = Boolean(row.enabled)
|
||||||
|
smtpForm.host = row.host || ''
|
||||||
|
smtpForm.port = row.port || 465
|
||||||
|
smtpForm.username = row.username || ''
|
||||||
|
smtpForm.password = ''
|
||||||
|
smtpForm.use_ssl = Boolean(row.use_ssl)
|
||||||
|
smtpForm.use_tls = Boolean(row.use_tls)
|
||||||
|
smtpForm.sender_name = row.sender_name || '自动化学习'
|
||||||
|
smtpForm.sender_email = row.sender_email || ''
|
||||||
|
smtpForm.daily_limit = row.daily_limit ?? 0
|
||||||
|
smtpForm.priority = row.priority ?? 0
|
||||||
|
smtpHasPassword.value = Boolean(row.has_password)
|
||||||
|
smtpIsPrimary.value = Boolean(row.is_primary)
|
||||||
|
smtpTemplateKey.value = inferSmtpTemplateKey(row)
|
||||||
|
|
||||||
|
smtpDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function smtpStatusMeta(row) {
|
||||||
|
if (row.is_primary) return { label: '主', type: 'warning' }
|
||||||
|
if (row.enabled) return { label: '备用', type: 'success' }
|
||||||
|
return { label: '禁用', type: 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function smtpDailyText(row) {
|
||||||
|
if (row.daily_limit && row.daily_limit > 0) return `${row.daily_sent}/${row.daily_limit}`
|
||||||
|
return `${row.daily_sent}/∞`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSmtp() {
|
||||||
|
if (!smtpForm.host.trim()) {
|
||||||
|
ElMessage.error('SMTP服务器地址不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!smtpForm.username.trim()) {
|
||||||
|
ElMessage.error('SMTP用户名不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePayload = {
|
||||||
|
name: smtpForm.name.trim() || '默认配置',
|
||||||
|
enabled: Boolean(smtpForm.enabled),
|
||||||
|
priority: Number(smtpForm.priority) || 0,
|
||||||
|
host: smtpForm.host.trim(),
|
||||||
|
port: Number(smtpForm.port) || 465,
|
||||||
|
username: smtpForm.username.trim(),
|
||||||
|
use_ssl: Boolean(smtpForm.use_ssl),
|
||||||
|
use_tls: Boolean(smtpForm.use_tls),
|
||||||
|
sender_name: (smtpForm.sender_name || '').trim(),
|
||||||
|
sender_email: (smtpForm.sender_email || '').trim(),
|
||||||
|
daily_limit: Number(smtpForm.daily_limit) || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (smtpEditMode.value) {
|
||||||
|
const payload = { ...basePayload }
|
||||||
|
if (smtpForm.password) payload.password = smtpForm.password
|
||||||
|
|
||||||
|
const res = await updateSmtpConfig(smtpForm.id, payload)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '更新失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
const payload = { ...basePayload }
|
||||||
|
if (smtpForm.password) payload.password = smtpForm.password
|
||||||
|
const res = await createSmtpConfig(payload)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '创建失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
smtpDialogOpen.value = false
|
||||||
|
await loadSmtpConfigs()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doTestSmtp() {
|
||||||
|
if (!smtpEditMode.value || !smtpForm.id) {
|
||||||
|
ElMessage.error('请先保存配置后再测试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let email
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt('请输入测试收件邮箱', '测试连接', {
|
||||||
|
inputPlaceholder: 'name@example.com',
|
||||||
|
confirmButtonText: '发送测试邮件',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputValidator: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim()),
|
||||||
|
inputErrorMessage: '邮箱格式不正确',
|
||||||
|
})
|
||||||
|
email = String(res.value || '').trim()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await testSmtpConfig(smtpForm.id, email)
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success('测试成功,邮件已发送')
|
||||||
|
await loadSmtpConfigs()
|
||||||
|
} else {
|
||||||
|
await ElMessageBox.alert(res?.error || '测试失败', '测试失败', { confirmButtonText: '知道了' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSetPrimary() {
|
||||||
|
if (!smtpEditMode.value || !smtpForm.id) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定将该配置设为主配置吗?', '设为主配置', {
|
||||||
|
confirmButtonText: '设为主配置',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await setPrimarySmtpConfig(smtpForm.id)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '设置失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已设为主配置')
|
||||||
|
smtpDialogOpen.value = false
|
||||||
|
await loadSmtpConfigs()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doClearPrimary() {
|
||||||
|
if (!smtpEditMode.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定取消主配置吗?取消后将按优先级选择可用SMTP。', '取消主配置', {
|
||||||
|
confirmButtonText: '取消主配置',
|
||||||
|
cancelButtonText: '保留',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await clearPrimarySmtpConfig()
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '操作失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已取消主配置')
|
||||||
|
smtpDialogOpen.value = false
|
||||||
|
await loadSmtpConfigs()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeleteSmtp() {
|
||||||
|
if (!smtpEditMode.value || !smtpForm.id) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除该SMTP配置吗?此操作不可恢复。', '删除配置', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteSmtpConfig(smtpForm.id)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '删除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
smtpDialogOpen.value = false
|
||||||
|
await loadSmtpConfigs()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 邮件统计 / 日志 ==========
|
||||||
|
const emailStatsLoading = ref(false)
|
||||||
|
const emailStats = ref({})
|
||||||
|
|
||||||
|
const emailLogsLoading = ref(false)
|
||||||
|
const emailLogTypeFilter = ref('')
|
||||||
|
const emailLogStatusFilter = ref('')
|
||||||
|
const emailLogPage = ref(1)
|
||||||
|
const emailLogPageSize = 15
|
||||||
|
const emailLogs = ref([])
|
||||||
|
const emailLogTotal = ref(0)
|
||||||
|
const emailLogTotalPages = ref(1)
|
||||||
|
|
||||||
|
function emailTypeLabel(type) {
|
||||||
|
const map = {
|
||||||
|
register: '注册验证',
|
||||||
|
reset: '密码重置',
|
||||||
|
bind: '邮箱绑定',
|
||||||
|
task_complete: '任务完成',
|
||||||
|
security_alert: '安全告警',
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailLogUserLabel(row) {
|
||||||
|
if (row?.username && row?.user_id) return `${row.username} (#${row.user_id})`
|
||||||
|
if (row?.user_id) return `用户#${row.user_id}`
|
||||||
|
return '系统'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const emailSummaryCards = computed(() => [
|
||||||
|
{ key: 'total_sent', label: '总发送', value: emailStats.value?.total_sent || 0, tone: 'blue' },
|
||||||
|
{ key: 'total_success', label: '成功', value: emailStats.value?.total_success || 0, tone: 'green' },
|
||||||
|
{ key: 'total_failed', label: '失败', value: emailStats.value?.total_failed || 0, tone: 'red' },
|
||||||
|
{ key: 'success_rate', label: '成功率', value: `${emailStats.value?.success_rate || 0}%`, tone: 'purple' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const emailTypeCards = computed(() => [
|
||||||
|
{ key: 'register_sent', label: '注册验证', value: emailStats.value?.register_sent || 0, tone: 'cyan' },
|
||||||
|
{ key: 'reset_sent', label: '密码重置', value: emailStats.value?.reset_sent || 0, tone: 'orange' },
|
||||||
|
{ key: 'bind_sent', label: '邮箱绑定', value: emailStats.value?.bind_sent || 0, tone: 'purple' },
|
||||||
|
{ key: 'task_complete_sent', label: '任务完成', value: emailStats.value?.task_complete_sent || 0, tone: 'green' },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function loadEmailStats() {
|
||||||
|
emailStatsLoading.value = true
|
||||||
|
try {
|
||||||
|
emailStats.value = await fetchEmailStats()
|
||||||
|
} catch {
|
||||||
|
emailStats.value = {}
|
||||||
|
} finally {
|
||||||
|
emailStatsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmailLogs(page = 1) {
|
||||||
|
emailLogsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
page_size: emailLogPageSize,
|
||||||
|
}
|
||||||
|
if (emailLogTypeFilter.value) params.type = emailLogTypeFilter.value
|
||||||
|
if (emailLogStatusFilter.value) params.status = emailLogStatusFilter.value
|
||||||
|
|
||||||
|
const data = await fetchEmailLogs(params)
|
||||||
|
emailLogs.value = data?.logs || []
|
||||||
|
emailLogTotal.value = data?.total || 0
|
||||||
|
emailLogPage.value = data?.page || page
|
||||||
|
emailLogTotalPages.value = data?.total_pages || 1
|
||||||
|
} catch {
|
||||||
|
emailLogs.value = []
|
||||||
|
emailLogTotal.value = 0
|
||||||
|
emailLogTotalPages.value = 1
|
||||||
|
} finally {
|
||||||
|
emailLogsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCleanupEmailLogs() {
|
||||||
|
let days
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt('请输入保留天数(将删除该天数之前的日志)', '清理日志', {
|
||||||
|
inputValue: '30',
|
||||||
|
confirmButtonText: '清理',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputValidator: (v) => {
|
||||||
|
const n = parseInt(String(v), 10)
|
||||||
|
return Number.isFinite(n) && n >= 7
|
||||||
|
},
|
||||||
|
inputErrorMessage: '天数必须大于等于7',
|
||||||
|
})
|
||||||
|
days = parseInt(String(res.value), 10)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除 ${days} 天之前的邮件日志吗?`, '二次确认', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await cleanupEmailLogs(days)
|
||||||
|
if (!res?.success) {
|
||||||
|
ElMessage.error(res?.error || '清理失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success(`已清理 ${res.deleted} 条日志`)
|
||||||
|
await loadEmailLogs(1)
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadEmailSettings(), loadSmtpConfigs(), loadEmailStats(), loadEmailLogs(1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>邮件配置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailSettingsLoading">
|
||||||
|
<h3 class="section-title">全局设置</h3>
|
||||||
|
|
||||||
|
<el-form label-width="140px">
|
||||||
|
<el-form-item label="启用邮件功能">
|
||||||
|
<el-switch v-model="settings.enabled" :disabled="emailSettingsSaving" @change="scheduleSaveEmailSettings" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用故障转移">
|
||||||
|
<el-switch
|
||||||
|
v-model="settings.failover_enabled"
|
||||||
|
:disabled="emailSettingsSaving"
|
||||||
|
@change="scheduleSaveEmailSettings"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用注册邮箱验证">
|
||||||
|
<el-switch
|
||||||
|
v-model="settings.register_verify_enabled"
|
||||||
|
:disabled="emailSettingsSaving"
|
||||||
|
@change="scheduleSaveEmailSettings"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">通知设置</el-divider>
|
||||||
|
<el-form-item label="启用任务完成通知">
|
||||||
|
<el-switch
|
||||||
|
v-model="settings.task_notify_enabled"
|
||||||
|
:disabled="emailSettingsSaving"
|
||||||
|
@change="scheduleSaveEmailSettings"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新设备登录提醒">
|
||||||
|
<el-switch
|
||||||
|
v-model="settings.login_alert_enabled"
|
||||||
|
:disabled="emailSettingsSaving"
|
||||||
|
@change="scheduleSaveEmailSettings"
|
||||||
|
/>
|
||||||
|
<div class="help">当检测到新设备或新IP登录时,发送邮件提醒用户</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="网站基础URL">
|
||||||
|
<el-input
|
||||||
|
v-model="settings.base_url"
|
||||||
|
placeholder="例如: https://example.com"
|
||||||
|
:disabled="emailSettingsSaving"
|
||||||
|
@blur="scheduleSaveEmailSettings"
|
||||||
|
/>
|
||||||
|
<div class="help">用于生成邮件中的验证链接,留空则使用默认配置。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="help app-muted">最近更新时间:{{ settings.updated_at || '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3 class="section-title">SMTP配置列表</h3>
|
||||||
|
<el-button type="primary" @click="openCreateSmtp">+ 添加配置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="smtpConfigs" v-loading="smtpLoading" style="width: 100%">
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="smtpStatusMeta(row).type" effect="light">
|
||||||
|
{{ smtpStatusMeta(row).label }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="160" />
|
||||||
|
<el-table-column label="服务器" min-width="200">
|
||||||
|
<template #default="{ row }">{{ row.host }}:{{ row.port }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="今日/限额" width="110">
|
||||||
|
<template #default="{ row }">{{ smtpDailyText(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="成功率" width="100">
|
||||||
|
<template #default="{ row }">{{ row.success_rate }}%</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEditSmtp(row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailStatsLoading">
|
||||||
|
<h3 class="section-title">邮件发送统计</h3>
|
||||||
|
|
||||||
|
<MetricGrid :items="emailSummaryCards" :loading="emailStatsLoading" :min-width="160" />
|
||||||
|
|
||||||
|
<div class="sub-stats">
|
||||||
|
<MetricGrid :items="emailTypeCards" :loading="emailStatsLoading" :min-width="150" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help app-muted">最后更新:{{ emailStats.last_updated || '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3 class="section-title">邮件发送日志</h3>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="emailLogTypeFilter" style="width: 140px" @change="loadEmailLogs(1)">
|
||||||
|
<el-option label="全部类型" value="" />
|
||||||
|
<el-option label="注册验证" value="register" />
|
||||||
|
<el-option label="密码重置" value="reset" />
|
||||||
|
<el-option label="邮箱绑定" value="bind" />
|
||||||
|
<el-option label="任务完成" value="task_complete" />
|
||||||
|
<el-option label="安全告警" value="security_alert" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
|
||||||
|
<el-option label="全部状态" value="" />
|
||||||
|
<el-option label="成功" value="success" />
|
||||||
|
<el-option label="失败" value="failed" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="danger" plain @click="onCleanupEmailLogs">清理日志</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="emailLogs" v-loading="emailLogsLoading" style="width: 100%">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
<el-table-column prop="email_to" label="收件人" min-width="180" />
|
||||||
|
<el-table-column label="来源用户" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ellipsis" :title="emailLogUserLabel(row)">{{ emailLogUserLabel(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="120">
|
||||||
|
<template #default="{ row }">{{ emailTypeLabel(row.email_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="主题" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ellipsis" :title="row.subject">{{ row.subject }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" effect="light">
|
||||||
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="错误" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ellipsis" :title="row.error_message || ''">{{ row.error_message || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="emailLogPage"
|
||||||
|
:page-size="emailLogPageSize"
|
||||||
|
:total="emailLogTotal"
|
||||||
|
layout="prev, pager, next, ->, total"
|
||||||
|
@current-change="loadEmailLogs"
|
||||||
|
/>
|
||||||
|
<div class="page-hint app-muted">第 {{ emailLogPage }} / {{ emailLogTotalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="smtpDialogOpen"
|
||||||
|
:title="smtpEditMode ? '编辑SMTP配置' : '添加SMTP配置'"
|
||||||
|
width="min(560px, 92vw)"
|
||||||
|
>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="smtpForm.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="smtpForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱模板">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<el-select v-model="smtpTemplateKey" placeholder="选择常用邮箱模板" style="width: 100%" @change="applySmtpTemplate">
|
||||||
|
<el-option v-for="t in SMTP_TEMPLATES" :key="t.key" :label="t.label" :value="t.key" />
|
||||||
|
</el-select>
|
||||||
|
<div
|
||||||
|
v-if="currentSmtpTemplate.note || (currentSmtpTemplate.links && currentSmtpTemplate.links.length)"
|
||||||
|
class="help"
|
||||||
|
>
|
||||||
|
<span v-if="currentSmtpTemplate.note">{{ currentSmtpTemplate.note }}</span>
|
||||||
|
<template v-if="currentSmtpTemplate.links && currentSmtpTemplate.links.length">
|
||||||
|
<span v-if="currentSmtpTemplate.note"> · </span>
|
||||||
|
<span v-for="(l, idx) in currentSmtpTemplate.links" :key="l.url">
|
||||||
|
<el-link :href="l.url" target="_blank" type="primary" :underline="false">{{ l.label }}</el-link>
|
||||||
|
<span v-if="idx < currentSmtpTemplate.links.length - 1"> · </span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务器">
|
||||||
|
<el-input v-model="smtpForm.host" placeholder="smtp.example.com" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="端口">
|
||||||
|
<el-input-number v-model="smtpForm.port" :min="1" :max="65535" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="smtpForm.username" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="smtpForm.password" type="password" show-password :placeholder="smtpPasswordPlaceholder" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SSL">
|
||||||
|
<el-switch v-model="smtpForm.use_ssl" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="TLS">
|
||||||
|
<el-switch v-model="smtpForm.use_tls" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发件人名称">
|
||||||
|
<el-input v-model="smtpForm.sender_name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发件人邮箱">
|
||||||
|
<el-input v-model="smtpForm.sender_email" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="每日限额">
|
||||||
|
<el-input-number v-model="smtpForm.daily_limit" :min="0" :max="1000000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级">
|
||||||
|
<el-input-number v-model="smtpForm.priority" :min="0" :max="1000" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<el-button @click="doTestSmtp">测试连接</el-button>
|
||||||
|
<el-button v-if="smtpEditMode && smtpIsPrimary" type="warning" plain @click="doClearPrimary">取消主配置</el-button>
|
||||||
|
<el-button v-if="smtpEditMode && !smtpIsPrimary" @click="doSetPrimary">设为主配置</el-button>
|
||||||
|
<el-button v-if="smtpEditMode" type="danger" plain @click="doDeleteSmtp">删除配置</el-button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<el-button @click="smtpDialogOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSmtp">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sub-stats {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
247
admin-frontend/src/pages/FeedbacksPage.vue
Normal file
247
admin-frontend/src/pages/FeedbacksPage.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { closeFeedback, deleteFeedback, fetchFeedbacks, replyFeedback } from '../api/feedbacks'
|
||||||
|
import MetricGrid from '../components/MetricGrid.vue'
|
||||||
|
|
||||||
|
const refreshNavBadges = inject('refreshNavBadges', null)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const stats = ref({ total: 0, pending: 0, replied: 0, closed: 0 })
|
||||||
|
const list = ref([])
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部状态', value: '' },
|
||||||
|
{ label: '待处理', value: 'pending' },
|
||||||
|
{ label: '已回复', value: 'replied' },
|
||||||
|
{ label: '已关闭', value: 'closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const metricItems = computed(() => [
|
||||||
|
{ key: 'total', label: '总反馈', value: stats.value.total || 0, tone: 'blue' },
|
||||||
|
{ key: 'pending', label: '待处理', value: stats.value.pending || 0, tone: 'orange' },
|
||||||
|
{ key: 'replied', label: '已回复', value: stats.value.replied || 0, tone: 'green' },
|
||||||
|
{ key: 'closed', label: '已关闭', value: stats.value.closed || 0, tone: 'purple' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function statusMeta(status) {
|
||||||
|
if (status === 'pending') return { label: '待处理', type: 'warning' }
|
||||||
|
if (status === 'replied') return { label: '已回复', type: 'success' }
|
||||||
|
if (status === 'closed') return { label: '已关闭', type: 'info' }
|
||||||
|
return { label: status || '-', type: 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchFeedbacks(statusFilter.value)
|
||||||
|
list.value = data?.feedbacks || []
|
||||||
|
stats.value = data?.stats || { total: 0, pending: 0, replied: 0, closed: 0 }
|
||||||
|
} catch {
|
||||||
|
list.value = []
|
||||||
|
stats.value = { total: 0, pending: 0, replied: 0, closed: 0 }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshNavBadges?.({ pendingFeedbacks: stats.value.pending || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReply(row) {
|
||||||
|
let text
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt('请输入回复内容', '回复反馈', {
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '回复内容',
|
||||||
|
confirmButtonText: '提交',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputValidator: (v) => Boolean(String(v || '').trim()),
|
||||||
|
inputErrorMessage: '回复内容不能为空',
|
||||||
|
})
|
||||||
|
text = res.value
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await replyFeedback(row.id, String(text || '').trim())
|
||||||
|
ElMessage.success(res?.message || '回复成功')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClose(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要关闭这个反馈吗?', '关闭反馈', {
|
||||||
|
confirmButtonText: '关闭',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await closeFeedback(row.id)
|
||||||
|
ElMessage.success(res?.message || '反馈已关闭')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个反馈吗?此操作不可恢复!', '删除反馈', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteFeedback(row.id)
|
||||||
|
ElMessage.success(res?.message || '反馈已删除')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>反馈管理</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="statusFilter" style="width: 160px" @change="load">
|
||||||
|
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MetricGrid :items="metricItems" :loading="loading" :min-width="165" />
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3 class="section-title">反馈列表</h3>
|
||||||
|
<div class="app-muted">共 {{ list.length }} 条(当前筛选)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="list" v-loading="loading" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="username" label="用户" width="140" />
|
||||||
|
<el-table-column label="标题" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="row.title" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.title }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="描述" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="row.description" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.description }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="contact" label="联系方式" min-width="160">
|
||||||
|
<template #default="{ row }">{{ row.contact || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="提交时间" width="180" />
|
||||||
|
<el-table-column label="回复" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="row.admin_reply || ''" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.admin_reply || '-' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="actions">
|
||||||
|
<template v-if="row.status !== 'closed'">
|
||||||
|
<el-button type="primary" size="small" @click="onReply(row)">回复</el-button>
|
||||||
|
<el-button size="small" @click="onClose(row)">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
294
admin-frontend/src/pages/LogsPage.vue
Normal file
294
admin-frontend/src/pages/LogsPage.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { fetchAllUsers } from '../api/users'
|
||||||
|
import { clearOldTaskLogs, fetchTaskLogs } from '../api/tasks'
|
||||||
|
import { getTaskSourceMeta } from '../utils/taskSource'
|
||||||
|
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const logs = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const userOptions = ref([])
|
||||||
|
|
||||||
|
const dateFilter = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const sourceFilter = ref('')
|
||||||
|
const userIdFilter = ref('')
|
||||||
|
const accountFilter = ref('')
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds === null || seconds === undefined) return '-'
|
||||||
|
const n = Number(seconds)
|
||||||
|
if (!Number.isFinite(n)) return '-'
|
||||||
|
if (n < 60) return `${n}秒`
|
||||||
|
return `${Math.floor(n / 60)}分${n % 60}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceMeta(source) {
|
||||||
|
const meta = getTaskSourceMeta(source)
|
||||||
|
return { key: meta.group, label: meta.label, type: meta.type, tooltip: meta.tooltip }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status) {
|
||||||
|
if (status === 'success') return { label: '成功', type: 'success' }
|
||||||
|
if (status === 'failed') return { label: '失败', type: 'danger' }
|
||||||
|
return { label: status || '-', type: 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
const users = await fetchAllUsers()
|
||||||
|
userOptions.value = (users || []).map((u) => ({ id: u.id, username: u.username }))
|
||||||
|
} catch {
|
||||||
|
userOptions.value = []
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const offset = (currentPage.value - 1) * pageSize
|
||||||
|
const params = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
if (dateFilter.value) params.date = dateFilter.value
|
||||||
|
if (statusFilter.value) params.status = statusFilter.value
|
||||||
|
if (sourceFilter.value) params.source = sourceFilter.value
|
||||||
|
if (userIdFilter.value) params.user_id = userIdFilter.value
|
||||||
|
if (accountFilter.value) params.account = accountFilter.value
|
||||||
|
|
||||||
|
const data = await fetchTaskLogs(params)
|
||||||
|
logs.value = data?.logs || []
|
||||||
|
total.value = data?.total || 0
|
||||||
|
} catch {
|
||||||
|
logs.value = []
|
||||||
|
total.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilter() {
|
||||||
|
currentPage.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
dateFilter.value = ''
|
||||||
|
statusFilter.value = ''
|
||||||
|
sourceFilter.value = ''
|
||||||
|
userIdFilter.value = ''
|
||||||
|
accountFilter.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearOld() {
|
||||||
|
let days
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt('请输入要清理多少天前的日志(默认30天)', '清理旧日志', {
|
||||||
|
inputValue: '30',
|
||||||
|
confirmButtonText: '下一步',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputValidator: (v) => {
|
||||||
|
const n = parseInt(String(v), 10)
|
||||||
|
return Number.isFinite(n) && n >= 1
|
||||||
|
},
|
||||||
|
inputErrorMessage: '请输入有效的天数(大于0的整数)',
|
||||||
|
})
|
||||||
|
days = parseInt(String(res.value), 10)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除 ${days} 天前的所有日志吗?此操作不可恢复!`, '二次确认', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await clearOldTaskLogs(days)
|
||||||
|
ElMessage.success(res?.message || '清理成功')
|
||||||
|
currentPage.value = 1
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadUsers()
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>任务日志</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="filters">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateFilter"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="日期"
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" style="width: 120px">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="成功" value="success" />
|
||||||
|
<el-option label="失败" value="failed" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="sourceFilter" placeholder="来源" style="width: 120px">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="手动" value="manual" />
|
||||||
|
<el-option label="定时任务(系统)" value="scheduled" />
|
||||||
|
<el-option label="定时任务(用户)" value="user_scheduled" />
|
||||||
|
<el-option label="手动(批量)" value="batch" />
|
||||||
|
<el-option label="手动(截图)" value="manual_screenshot" />
|
||||||
|
<el-option label="手动(立即)" value="immediate" />
|
||||||
|
<el-option label="手动(恢复)" value="resumed" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="userIdFilter"
|
||||||
|
placeholder="用户"
|
||||||
|
style="width: 140px"
|
||||||
|
:loading="usersLoading"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="accountFilter" placeholder="账号关键字" style="width: 170px" clearable />
|
||||||
|
<el-button type="primary" @click="onFilter">筛选</el-button>
|
||||||
|
<el-button @click="onReset">重置</el-button>
|
||||||
|
<el-button type="danger" plain @click="onClearOld">清理旧日志</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
<el-table-column label="来源" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="sourceMeta(row.source).tooltip"
|
||||||
|
:content="sourceMeta(row.source).tooltip"
|
||||||
|
placement="top"
|
||||||
|
:show-after="300"
|
||||||
|
>
|
||||||
|
<el-tag :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="user_username" label="用户" width="140" />
|
||||||
|
<el-table-column prop="username" label="账号" width="160" />
|
||||||
|
<el-table-column prop="browse_type" label="浏览类型" width="120" />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="内容/附件" width="110">
|
||||||
|
<template #default="{ row }">{{ row.total_items }} / {{ row.total_attachments }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用时" width="90">
|
||||||
|
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="失败原因" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="row.error_message || ''" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.error_message || '-' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="prev, pager, next, jumper, ->, total"
|
||||||
|
@current-change="load"
|
||||||
|
/>
|
||||||
|
<div class="page-hint app-muted">第 {{ currentPage }} / {{ totalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1218
admin-frontend/src/pages/ReportPage.vue
Normal file
1218
admin-frontend/src/pages/ReportPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
836
admin-frontend/src/pages/SecurityPage.vue
Normal file
836
admin-frontend/src/pages/SecurityPage.vue
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
banIp,
|
||||||
|
banUser,
|
||||||
|
cleanup,
|
||||||
|
clearIpRisk,
|
||||||
|
getBannedIps,
|
||||||
|
getBannedUsers,
|
||||||
|
getDashboard,
|
||||||
|
getIpRisk,
|
||||||
|
getThreats,
|
||||||
|
getUserRisk,
|
||||||
|
unbanIp,
|
||||||
|
unbanUser,
|
||||||
|
} from '../api/security'
|
||||||
|
import MetricGrid from '../components/MetricGrid.vue'
|
||||||
|
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const activeTab = ref('threats')
|
||||||
|
|
||||||
|
const dashboardLoading = ref(false)
|
||||||
|
const dashboard = ref(null)
|
||||||
|
|
||||||
|
const threatsLoading = ref(false)
|
||||||
|
const threatItems = ref([])
|
||||||
|
const threatTotal = ref(0)
|
||||||
|
const threatPage = ref(1)
|
||||||
|
const threatTypeFilter = ref('')
|
||||||
|
const threatSeverityFilter = ref('')
|
||||||
|
|
||||||
|
const bansLoading = ref(false)
|
||||||
|
const bannedIps = ref([])
|
||||||
|
const bannedUsers = ref([])
|
||||||
|
const banTab = ref('ips')
|
||||||
|
|
||||||
|
const banDialogOpen = ref(false)
|
||||||
|
const banSubmitting = ref(false)
|
||||||
|
const banForm = ref({
|
||||||
|
kind: 'ip',
|
||||||
|
ip: '',
|
||||||
|
user_id: '',
|
||||||
|
reason: '',
|
||||||
|
duration_hours: 24,
|
||||||
|
permanent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const riskTab = ref('ip')
|
||||||
|
const riskLoading = ref(false)
|
||||||
|
const riskIpInput = ref('')
|
||||||
|
const riskUserIdInput = ref('')
|
||||||
|
const riskResult = ref(null)
|
||||||
|
const riskResultKind = ref('')
|
||||||
|
|
||||||
|
const commonThreatTypes = [
|
||||||
|
'sql_injection',
|
||||||
|
'xss',
|
||||||
|
'path_traversal',
|
||||||
|
'command_injection',
|
||||||
|
'ssrf',
|
||||||
|
'scanner',
|
||||||
|
'bruteforce',
|
||||||
|
'csrf',
|
||||||
|
'xxe',
|
||||||
|
'file_upload',
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeCount(value) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreMeta(score) {
|
||||||
|
const n = Number(score || 0)
|
||||||
|
if (n >= 80) return { label: '高', type: 'danger' }
|
||||||
|
if (n >= 50) return { label: '中', type: 'warning' }
|
||||||
|
return { label: '低', type: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpires(expiresAt) {
|
||||||
|
const text = String(expiresAt || '').trim()
|
||||||
|
return text ? text : '永久'
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadTooltip(row) {
|
||||||
|
const parts = []
|
||||||
|
if (row?.field_name) parts.push(`字段: ${row.field_name}`)
|
||||||
|
if (row?.rule) parts.push(`规则: ${row.rule}`)
|
||||||
|
if (row?.matched) parts.push(`匹配: ${row.matched}`)
|
||||||
|
if (row?.value_preview) parts.push(`值: ${row.value_preview}`)
|
||||||
|
return parts.length ? parts.join(' · ') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathText(row) {
|
||||||
|
const method = String(row?.request_method || '').trim()
|
||||||
|
const path = String(row?.request_path || '').trim()
|
||||||
|
const combined = `${method} ${path}`.trim()
|
||||||
|
return combined || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const threatTypeOptions = computed(() => {
|
||||||
|
const seen = new Set(commonThreatTypes)
|
||||||
|
const recent = dashboard.value?.recent_threat_events || []
|
||||||
|
for (const item of recent) {
|
||||||
|
const t = String(item?.threat_type || '').trim()
|
||||||
|
if (t) seen.add(t)
|
||||||
|
}
|
||||||
|
for (const item of threatItems.value || []) {
|
||||||
|
const t = String(item?.threat_type || '').trim()
|
||||||
|
if (t) seen.add(t)
|
||||||
|
}
|
||||||
|
return Array.from(seen)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((t) => ({ label: t, value: t }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dashboardCards = computed(() => {
|
||||||
|
const d = dashboard.value || {}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'threat_events_24h',
|
||||||
|
label: '最近24小时威胁事件',
|
||||||
|
value: normalizeCount(d.threat_events_24h),
|
||||||
|
tone: 'red',
|
||||||
|
hint: '用于衡量当前攻击面活跃度',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'banned_ip_count',
|
||||||
|
label: '当前封禁 IP 数',
|
||||||
|
value: normalizeCount(d.banned_ip_count),
|
||||||
|
tone: 'orange',
|
||||||
|
hint: '自动与人工封禁总量',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'banned_user_count',
|
||||||
|
label: '当前封禁用户数',
|
||||||
|
value: normalizeCount(d.banned_user_count),
|
||||||
|
tone: 'purple',
|
||||||
|
hint: '高风险账户拦截情况',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const threatTotalPages = computed(() => Math.max(1, Math.ceil((threatTotal.value || 0) / pageSize)))
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
dashboardLoading.value = true
|
||||||
|
try {
|
||||||
|
dashboard.value = await getDashboard()
|
||||||
|
} catch {
|
||||||
|
dashboard.value = null
|
||||||
|
} finally {
|
||||||
|
dashboardLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThreats() {
|
||||||
|
threatsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: threatPage.value,
|
||||||
|
per_page: pageSize,
|
||||||
|
}
|
||||||
|
if (threatTypeFilter.value) params.event_type = threatTypeFilter.value
|
||||||
|
if (threatSeverityFilter.value) params.severity = threatSeverityFilter.value
|
||||||
|
|
||||||
|
const data = await getThreats(params)
|
||||||
|
threatItems.value = data?.items || []
|
||||||
|
threatTotal.value = data?.total || 0
|
||||||
|
} catch {
|
||||||
|
threatItems.value = []
|
||||||
|
threatTotal.value = 0
|
||||||
|
} finally {
|
||||||
|
threatsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBans() {
|
||||||
|
if (bansLoading.value) return
|
||||||
|
bansLoading.value = true
|
||||||
|
try {
|
||||||
|
const [ipsRes, usersRes] = await Promise.allSettled([getBannedIps(), getBannedUsers()])
|
||||||
|
bannedIps.value = ipsRes.status === 'fulfilled' ? ipsRes.value?.items || [] : []
|
||||||
|
bannedUsers.value = usersRes.status === 'fulfilled' ? usersRes.value?.items || [] : []
|
||||||
|
} finally {
|
||||||
|
bansLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.allSettled([loadDashboard(), loadThreats(), loadBans()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThreatFilter() {
|
||||||
|
threatPage.value = 1
|
||||||
|
loadThreats()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThreatReset() {
|
||||||
|
threatTypeFilter.value = ''
|
||||||
|
threatSeverityFilter.value = ''
|
||||||
|
threatPage.value = 1
|
||||||
|
loadThreats()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBanForm() {
|
||||||
|
banForm.value = {
|
||||||
|
kind: 'ip',
|
||||||
|
ip: '',
|
||||||
|
user_id: '',
|
||||||
|
reason: '',
|
||||||
|
duration_hours: 24,
|
||||||
|
permanent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBanDialog(kind = 'ip', preset = {}) {
|
||||||
|
resetBanForm()
|
||||||
|
banForm.value.kind = kind === 'user' ? 'user' : 'ip'
|
||||||
|
if (banForm.value.kind === 'ip') {
|
||||||
|
banForm.value.ip = String(preset.ip || '').trim()
|
||||||
|
} else {
|
||||||
|
banForm.value.user_id = String(preset.user_id || '').trim()
|
||||||
|
}
|
||||||
|
if (preset.reason) banForm.value.reason = String(preset.reason || '').trim()
|
||||||
|
banDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBan() {
|
||||||
|
const kind = banForm.value.kind
|
||||||
|
const reason = String(banForm.value.reason || '').trim()
|
||||||
|
const permanent = Boolean(banForm.value.permanent)
|
||||||
|
const durationHours = Number(banForm.value.duration_hours || 24)
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
ElMessage.error('原因不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'ip') {
|
||||||
|
const ip = String(banForm.value.ip || '').trim()
|
||||||
|
if (!ip) {
|
||||||
|
ElMessage.error('IP不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
banSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await banIp({ ip, reason, duration_hours: durationHours, permanent })
|
||||||
|
ElMessage.success('IP已封禁')
|
||||||
|
banDialogOpen.value = false
|
||||||
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
banSubmitting.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdRaw = String(banForm.value.user_id || '').trim()
|
||||||
|
const userId = Number.parseInt(userIdRaw, 10)
|
||||||
|
if (!Number.isFinite(userId)) {
|
||||||
|
ElMessage.error('用户ID无效')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
banSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await banUser({ user_id: userId, reason, duration_hours: durationHours, permanent })
|
||||||
|
ElMessage.success('用户已封禁')
|
||||||
|
banDialogOpen.value = false
|
||||||
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
banSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnbanIp(ip) {
|
||||||
|
const ipText = String(ip || '').trim()
|
||||||
|
if (!ipText) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定解除对 IP ${ipText} 的封禁吗?`, '解除封禁', {
|
||||||
|
confirmButtonText: '解除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unbanIp(ipText)
|
||||||
|
ElMessage.success('已解除IP封禁')
|
||||||
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnbanUser(userId) {
|
||||||
|
const id = Number.parseInt(String(userId || '').trim(), 10)
|
||||||
|
if (!Number.isFinite(id)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定解除对 用户ID ${id} 的封禁吗?`, '解除封禁', {
|
||||||
|
confirmButtonText: '解除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unbanUser(id)
|
||||||
|
ElMessage.success('已解除用户封禁')
|
||||||
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToIpRisk(ip) {
|
||||||
|
const ipText = String(ip || '').trim()
|
||||||
|
if (!ipText) return
|
||||||
|
activeTab.value = 'risk'
|
||||||
|
riskTab.value = 'ip'
|
||||||
|
riskIpInput.value = ipText
|
||||||
|
queryIpRisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToUserRisk(userId) {
|
||||||
|
const idText = String(userId || '').trim()
|
||||||
|
if (!idText) return
|
||||||
|
activeTab.value = 'risk'
|
||||||
|
riskTab.value = 'user'
|
||||||
|
riskUserIdInput.value = idText
|
||||||
|
queryUserRisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryIpRisk() {
|
||||||
|
const ip = String(riskIpInput.value || '').trim()
|
||||||
|
if (!ip) {
|
||||||
|
ElMessage.error('请输入IP')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
riskLoading.value = true
|
||||||
|
try {
|
||||||
|
riskResult.value = await getIpRisk(ip)
|
||||||
|
riskResultKind.value = 'ip'
|
||||||
|
} catch {
|
||||||
|
riskResult.value = null
|
||||||
|
riskResultKind.value = ''
|
||||||
|
} finally {
|
||||||
|
riskLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryUserRisk() {
|
||||||
|
const raw = String(riskUserIdInput.value || '').trim()
|
||||||
|
const userId = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(userId)) {
|
||||||
|
ElMessage.error('请输入有效的用户ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
riskLoading.value = true
|
||||||
|
try {
|
||||||
|
riskResult.value = await getUserRisk(userId)
|
||||||
|
riskResultKind.value = 'user'
|
||||||
|
} catch {
|
||||||
|
riskResult.value = null
|
||||||
|
riskResultKind.value = ''
|
||||||
|
} finally {
|
||||||
|
riskLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBanFromRisk() {
|
||||||
|
if (!riskResult.value || !riskResultKind.value) return
|
||||||
|
if (riskResultKind.value === 'ip') {
|
||||||
|
openBanDialog('ip', { ip: riskResult.value?.ip, reason: '风险查询手动封禁' })
|
||||||
|
} else {
|
||||||
|
openBanDialog('user', { user_id: riskResult.value?.user_id, reason: '风险查询手动封禁' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbanFromRisk() {
|
||||||
|
if (!riskResult.value || !riskResultKind.value) return
|
||||||
|
if (riskResultKind.value === 'ip') {
|
||||||
|
await onUnbanIp(riskResult.value?.ip)
|
||||||
|
await queryIpRisk()
|
||||||
|
} else {
|
||||||
|
await onUnbanUser(riskResult.value?.user_id)
|
||||||
|
await queryUserRisk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearIpRiskScore() {
|
||||||
|
if (riskResultKind.value !== 'ip') return
|
||||||
|
const ipText = String(riskResult.value?.ip || '').trim()
|
||||||
|
if (!ipText) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定清除 IP ${ipText} 的风险分吗?\n\n清除风险分不会删除威胁历史,也不会解除封禁。`,
|
||||||
|
'清除风险分',
|
||||||
|
{ confirmButtonText: '清除', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskLoading.value) return
|
||||||
|
riskLoading.value = true
|
||||||
|
try {
|
||||||
|
await clearIpRisk(ipText)
|
||||||
|
ElMessage.success('IP风险分已清零')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
riskLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryIpRisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupLoading = ref(false)
|
||||||
|
|
||||||
|
async function onCleanup() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定清理过期封禁记录,并衰减风险分吗?\n\n该操作不会影响仍在有效期内的封禁。',
|
||||||
|
'清理过期记录',
|
||||||
|
{ confirmButtonText: '清理', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupLoading.value = true
|
||||||
|
try {
|
||||||
|
await cleanup()
|
||||||
|
ElMessage.success('清理完成')
|
||||||
|
await refreshAll()
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
cleanupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>安全防护</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
|
||||||
|
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MetricGrid :items="dashboardCards" :loading="dashboardLoading" :min-width="220" />
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="威胁事件" name="threats">
|
||||||
|
<div class="filters">
|
||||||
|
<el-select
|
||||||
|
v-model="threatTypeFilter"
|
||||||
|
placeholder="类型"
|
||||||
|
style="width: 220px"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option v-for="t in threatTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="threatSeverityFilter" placeholder="严重程度" style="width: 200px" clearable>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="高风险(>=80)" value="high" />
|
||||||
|
<el-option label="中风险(50-79)" value="medium" />
|
||||||
|
<el-option label="低风险(<50)" value="low" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="onThreatFilter">筛选</el-button>
|
||||||
|
<el-button @click="onThreatReset">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="threatItems" v-loading="threatsLoading" style="width: 100%">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
<el-table-column label="类型" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="严重程度" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
||||||
|
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="IP" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.ip" type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
||||||
|
{{ row.ip }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="row.user_id !== null && row.user_id !== undefined"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="jumpToUserRisk(row.user_id)"
|
||||||
|
>
|
||||||
|
{{ row.user_id }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作路径" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
||||||
|
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Payload预览" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="threatPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="threatTotal"
|
||||||
|
layout="prev, pager, next, jumper, ->, total"
|
||||||
|
@current-change="loadThreats"
|
||||||
|
/>
|
||||||
|
<div class="page-hint app-muted">第 {{ threatPage }} / {{ threatTotalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="封禁管理" name="bans">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="banTab" class="inner-tabs">
|
||||||
|
<el-tab-pane label="IP黑名单" name="ips">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="bannedIps" v-loading="bansLoading" style="width: 100%">
|
||||||
|
<el-table-column label="IP" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
||||||
|
{{ row.ip || '-' }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reason" label="原因" min-width="260" />
|
||||||
|
<el-table-column label="过期时间" width="190">
|
||||||
|
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" plain @click="onUnbanIp(row.ip)">解除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="用户黑名单" name="users">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="bannedUsers" v-loading="bansLoading" style="width: 100%">
|
||||||
|
<el-table-column label="用户ID" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="jumpToUserRisk(row.user_id)">
|
||||||
|
{{ row.user_id ?? '-' }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reason" label="原因" min-width="260" />
|
||||||
|
<el-table-column label="过期时间" width="190">
|
||||||
|
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" plain @click="onUnbanUser(row.user_id)">解除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="风险查询" name="risk">
|
||||||
|
<el-tabs v-model="riskTab" class="inner-tabs">
|
||||||
|
<el-tab-pane label="IP查询" name="ip">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input v-model="riskIpInput" placeholder="输入IP,如 1.2.3.4" style="width: 260px" clearable />
|
||||||
|
<el-button type="primary" :loading="riskLoading" @click="queryIpRisk">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="用户查询" name="user">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input v-model="riskUserIdInput" placeholder="输入用户ID,如 123" style="width: 260px" clearable />
|
||||||
|
<el-button type="primary" :loading="riskLoading" @click="queryUserRisk">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-card v-if="riskResult" shadow="never" :body-style="{ padding: '16px' }" class="sub-card">
|
||||||
|
<div class="risk-head">
|
||||||
|
<div class="risk-title">
|
||||||
|
<strong v-if="riskResultKind === 'ip'">IP: {{ riskResult.ip }}</strong>
|
||||||
|
<strong v-else>用户ID: {{ riskResult.user_id }}</strong>
|
||||||
|
<span class="app-muted">风险分</span>
|
||||||
|
<el-tag :type="scoreMeta(riskResult.risk_score).type" effect="light">
|
||||||
|
{{ riskResult.risk_score ?? 0 }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="riskResult.is_banned" type="danger" effect="light">已封禁</el-tag>
|
||||||
|
<el-tag v-else type="success" effect="light">未封禁</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button v-if="!riskResult.is_banned" type="primary" plain @click="openBanFromRisk">封禁</el-button>
|
||||||
|
<el-button v-else type="danger" plain @click="unbanFromRisk">解除封禁</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="riskResultKind === 'ip'"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:loading="riskLoading"
|
||||||
|
@click="clearIpRiskScore"
|
||||||
|
>
|
||||||
|
清除风险分
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="riskResult.threat_history || []" v-loading="riskLoading" style="width: 100%">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
<el-table-column label="类型" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="严重程度" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
||||||
|
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作路径" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
||||||
|
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Payload预览" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
||||||
|
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="banDialogOpen" title="手动封禁" width="min(520px, 92vw)" @closed="resetBanForm">
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-radio-group v-model="banForm.kind">
|
||||||
|
<el-radio-button label="ip">IP</el-radio-button>
|
||||||
|
<el-radio-button label="user">用户</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="banForm.kind === 'ip'" label="IP">
|
||||||
|
<el-input v-model="banForm.ip" placeholder="例如 1.2.3.4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="用户ID">
|
||||||
|
<el-input v-model="banForm.user_id" placeholder="例如 123" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="原因">
|
||||||
|
<el-input v-model="banForm.reason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="永久封禁">
|
||||||
|
<el-switch v-model="banForm.permanent" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="!banForm.permanent" label="持续(小时)">
|
||||||
|
<el-input-number v-model="banForm.duration_hours" :min="1" :max="8760" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<el-button @click="banDialogOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="banSubmitting" @click="submitBan">确认封禁</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-tabs {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
368
admin-frontend/src/pages/SettingsPage.vue
Normal file
368
admin-frontend/src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAdminPasskeyOptions,
|
||||||
|
createAdminPasskeyVerify,
|
||||||
|
deleteAdminPasskey,
|
||||||
|
fetchAdminPasskeys,
|
||||||
|
logout,
|
||||||
|
reportAdminPasskeyClientError,
|
||||||
|
updateAdminPassword,
|
||||||
|
updateAdminUsername,
|
||||||
|
} from '../api/admin'
|
||||||
|
import { createPasskey, getPasskeyClientErrorMessage, isPasskeyAvailable } from '../utils/passkey'
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const passkeyLoading = ref(false)
|
||||||
|
const passkeyAddLoading = ref(false)
|
||||||
|
const passkeyDeviceName = ref('')
|
||||||
|
const passkeyItems = ref([])
|
||||||
|
const passkeyRegisterOptions = ref(null)
|
||||||
|
const passkeyRegisterOptionsAt = ref(0)
|
||||||
|
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
|
||||||
|
|
||||||
|
function validateStrongPassword(value) {
|
||||||
|
const text = String(value || '')
|
||||||
|
if (text.length < 8) return { ok: false, message: '密码长度至少8位' }
|
||||||
|
if (text.length > 128) return { ok: false, message: '密码长度不能超过128个字符' }
|
||||||
|
if (!/[a-zA-Z]/.test(text) || !/\d/.test(text)) return { ok: false, message: '密码必须包含字母和数字' }
|
||||||
|
return { ok: true, message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relogin() {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
window.location.href = '/yuyx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUsername() {
|
||||||
|
const value = username.value.trim()
|
||||||
|
if (!value) {
|
||||||
|
ElMessage.error('请输入新用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定将管理员用户名修改为「${value}」吗?修改后需要重新登录。`, '修改用户名', {
|
||||||
|
confirmButtonText: '确认修改',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminUsername(value)
|
||||||
|
ElMessage.success('用户名修改成功,请重新登录')
|
||||||
|
username.value = ''
|
||||||
|
setTimeout(relogin, 1200)
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
const currentValue = currentPassword.value
|
||||||
|
const value = password.value
|
||||||
|
const confirmValue = confirmPassword.value
|
||||||
|
|
||||||
|
if (!currentValue) {
|
||||||
|
ElMessage.error('请输入当前密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
ElMessage.error('请输入新密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = validateStrongPassword(value)
|
||||||
|
if (!check.ok) {
|
||||||
|
ElMessage.error(check.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== confirmValue) {
|
||||||
|
ElMessage.error('两次输入的新密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定修改管理员密码吗?修改后需要重新登录。', '修改密码', {
|
||||||
|
confirmButtonText: '确认修改',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminPassword({ currentPassword: currentValue, newPassword: value })
|
||||||
|
ElMessage.success('密码修改成功,请重新登录')
|
||||||
|
currentPassword.value = ''
|
||||||
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
setTimeout(relogin, 1200)
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPasskeys() {
|
||||||
|
passkeyLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchAdminPasskeys()
|
||||||
|
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
|
||||||
|
if (passkeyItems.value.length < 3) {
|
||||||
|
await prefetchPasskeyRegisterOptions()
|
||||||
|
} else {
|
||||||
|
passkeyRegisterOptions.value = null
|
||||||
|
passkeyRegisterOptionsAt.value = 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
passkeyItems.value = []
|
||||||
|
passkeyRegisterOptions.value = null
|
||||||
|
passkeyRegisterOptionsAt.value = 0
|
||||||
|
} finally {
|
||||||
|
passkeyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedPasskeyRegisterOptions() {
|
||||||
|
if (!passkeyRegisterOptions.value) return null
|
||||||
|
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
|
||||||
|
return passkeyRegisterOptions.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetchPasskeyRegisterOptions() {
|
||||||
|
try {
|
||||||
|
const res = await createAdminPasskeyOptions({})
|
||||||
|
passkeyRegisterOptions.value = res
|
||||||
|
passkeyRegisterOptionsAt.value = Date.now()
|
||||||
|
} catch {
|
||||||
|
passkeyRegisterOptions.value = null
|
||||||
|
passkeyRegisterOptionsAt.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPasskey() {
|
||||||
|
if (!isPasskeyAvailable()) {
|
||||||
|
ElMessage.error('当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (passkeyItems.value.length >= 3) {
|
||||||
|
ElMessage.error('最多可绑定3台设备')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeyAddLoading.value = true
|
||||||
|
try {
|
||||||
|
let optionsRes = getCachedPasskeyRegisterOptions()
|
||||||
|
if (!optionsRes) {
|
||||||
|
optionsRes = await createAdminPasskeyOptions({})
|
||||||
|
}
|
||||||
|
const credential = await createPasskey(optionsRes?.publicKey || {})
|
||||||
|
await createAdminPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
|
||||||
|
passkeyRegisterOptions.value = null
|
||||||
|
passkeyRegisterOptionsAt.value = 0
|
||||||
|
passkeyDeviceName.value = ''
|
||||||
|
ElMessage.success('Passkey设备添加成功')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await reportAdminPasskeyClientError({
|
||||||
|
stage: 'register',
|
||||||
|
source: 'admin-settings',
|
||||||
|
name: e?.name || '',
|
||||||
|
message: e?.message || '',
|
||||||
|
code: e?.code || '',
|
||||||
|
user_agent: navigator.userAgent || '',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore report failure
|
||||||
|
}
|
||||||
|
passkeyRegisterOptions.value = null
|
||||||
|
passkeyRegisterOptionsAt.value = 0
|
||||||
|
await prefetchPasskeyRegisterOptions()
|
||||||
|
const data = e?.response?.data
|
||||||
|
const message =
|
||||||
|
data?.error ||
|
||||||
|
getPasskeyClientErrorMessage(e, 'Passkey注册')
|
||||||
|
ElMessage.error(message)
|
||||||
|
} finally {
|
||||||
|
passkeyAddLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePasskey(item) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAdminPasskey(item.id)
|
||||||
|
ElMessage.success('设备已删除')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPasskeys()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>设置</h2>
|
||||||
|
<span class="app-muted">管理员账号设置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<h3 class="section-title">修改管理员用户名</h3>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="新用户名">
|
||||||
|
<el-input v-model="username" placeholder="输入新用户名" :disabled="submitting" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="saveUsername">保存用户名</el-button>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<h3 class="section-title">修改管理员密码</h3>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="当前密码">
|
||||||
|
<el-input
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="输入当前密码"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="输入新密码"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="确认新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="savePassword">保存密码</el-button>
|
||||||
|
<div class="help">建议使用更强密码(至少8位且包含字母与数字)。</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<h3 class="section-title">Passkey设备</h3>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="最多可绑定3台设备,可用于管理员无密码登录。"
|
||||||
|
show-icon
|
||||||
|
class="help-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form inline>
|
||||||
|
<el-form-item label="设备备注">
|
||||||
|
<el-input
|
||||||
|
v-model="passkeyDeviceName"
|
||||||
|
placeholder="例如:值班iPhone / 办公Mac"
|
||||||
|
maxlength="40"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="passkeyAddLoading" @click="addPasskey">添加Passkey设备</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div v-loading="passkeyLoading">
|
||||||
|
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
|
||||||
|
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
|
||||||
|
<el-table-column prop="device_name" label="设备备注" min-width="160" />
|
||||||
|
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
|
||||||
|
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="140" />
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" text @click="removePasskey(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
759
admin-frontend/src/pages/SystemPage.vue
Normal file
759
admin-frontend/src/pages/SystemPage.vue
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { fetchSystemConfig, updateSystemConfig } from '../api/system'
|
||||||
|
import { fetchKdocsQr, fetchKdocsStatus, clearKdocsLogin } from '../api/kdocs'
|
||||||
|
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
|
||||||
|
import { getCachedKdocsStatus, preloadKdocsStatus, updateCachedKdocsStatus } from '../utils/kdocsStatusCache'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const maxConcurrentGlobal = ref(2)
|
||||||
|
const maxConcurrentPerAccount = ref(1)
|
||||||
|
const maxScreenshotConcurrent = ref(3)
|
||||||
|
const dbSlowQueryMs = ref(120)
|
||||||
|
|
||||||
|
const proxyEnabled = ref(false)
|
||||||
|
const proxyApiUrl = ref('')
|
||||||
|
const proxyExpireMinutes = ref(3)
|
||||||
|
|
||||||
|
const autoApproveEnabled = ref(false)
|
||||||
|
const autoApproveHourlyLimit = ref(10)
|
||||||
|
const autoApproveVipDays = ref(7)
|
||||||
|
|
||||||
|
const kdocsEnabled = ref(false)
|
||||||
|
const kdocsDocUrl = ref('')
|
||||||
|
const kdocsDefaultUnit = ref('')
|
||||||
|
const kdocsSheetName = ref('')
|
||||||
|
const kdocsSheetIndex = ref(0)
|
||||||
|
const kdocsUnitColumn = ref('A')
|
||||||
|
const kdocsImageColumn = ref('D')
|
||||||
|
const kdocsRowStart = ref(0)
|
||||||
|
const kdocsRowEnd = ref(0)
|
||||||
|
const kdocsAdminNotifyEnabled = ref(false)
|
||||||
|
const kdocsAdminNotifyEmail = ref('')
|
||||||
|
|
||||||
|
const initialKdocsStatus = getCachedKdocsStatus({ maxAgeMs: 10 * 60 * 1000 })
|
||||||
|
const kdocsStatus = ref(initialKdocsStatus || {})
|
||||||
|
const kdocsQrOpen = ref(false)
|
||||||
|
const kdocsQrImage = ref('')
|
||||||
|
const kdocsPolling = ref(false)
|
||||||
|
const kdocsStatusLoading = ref(false)
|
||||||
|
const kdocsQrLoading = ref(false)
|
||||||
|
const kdocsClearLoading = ref(false)
|
||||||
|
const kdocsSilentRefreshing = ref(!initialKdocsStatus)
|
||||||
|
const kdocsActionHint = ref('')
|
||||||
|
let kdocsPollingTimer = null
|
||||||
|
|
||||||
|
const kdocsActionBusy = computed(
|
||||||
|
() => kdocsStatusLoading.value || kdocsQrLoading.value || kdocsClearLoading.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const kdocsDetecting = computed(
|
||||||
|
() => kdocsSilentRefreshing.value || kdocsStatusLoading.value || kdocsPolling.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const kdocsStatusText = computed(() => {
|
||||||
|
if (kdocsDetecting.value) return '检测中'
|
||||||
|
const status = kdocsStatus.value || {}
|
||||||
|
if (status?.logged_in === true || status?.last_login_ok === true) return '已登录'
|
||||||
|
if (status?.logged_in === false || status?.last_login_ok === false || status?.login_required === true) return '未登录'
|
||||||
|
if (status?.last_error) return '异常'
|
||||||
|
return '未知'
|
||||||
|
})
|
||||||
|
|
||||||
|
const kdocsStatusClass = computed(() => {
|
||||||
|
if (kdocsDetecting.value) return 'is-checking'
|
||||||
|
if (kdocsStatusText.value === '已登录') return 'is-online'
|
||||||
|
if (kdocsStatusText.value === '未登录') return 'is-offline'
|
||||||
|
if (kdocsStatusText.value === '异常') return 'is-error'
|
||||||
|
return 'is-unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
function setKdocsHint(message) {
|
||||||
|
if (!message) {
|
||||||
|
kdocsActionHint.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
kdocsActionHint.value = `${message} (${time})`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [system, proxy] = await Promise.all([
|
||||||
|
fetchSystemConfig(),
|
||||||
|
fetchProxyConfig(),
|
||||||
|
])
|
||||||
|
|
||||||
|
maxConcurrentGlobal.value = system.max_concurrent_global ?? 2
|
||||||
|
maxConcurrentPerAccount.value = system.max_concurrent_per_account ?? 1
|
||||||
|
maxScreenshotConcurrent.value = system.max_screenshot_concurrent ?? 3
|
||||||
|
dbSlowQueryMs.value = system.db_slow_query_ms ?? 120
|
||||||
|
|
||||||
|
autoApproveEnabled.value = (system.auto_approve_enabled ?? 0) === 1
|
||||||
|
autoApproveHourlyLimit.value = system.auto_approve_hourly_limit ?? 10
|
||||||
|
autoApproveVipDays.value = system.auto_approve_vip_days ?? 7
|
||||||
|
|
||||||
|
proxyEnabled.value = (proxy.proxy_enabled ?? 0) === 1
|
||||||
|
proxyApiUrl.value = proxy.proxy_api_url || ''
|
||||||
|
proxyExpireMinutes.value = proxy.proxy_expire_minutes ?? 3
|
||||||
|
|
||||||
|
kdocsEnabled.value = (system.kdocs_enabled ?? 0) === 1
|
||||||
|
kdocsDocUrl.value = system.kdocs_doc_url || ''
|
||||||
|
kdocsDefaultUnit.value = system.kdocs_default_unit || ''
|
||||||
|
kdocsSheetName.value = system.kdocs_sheet_name || ''
|
||||||
|
kdocsSheetIndex.value = system.kdocs_sheet_index ?? 0
|
||||||
|
kdocsUnitColumn.value = (system.kdocs_unit_column || 'A').toUpperCase()
|
||||||
|
kdocsImageColumn.value = (system.kdocs_image_column || 'D').toUpperCase()
|
||||||
|
kdocsRowStart.value = system.kdocs_row_start ?? 0
|
||||||
|
kdocsRowEnd.value = system.kdocs_row_end ?? 0
|
||||||
|
kdocsAdminNotifyEnabled.value = (system.kdocs_admin_notify_enabled ?? 0) === 1
|
||||||
|
kdocsAdminNotifyEmail.value = system.kdocs_admin_notify_email || ''
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedStatus = getCachedKdocsStatus({ maxAgeMs: 10 * 60 * 1000 })
|
||||||
|
if (cachedStatus) {
|
||||||
|
kdocsStatus.value = cachedStatus
|
||||||
|
kdocsSilentRefreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静默刷新金山登录状态,确保状态持续更新且不阻塞首屏。
|
||||||
|
void refreshKdocsStatusSilently()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshKdocsStatusSilently() {
|
||||||
|
if (kdocsSilentRefreshing.value || kdocsStatusLoading.value) return
|
||||||
|
kdocsSilentRefreshing.value = true
|
||||||
|
try {
|
||||||
|
const status = await preloadKdocsStatus({
|
||||||
|
force: false,
|
||||||
|
maxAgeMs: 60_000,
|
||||||
|
silent: true,
|
||||||
|
live: 0,
|
||||||
|
})
|
||||||
|
kdocsStatus.value = status || {}
|
||||||
|
} catch {
|
||||||
|
// silent mode
|
||||||
|
} finally {
|
||||||
|
kdocsSilentRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConcurrency() {
|
||||||
|
const payload = {
|
||||||
|
max_concurrent_global: Number(maxConcurrentGlobal.value),
|
||||||
|
max_concurrent_per_account: Number(maxConcurrentPerAccount.value),
|
||||||
|
max_screenshot_concurrent: Number(maxScreenshotConcurrent.value),
|
||||||
|
db_slow_query_ms: Number(dbSlowQueryMs.value),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定更新并发配置吗?\n\n全局并发数: ${payload.max_concurrent_global}\n单账号并发数: ${payload.max_concurrent_per_account}\n截图并发数: ${payload.max_screenshot_concurrent}\n慢 SQL 阈值: ${payload.db_slow_query_ms}ms`,
|
||||||
|
'保存并发配置',
|
||||||
|
{ confirmButtonText: '保存', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateSystemConfig(payload)
|
||||||
|
ElMessage.success(res?.message || '并发配置已更新')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProxy() {
|
||||||
|
if (proxyEnabled.value && !proxyApiUrl.value.trim()) {
|
||||||
|
ElMessage.error('启用代理时,API地址不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
proxy_enabled: proxyEnabled.value ? 1 : 0,
|
||||||
|
proxy_api_url: proxyApiUrl.value.trim(),
|
||||||
|
proxy_expire_minutes: Number(proxyExpireMinutes.value) || 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateProxyConfig(payload)
|
||||||
|
ElMessage.success(res?.message || '代理配置已更新')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestProxy() {
|
||||||
|
if (!proxyApiUrl.value.trim()) {
|
||||||
|
ElMessage.error('请先输入代理API地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await testProxy({ api_url: proxyApiUrl.value.trim() })
|
||||||
|
await ElMessageBox.alert(res?.message || '测试完成', '代理测试', { confirmButtonText: '知道了' })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAutoApprove() {
|
||||||
|
const hourly = Number(autoApproveHourlyLimit.value)
|
||||||
|
const vipDays = Number(autoApproveVipDays.value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(hourly) || hourly < 1) {
|
||||||
|
ElMessage.error('每小时注册限制必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(vipDays) || vipDays < 0) {
|
||||||
|
ElMessage.error('VIP天数不能为负数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
auto_approve_enabled: autoApproveEnabled.value ? 1 : 0,
|
||||||
|
auto_approve_hourly_limit: hourly,
|
||||||
|
auto_approve_vip_days: vipDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateSystemConfig(payload)
|
||||||
|
ElMessage.success(res?.message || '注册设置已保存')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveKdocsConfig() {
|
||||||
|
const payload = {
|
||||||
|
kdocs_enabled: kdocsEnabled.value ? 1 : 0,
|
||||||
|
kdocs_doc_url: kdocsDocUrl.value.trim(),
|
||||||
|
kdocs_default_unit: kdocsDefaultUnit.value.trim(),
|
||||||
|
kdocs_sheet_name: kdocsSheetName.value.trim(),
|
||||||
|
kdocs_sheet_index: Number(kdocsSheetIndex.value) || 0,
|
||||||
|
kdocs_unit_column: kdocsUnitColumn.value.trim().toUpperCase(),
|
||||||
|
kdocs_image_column: kdocsImageColumn.value.trim().toUpperCase(),
|
||||||
|
kdocs_row_start: Number(kdocsRowStart.value) || 0,
|
||||||
|
kdocs_row_end: Number(kdocsRowEnd.value) || 0,
|
||||||
|
kdocs_admin_notify_enabled: kdocsAdminNotifyEnabled.value ? 1 : 0,
|
||||||
|
kdocs_admin_notify_email: kdocsAdminNotifyEmail.value.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateSystemConfig(payload)
|
||||||
|
ElMessage.success(res?.message || '表格配置已更新')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshKdocsStatus() {
|
||||||
|
if (kdocsStatusLoading.value) return
|
||||||
|
kdocsStatusLoading.value = true
|
||||||
|
setKdocsHint('正在刷新状态')
|
||||||
|
try {
|
||||||
|
const status = await fetchKdocsStatus({ live: 1 })
|
||||||
|
kdocsStatus.value = status || {}
|
||||||
|
updateCachedKdocsStatus(kdocsStatus.value)
|
||||||
|
setKdocsHint('状态已刷新')
|
||||||
|
} catch {
|
||||||
|
setKdocsHint('刷新失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
kdocsStatusLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollKdocsStatus() {
|
||||||
|
try {
|
||||||
|
const status = await fetchKdocsStatus({ live: 1 })
|
||||||
|
kdocsStatus.value = status || {}
|
||||||
|
updateCachedKdocsStatus(kdocsStatus.value)
|
||||||
|
const loggedIn = status?.logged_in === true || status?.last_login_ok === true
|
||||||
|
if (loggedIn) {
|
||||||
|
ElMessage.success('扫码成功,已登录')
|
||||||
|
setKdocsHint('扫码成功,已登录')
|
||||||
|
kdocsQrOpen.value = false
|
||||||
|
stopKdocsPolling()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startKdocsPolling() {
|
||||||
|
stopKdocsPolling()
|
||||||
|
kdocsPolling.value = true
|
||||||
|
setKdocsHint('扫码检测中')
|
||||||
|
pollKdocsStatus()
|
||||||
|
kdocsPollingTimer = setInterval(pollKdocsStatus, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopKdocsPolling() {
|
||||||
|
if (kdocsPollingTimer) {
|
||||||
|
clearInterval(kdocsPollingTimer)
|
||||||
|
kdocsPollingTimer = null
|
||||||
|
}
|
||||||
|
kdocsPolling.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFetchKdocsQr() {
|
||||||
|
if (kdocsQrLoading.value) return
|
||||||
|
kdocsQrLoading.value = true
|
||||||
|
setKdocsHint('正在获取二维码')
|
||||||
|
try {
|
||||||
|
kdocsQrImage.value = ''
|
||||||
|
const res = await fetchKdocsQr()
|
||||||
|
kdocsQrImage.value = res?.qr_image || ''
|
||||||
|
if (!kdocsQrImage.value) {
|
||||||
|
if (res?.logged_in) {
|
||||||
|
ElMessage.success('当前已登录,无需扫码')
|
||||||
|
setKdocsHint('当前已登录,无需扫码')
|
||||||
|
await refreshKdocsStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.warning('未获取到二维码')
|
||||||
|
setKdocsHint('未获取到二维码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setKdocsHint('二维码已获取')
|
||||||
|
kdocsQrOpen.value = true
|
||||||
|
} catch {
|
||||||
|
setKdocsHint('获取二维码失败')
|
||||||
|
} finally {
|
||||||
|
kdocsQrLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearKdocsLogin() {
|
||||||
|
if (kdocsClearLoading.value) return
|
||||||
|
kdocsClearLoading.value = true
|
||||||
|
setKdocsHint('正在清除登录态')
|
||||||
|
try {
|
||||||
|
await clearKdocsLogin()
|
||||||
|
kdocsQrOpen.value = false
|
||||||
|
kdocsQrImage.value = ''
|
||||||
|
kdocsStatus.value = updateCachedKdocsStatus({
|
||||||
|
...(kdocsStatus.value || {}),
|
||||||
|
logged_in: false,
|
||||||
|
last_login_ok: false,
|
||||||
|
login_required: true,
|
||||||
|
})
|
||||||
|
ElMessage.success('登录态已清除')
|
||||||
|
setKdocsHint('登录态已清除')
|
||||||
|
await refreshKdocsStatus()
|
||||||
|
} catch {
|
||||||
|
setKdocsHint('清除登录态失败')
|
||||||
|
} finally {
|
||||||
|
kdocsClearLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(kdocsQrOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
startKdocsPolling()
|
||||||
|
} else {
|
||||||
|
stopKdocsPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopKdocsPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack" v-loading="loading">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>系统配置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-grid">
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
|
||||||
|
<h3 class="section-title">并发配置</h3>
|
||||||
|
<div class="section-sub app-muted">控制任务与截图的并发资源上限</div>
|
||||||
|
|
||||||
|
<el-form label-width="122px">
|
||||||
|
<el-form-item label="全局最大并发数">
|
||||||
|
<el-input-number v-model="maxConcurrentGlobal" :min="1" :max="200" />
|
||||||
|
<div class="help">同时最多运行账号数(浏览任务 API 执行,资源占用较低)。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="单账号最大并发数">
|
||||||
|
<el-input-number v-model="maxConcurrentPerAccount" :min="1" :max="50" />
|
||||||
|
<div class="help">建议保持为 1,避免同账号任务抢占。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="截图最大并发数">
|
||||||
|
<el-input-number v-model="maxScreenshotConcurrent" :min="1" :max="50" />
|
||||||
|
<div class="help">截图资源占用较低,可按机器性能逐步提高。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="慢 SQL 阈值(ms)">
|
||||||
|
<el-input-number v-model="dbSlowQueryMs" :min="0" :max="60000" />
|
||||||
|
<div class="help">低于该阈值不会计入慢 SQL(0 表示关闭慢 SQL 采样)。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button type="primary" @click="saveConcurrency">保存并发配置</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
|
||||||
|
<h3 class="section-title">代理设置</h3>
|
||||||
|
<div class="section-sub app-muted">用于任务出网代理与连接有效期管理</div>
|
||||||
|
|
||||||
|
<el-form label-width="122px">
|
||||||
|
<el-form-item label="启用 IP 代理">
|
||||||
|
<el-switch v-model="proxyEnabled" />
|
||||||
|
<div class="help">开启后,浏览任务通过代理访问,失败自动重试。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="代理 API 地址">
|
||||||
|
<el-input v-model="proxyApiUrl" placeholder="http://api.xxx/Tools/IP.ashx?..." />
|
||||||
|
<div class="help">API 应返回 `IP:PORT`(例:123.45.67.89:8888)。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="有效期(分钟)">
|
||||||
|
<el-input-number v-model="proxyExpireMinutes" :min="1" :max="60" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button type="primary" @click="saveProxy">保存代理配置</el-button>
|
||||||
|
<el-button @click="onTestProxy">测试代理</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
|
||||||
|
<h3 class="section-title">注册设置</h3>
|
||||||
|
<div class="section-sub app-muted">控制注册节流与新用户赠送 VIP</div>
|
||||||
|
|
||||||
|
<el-form label-width="122px">
|
||||||
|
<el-form-item label="注册赠送 VIP">
|
||||||
|
<el-switch v-model="autoApproveEnabled" />
|
||||||
|
<div class="help">开启后,新用户注册成功自动赠送下方设定的 VIP 天数。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="每小时注册限制">
|
||||||
|
<el-input-number v-model="autoApproveHourlyLimit" :min="1" :max="10000" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="赠送 VIP 天数">
|
||||||
|
<el-input-number v-model="autoApproveVipDays" :min="0" :max="999999" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button type="primary" @click="saveAutoApprove">保存注册设置</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card kdocs-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3 class="section-title">金山文档上传</h3>
|
||||||
|
<div class="status-inline app-muted">
|
||||||
|
<span>登录状态:</span>
|
||||||
|
<span class="status-chip" :class="kdocsStatusClass">
|
||||||
|
{{ kdocsStatusText }}
|
||||||
|
<span v-if="kdocsDetecting" class="status-dots" aria-hidden="true">
|
||||||
|
<i></i><i></i><i></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>· 待上传 {{ kdocsStatus.queue_size || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="118px" class="kdocs-form">
|
||||||
|
<el-form-item label="启用上传">
|
||||||
|
<el-switch v-model="kdocsEnabled" />
|
||||||
|
<div class="help">表格结构变化时可先关闭,避免错误上传。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="文档链接">
|
||||||
|
<el-input v-model="kdocsDocUrl" placeholder="https://kdocs.cn/..." />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="默认县区">
|
||||||
|
<el-input v-model="kdocsDefaultUnit" placeholder="如:道县(用户可覆盖)" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Sheet 名称">
|
||||||
|
<el-input v-model="kdocsSheetName" placeholder="留空使用第一个 Sheet" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Sheet 序号">
|
||||||
|
<el-input-number v-model="kdocsSheetIndex" :min="0" :max="50" />
|
||||||
|
<div class="help">0 表示第一个 Sheet。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="列配置">
|
||||||
|
<div class="kdocs-inline">
|
||||||
|
<el-input v-model="kdocsUnitColumn" placeholder="县区列,如 A" />
|
||||||
|
<el-input v-model="kdocsImageColumn" placeholder="图片列,如 D" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="有效行范围">
|
||||||
|
<div class="kdocs-range">
|
||||||
|
<el-input-number v-model="kdocsRowStart" :min="0" :max="10000" placeholder="起始行" style="width: 140px" />
|
||||||
|
<span class="app-muted">至</span>
|
||||||
|
<el-input-number v-model="kdocsRowEnd" :min="0" :max="10000" placeholder="结束行" style="width: 140px" />
|
||||||
|
</div>
|
||||||
|
<div class="help">用于限制上传区间(如 50-100),0 表示不限制。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="管理员通知">
|
||||||
|
<el-switch v-model="kdocsAdminNotifyEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="通知邮箱">
|
||||||
|
<el-input v-model="kdocsAdminNotifyEmail" placeholder="admin@example.com" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button type="primary" @click="saveKdocsConfig">保存表格配置</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
:loading="kdocsQrLoading"
|
||||||
|
:disabled="kdocsActionBusy && !kdocsQrLoading"
|
||||||
|
@click="onFetchKdocsQr"
|
||||||
|
>
|
||||||
|
获取二维码
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:loading="kdocsClearLoading"
|
||||||
|
:disabled="kdocsActionBusy && !kdocsClearLoading"
|
||||||
|
@click="onClearKdocsLogin"
|
||||||
|
>
|
||||||
|
清除登录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="kdocsStatus.last_error" class="help">最近错误:{{ kdocsStatus.last_error }}</div>
|
||||||
|
<div v-if="kdocsActionHint" class="help">操作提示:{{ kdocsActionHint }}</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="kdocsQrOpen" title="扫码登录" width="min(420px, 92vw)">
|
||||||
|
<div class="kdocs-qr">
|
||||||
|
<img v-if="kdocsQrImage" :src="`data:image/png;base64,${kdocsQrImage}`" alt="KDocs QR" />
|
||||||
|
<div class="help">请使用管理员微信扫码登录。</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inline {
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.is-checking {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.is-online {
|
||||||
|
color: #065f46;
|
||||||
|
background: #d1fae5;
|
||||||
|
border-color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.is-offline {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.is-error {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.is-unknown {
|
||||||
|
color: #374151;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots i {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.25;
|
||||||
|
animation: dotPulse 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots i:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots i:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-form {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-inline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-qr {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-qr img {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.config-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.config-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-inline {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kdocs-range {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
342
admin-frontend/src/pages/UsersPage.vue
Normal file
342
admin-frontend/src/pages/UsersPage.vue
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup>
|
||||||
|
import { inject, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
adminResetUserPassword,
|
||||||
|
deleteUser,
|
||||||
|
fetchAllUsers,
|
||||||
|
approveUser,
|
||||||
|
rejectUser,
|
||||||
|
removeUserVip,
|
||||||
|
setUserVip,
|
||||||
|
} from '../api/users'
|
||||||
|
import { parseSqliteDateTime } from '../utils/datetime'
|
||||||
|
import { validatePasswordStrength } from '../utils/password'
|
||||||
|
|
||||||
|
const refreshStats = inject('refreshStats', null)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const users = ref([])
|
||||||
|
|
||||||
|
function isVip(user) {
|
||||||
|
const expire = user?.vip_expire_time
|
||||||
|
if (!expire) return false
|
||||||
|
if (String(expire).startsWith('2099-12-31')) return true
|
||||||
|
const dt = parseSqliteDateTime(expire)
|
||||||
|
return dt ? dt.getTime() > Date.now() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function vipLabel(user) {
|
||||||
|
const expire = user?.vip_expire_time
|
||||||
|
if (!expire || !isVip(user)) return ''
|
||||||
|
if (String(expire).startsWith('2099-12-31')) return '永久VIP'
|
||||||
|
const dt = parseSqliteDateTime(expire)
|
||||||
|
if (!dt) return `到期: ${expire}`
|
||||||
|
const daysLeft = Math.ceil((dt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
|
return `到期: ${expire}(剩${daysLeft}天)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status) {
|
||||||
|
if (status === 'rejected') return { label: '禁用', type: 'danger' }
|
||||||
|
return { label: '正常', type: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
users.value = await fetchAllUsers()
|
||||||
|
} catch {
|
||||||
|
users.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEnableUser(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定启用用户「${row.username}」吗?启用后用户可正常登录。`, '启用用户', {
|
||||||
|
confirmButtonText: '启用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await approveUser(row.id)
|
||||||
|
ElMessage.success('用户已启用')
|
||||||
|
await loadUsers()
|
||||||
|
await refreshStats?.({ force: true })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisableUser(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定禁用用户「${row.username}」吗?禁用后用户将无法登录。`, '禁用用户', {
|
||||||
|
confirmButtonText: '禁用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rejectUser(row.id)
|
||||||
|
ElMessage.success('用户已禁用')
|
||||||
|
await loadUsers()
|
||||||
|
await refreshStats?.({ force: true })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除用户「${row.username}」吗?此操作将删除该用户的所有数据,不可恢复!`,
|
||||||
|
'删除用户',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUser(row.id)
|
||||||
|
ElMessage.success('用户已删除')
|
||||||
|
await loadUsers()
|
||||||
|
await refreshStats?.({ force: true })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSetVip(row, days) {
|
||||||
|
const label = { 7: '一周', 30: '一个月', 365: '一年', 999999: '永久' }[days] || `${days}天`
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定为用户「${row.username}」开通 ${label} VIP 吗?`, '设置VIP', {
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await setUserVip(row.id, days)
|
||||||
|
ElMessage.success(res?.message || 'VIP设置成功')
|
||||||
|
await loadUsers()
|
||||||
|
await refreshStats?.({ force: true })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoveVip(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定移除用户「${row.username}」的 VIP 吗?`, '移除VIP', {
|
||||||
|
confirmButtonText: '移除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await removeUserVip(row.id)
|
||||||
|
ElMessage.success(res?.message || 'VIP已移除')
|
||||||
|
await loadUsers()
|
||||||
|
await refreshStats?.({ force: true })
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResetPassword(row) {
|
||||||
|
let value
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt('请输入新密码(至少8位且包含字母和数字)', '重置密码', {
|
||||||
|
confirmButtonText: '提交',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'password',
|
||||||
|
inputPlaceholder: '新密码',
|
||||||
|
inputValidator: (v) => validatePasswordStrength(v).ok,
|
||||||
|
inputErrorMessage: '密码至少8位且包含字母和数字',
|
||||||
|
})
|
||||||
|
value = result.value
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = validatePasswordStrength(value)
|
||||||
|
if (!check.ok) {
|
||||||
|
ElMessage.error(check.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定将用户「${row.username}」的密码重置为该新密码吗?`, '二次确认', {
|
||||||
|
confirmButtonText: '确认重置',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminResetUserPassword(row.id, value)
|
||||||
|
ElMessage.success(res?.message || '密码重置成功')
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack">
|
||||||
|
<div class="app-page-title">
|
||||||
|
<h2>用户</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
|
||||||
|
<el-table-column label="用户" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="user-block">
|
||||||
|
<div class="user-main">
|
||||||
|
<strong>{{ row.username }}</strong>
|
||||||
|
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.email" class="app-muted user-sub">{{ row.email }}</div>
|
||||||
|
<div v-if="vipLabel(row)" class="vip-sub">{{ vipLabel(row) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="时间" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.created_at }}</div>
|
||||||
|
<div v-if="row.vip_expire_time" class="app-muted">VIP到期: {{ row.vip_expire_time }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="actions">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'rejected'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="onEnableUser(row)"
|
||||||
|
>启用</el-button>
|
||||||
|
<el-button v-else type="warning" size="small" @click="onDisableUser(row)">禁用</el-button>
|
||||||
|
|
||||||
|
<el-dropdown trigger="click">
|
||||||
|
<el-button size="small">VIP</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 7)">开通一周</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 30)">开通一月</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 365)">开通一年</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 999999)">永久VIP</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="isVip(row)" @click="onRemoveVip(row)">移除VIP</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
|
<el-button size="small" @click="onResetPassword(row)">重置密码</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
admin-frontend/src/router/index.js
Normal file
41
admin-frontend/src/router/index.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminLayout from '../layouts/AdminLayout.vue'
|
||||||
|
|
||||||
|
const ReportPage = () => import('../pages/ReportPage.vue')
|
||||||
|
const UsersPage = () => import('../pages/UsersPage.vue')
|
||||||
|
const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
|
||||||
|
const LogsPage = () => import('../pages/LogsPage.vue')
|
||||||
|
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
|
||||||
|
const EmailPage = () => import('../pages/EmailPage.vue')
|
||||||
|
const SecurityPage = () => import('../pages/SecurityPage.vue')
|
||||||
|
const SystemPage = () => import('../pages/SystemPage.vue')
|
||||||
|
const SettingsPage = () => import('../pages/SettingsPage.vue')
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AdminLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/reports' },
|
||||||
|
{ path: '/pending', redirect: '/reports' },
|
||||||
|
{ path: '/stats', redirect: '/reports' },
|
||||||
|
{ path: '/reports', name: 'reports', component: ReportPage },
|
||||||
|
{ path: '/users', name: 'users', component: UsersPage },
|
||||||
|
{ path: '/feedbacks', name: 'feedbacks', component: FeedbacksPage },
|
||||||
|
{ path: '/logs', name: 'logs', component: LogsPage },
|
||||||
|
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
|
||||||
|
{ path: '/email', name: 'email', component: EmailPage },
|
||||||
|
{ path: '/security', name: 'security', component: SecurityPage },
|
||||||
|
{ path: '/system', name: 'system', component: SystemPage },
|
||||||
|
{ path: '/settings', name: 'settings', component: SettingsPage },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
269
admin-frontend/src/style.css
Normal file
269
admin-frontend/src/style.css
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
:root {
|
||||||
|
--app-bg: #f4f6fb;
|
||||||
|
--app-text: #111827;
|
||||||
|
--app-muted: #6b7280;
|
||||||
|
--app-border: rgba(15, 23, 42, 0.1);
|
||||||
|
--app-border-strong: rgba(15, 23, 42, 0.14);
|
||||||
|
--app-radius: 12px;
|
||||||
|
--app-radius-lg: 14px;
|
||||||
|
--app-shadow-soft: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||||
|
--app-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--app-card-bg: rgba(255, 255, 255, 0.94);
|
||||||
|
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--app-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at -10% -10%, rgba(59, 130, 246, 0.12), transparent 55%),
|
||||||
|
radial-gradient(1000px 420px at 110% 0%, rgba(139, 92, 246, 0.1), transparent 50%),
|
||||||
|
var(--app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-muted {
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card {
|
||||||
|
border-radius: var(--app-radius-lg);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: var(--app-card-bg);
|
||||||
|
box-shadow: var(--app-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-select__wrapper,
|
||||||
|
.el-input-number,
|
||||||
|
.el-picker__wrapper {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table td.el-table__cell,
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
padding-top: 11px;
|
||||||
|
padding-bottom: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-table__row:hover > td.el-table__cell {
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
border-radius: var(--app-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-page-title {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-title h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
max-width: 92vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
width: auto !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding: 0 0 6px !important;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tabs__item {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-page-title > div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-title .toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar > * {
|
||||||
|
flex: 1 1 calc(50% - 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .el-button,
|
||||||
|
.toolbar .el-select,
|
||||||
|
.toolbar .el-input,
|
||||||
|
.toolbar .el-input-number {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap .el-table {
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-pagination {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.toolbar > * {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap .el-table {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
admin-frontend/src/utils/datetime.js
Normal file
29
admin-frontend/src/utils/datetime.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function parseSqliteDateTime(value) {
|
||||||
|
if (!value) return null
|
||||||
|
if (value instanceof Date) return value
|
||||||
|
|
||||||
|
let str = String(value).trim()
|
||||||
|
if (!str) return null
|
||||||
|
|
||||||
|
// "YYYY-MM-DD" -> "YYYY-MM-DDT00:00:00"
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) str = `${str}T00:00:00`
|
||||||
|
|
||||||
|
// "YYYY-MM-DD HH:mm:ss" -> "YYYY-MM-DDTHH:mm:ss"
|
||||||
|
let iso = str.includes('T') ? str : str.replace(' ', 'T')
|
||||||
|
|
||||||
|
// SQLite 可能带微秒,Date 仅可靠支持到毫秒
|
||||||
|
iso = iso.replace(/\.(\d{3})\d+/, '.$1')
|
||||||
|
|
||||||
|
// 统一按北京时间解析(除非字符串本身已带时区)
|
||||||
|
const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(iso)
|
||||||
|
if (!hasTimezone) iso = `${iso}+08:00`
|
||||||
|
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value) {
|
||||||
|
if (!value) return '-'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
121
admin-frontend/src/utils/kdocsStatusCache.js
Normal file
121
admin-frontend/src/utils/kdocsStatusCache.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { fetchKdocsStatus } from '../api/kdocs'
|
||||||
|
|
||||||
|
const CACHE_KEY = 'admin:kdocs:status:v1'
|
||||||
|
const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
let memoryStatus = null
|
||||||
|
let memoryUpdatedAt = 0
|
||||||
|
let inflightPromise = null
|
||||||
|
|
||||||
|
function nowTs() {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return {}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionCache() {
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(CACHE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
const updatedAt = Number(parsed.updated_at || 0)
|
||||||
|
const status = normalizeStatus(parsed.status)
|
||||||
|
if (!updatedAt) return null
|
||||||
|
return { status, updatedAt }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSessionCache(status, updatedAt) {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
CACHE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
status: normalizeStatus(status),
|
||||||
|
updated_at: Number(updatedAt || nowTs()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateFromSessionIfNeeded() {
|
||||||
|
if (memoryStatus !== null) return
|
||||||
|
const cached = readSessionCache()
|
||||||
|
if (!cached) return
|
||||||
|
memoryStatus = cached.status
|
||||||
|
memoryUpdatedAt = cached.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitStatus(status) {
|
||||||
|
memoryStatus = normalizeStatus(status)
|
||||||
|
memoryUpdatedAt = nowTs()
|
||||||
|
writeSessionCache(memoryStatus, memoryUpdatedAt)
|
||||||
|
return memoryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFresh(maxAgeMs) {
|
||||||
|
if (memoryStatus === null || !memoryUpdatedAt) return false
|
||||||
|
const ageLimit = Number(maxAgeMs)
|
||||||
|
if (!Number.isFinite(ageLimit) || ageLimit < 0) return true
|
||||||
|
return nowTs() - memoryUpdatedAt <= ageLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedKdocsStatus(options = {}) {
|
||||||
|
hydrateFromSessionIfNeeded()
|
||||||
|
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS
|
||||||
|
if (!isFresh(maxAgeMs)) return null
|
||||||
|
return normalizeStatus(memoryStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCachedKdocsStatus(status) {
|
||||||
|
return commitStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCachedKdocsStatus() {
|
||||||
|
memoryStatus = null
|
||||||
|
memoryUpdatedAt = 0
|
||||||
|
inflightPromise = null
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(CACHE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadKdocsStatus(options = {}) {
|
||||||
|
const {
|
||||||
|
force = false,
|
||||||
|
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||||
|
silent = true,
|
||||||
|
live = 0,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cached = getCachedKdocsStatus({ maxAgeMs })
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inflightPromise) return inflightPromise
|
||||||
|
|
||||||
|
const params = live ? { live: 1 } : {}
|
||||||
|
const requestConfig = {
|
||||||
|
__silent: Boolean(silent),
|
||||||
|
__no_retry: true,
|
||||||
|
timeout: 8000,
|
||||||
|
}
|
||||||
|
|
||||||
|
inflightPromise = fetchKdocsStatus(params, requestConfig)
|
||||||
|
.then((status) => commitStatus(status || {}))
|
||||||
|
.finally(() => {
|
||||||
|
inflightPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return inflightPromise
|
||||||
|
}
|
||||||
130
admin-frontend/src/utils/passkey.js
Normal file
130
admin-frontend/src/utils/passkey.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
function ensurePublicKeyOptions(options) {
|
||||||
|
if (!options || typeof options !== 'object') {
|
||||||
|
throw new Error('Passkey参数无效')
|
||||||
|
}
|
||||||
|
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToUint8Array(base64url) {
|
||||||
|
const value = String(base64url || '')
|
||||||
|
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||||
|
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const raw = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
bytes[i] = raw.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToBase64Url(input) {
|
||||||
|
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
.btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCreationOptions(rawOptions) {
|
||||||
|
const options = ensurePublicKeyOptions(rawOptions)
|
||||||
|
const normalized = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToUint8Array(options.challenge),
|
||||||
|
user: {
|
||||||
|
...options.user,
|
||||||
|
id: base64UrlToUint8Array(options.user?.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(options.excludeCredentials)) {
|
||||||
|
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToUint8Array(item.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCredential(credential) {
|
||||||
|
if (!credential) return null
|
||||||
|
|
||||||
|
const response = credential.response || {}
|
||||||
|
const output = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: uint8ArrayToBase64Url(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
authenticatorAttachment: credential.authenticatorAttachment || undefined,
|
||||||
|
response: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.clientDataJSON) {
|
||||||
|
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||||
|
}
|
||||||
|
if (response.attestationObject) {
|
||||||
|
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
|
||||||
|
}
|
||||||
|
if (response.authenticatorData) {
|
||||||
|
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||||
|
}
|
||||||
|
if (response.signature) {
|
||||||
|
output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.userHandle) {
|
||||||
|
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||||
|
} else {
|
||||||
|
output.response.userHandle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.getTransports === 'function') {
|
||||||
|
output.response.transports = response.getTransports() || []
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasskeyAvailable() {
|
||||||
|
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMiuiBrowser() {
|
||||||
|
const ua = String(window?.navigator?.userAgent || '')
|
||||||
|
return /MiuiBrowser|XiaoMi\/MiuiBrowser/i.test(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasskeyClientErrorMessage(error, actionLabel = 'Passkey操作') {
|
||||||
|
const name = String(error?.name || '').trim()
|
||||||
|
const message = String(error?.message || '').trim()
|
||||||
|
|
||||||
|
if (name === 'NotAllowedError') {
|
||||||
|
return `${actionLabel}未完成(可能已取消、超时或设备未响应)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'NotReadableError') {
|
||||||
|
if (/credential manager/i.test(message) && isMiuiBrowser()) {
|
||||||
|
return '当前小米浏览器与系统凭据管理器兼容性较差,请改用系统 Chrome 或 Edge 后重试。'
|
||||||
|
}
|
||||||
|
if (/credential manager/i.test(message)) {
|
||||||
|
return '系统凭据管理器返回异常,请确认已设置系统锁屏并改用系统 Chrome/Edge 后重试。'
|
||||||
|
}
|
||||||
|
return message || `${actionLabel}失败(设备读取异常)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'SecurityError') {
|
||||||
|
return '当前环境安全策略不满足 Passkey 要求,请确认使用 HTTPS 且证书有效。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || `${actionLabel}失败`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPasskey(rawOptions) {
|
||||||
|
const publicKey = toCreationOptions(rawOptions)
|
||||||
|
const credential = await navigator.credentials.create({ publicKey })
|
||||||
|
return serializeCredential(credential)
|
||||||
|
}
|
||||||
13
admin-frontend/src/utils/password.js
Normal file
13
admin-frontend/src/utils/password.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function validatePasswordStrength(password) {
|
||||||
|
const value = String(password || '')
|
||||||
|
if (!value) return { ok: false, message: '密码不能为空' }
|
||||||
|
if (value.length < 8) return { ok: false, message: '密码长度不能少于8个字符' }
|
||||||
|
if (value.length > 128) return { ok: false, message: '密码长度不能超过128个字符' }
|
||||||
|
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(value)
|
||||||
|
const hasDigit = /\d/.test(value)
|
||||||
|
if (!hasLetter || !hasDigit) return { ok: false, message: '密码必须包含字母和数字' }
|
||||||
|
|
||||||
|
return { ok: true, message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
43
admin-frontend/src/utils/taskSource.js
Normal file
43
admin-frontend/src/utils/taskSource.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
function normalizeRawSource(source) {
|
||||||
|
return String(source || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatchIdFromUserScheduledSource(raw) {
|
||||||
|
if (!raw.startsWith('user_scheduled')) return ''
|
||||||
|
if (!raw.includes(':')) return ''
|
||||||
|
return raw.split(':', 2)[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskSourceMeta(source) {
|
||||||
|
const raw = normalizeRawSource(source)
|
||||||
|
|
||||||
|
if (!raw || raw === 'manual') {
|
||||||
|
return { group: 'manual', label: '手动', type: 'success', tooltip: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === 'scheduled') {
|
||||||
|
return { group: 'scheduled', label: '定时任务', type: 'primary', tooltip: '系统定时' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith('user_scheduled')) {
|
||||||
|
const batchId = getBatchIdFromUserScheduledSource(raw)
|
||||||
|
const batchShort = String(batchId || '').replace(/^batch_/, '')
|
||||||
|
return {
|
||||||
|
group: 'scheduled',
|
||||||
|
label: '定时任务',
|
||||||
|
type: 'primary',
|
||||||
|
tooltip: batchShort ? `用户定时批次:${batchShort}` : '用户定时',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualTips = {
|
||||||
|
batch: '手动批量',
|
||||||
|
manual_screenshot: '手动截图',
|
||||||
|
immediate: '立即执行',
|
||||||
|
resumed: '断点恢复',
|
||||||
|
}
|
||||||
|
const tip = manualTips[raw] || raw
|
||||||
|
|
||||||
|
return { group: 'manual', label: '手动', type: 'success', tooltip: tip }
|
||||||
|
}
|
||||||
|
|
||||||
32
admin-frontend/vite.config.js
Normal file
32
admin-frontend/vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: '../static/admin',
|
||||||
|
emptyOutDir: true,
|
||||||
|
manifest: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return undefined
|
||||||
|
|
||||||
|
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/@vue/') || id.includes('/node_modules/vue-router/')) {
|
||||||
|
return 'vendor-vue'
|
||||||
|
}
|
||||||
|
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
|
||||||
|
return 'vendor-element'
|
||||||
|
}
|
||||||
|
if (id.includes('/node_modules/axios/')) {
|
||||||
|
return 'vendor-axios'
|
||||||
|
}
|
||||||
|
return 'vendor-misc'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
631
api_browser.py
631
api_browser.py
@@ -2,20 +2,140 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
API 浏览器 - 用纯 HTTP 请求实现浏览功能
|
API 浏览器 - 用纯 HTTP 请求实现浏览功能
|
||||||
比 Playwright 快 30-60 倍
|
比传统浏览器自动化快 30-60 倍
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import atexit
|
import atexit
|
||||||
import weakref
|
import weakref
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from app_config import get_config
|
||||||
|
|
||||||
|
import time as _time_module
|
||||||
|
|
||||||
|
_MODULE_START_TIME = _time_module.time()
|
||||||
|
_WARMUP_PERIOD_SECONDS = 60 # 启动后 60 秒内使用更长超时
|
||||||
|
_WARMUP_TIMEOUT_SECONDS = 15.0 # 预热期间的超时时间
|
||||||
|
|
||||||
|
|
||||||
BASE_URL = "https://postoa.aidunsoft.com"
|
# HTML解析缓存类
|
||||||
|
class HTMLParseCache:
|
||||||
|
"""HTML解析结果缓存"""
|
||||||
|
|
||||||
|
def __init__(self, ttl: int = 300, maxsize: int = 1000):
|
||||||
|
self.cache = {}
|
||||||
|
self.ttl = ttl
|
||||||
|
self.maxsize = maxsize
|
||||||
|
self._access_times = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def _make_key(self, url: str, content_hash: str) -> str:
|
||||||
|
return f"{url}:{content_hash}"
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[tuple]:
|
||||||
|
"""获取缓存,如果存在且未过期"""
|
||||||
|
with self._lock:
|
||||||
|
if key in self.cache:
|
||||||
|
value, timestamp = self.cache[key]
|
||||||
|
if time.time() - timestamp < self.ttl:
|
||||||
|
self._access_times[key] = time.time()
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
# 过期删除
|
||||||
|
del self.cache[key]
|
||||||
|
del self._access_times[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: tuple):
|
||||||
|
"""设置缓存"""
|
||||||
|
with self._lock:
|
||||||
|
# 如果缓存已满,删除最久未访问的项
|
||||||
|
if len(self.cache) >= self.maxsize:
|
||||||
|
if self._access_times:
|
||||||
|
# 使用简单的LRU策略,删除最久未访问的项
|
||||||
|
oldest_key = None
|
||||||
|
oldest_time = float("inf")
|
||||||
|
for key, access_time in self._access_times.items():
|
||||||
|
if access_time < oldest_time:
|
||||||
|
oldest_time = access_time
|
||||||
|
oldest_key = key
|
||||||
|
if oldest_key:
|
||||||
|
del self.cache[oldest_key]
|
||||||
|
del self._access_times[oldest_key]
|
||||||
|
|
||||||
|
self.cache[key] = (value, time.time())
|
||||||
|
self._access_times[key] = time.time()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""清空缓存"""
|
||||||
|
with self._lock:
|
||||||
|
self.cache.clear()
|
||||||
|
self._access_times.clear()
|
||||||
|
|
||||||
|
def get_lru_key(self) -> Optional[str]:
|
||||||
|
"""获取最久未访问的键"""
|
||||||
|
if not self._access_times:
|
||||||
|
return None
|
||||||
|
return min(self._access_times.keys(), key=lambda k: self._access_times[k])
|
||||||
|
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
BASE_URL = getattr(config, "ZSGL_BASE_URL", "https://postoa.aidunsoft.com")
|
||||||
|
LOGIN_URL = getattr(config, "ZSGL_LOGIN_URL", f"{BASE_URL}/admin/login.aspx")
|
||||||
|
INDEX_URL_PATTERN = getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx")
|
||||||
|
COOKIES_DIR = getattr(config, "COOKIES_DIR", "data/cookies")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_API_REQUEST_TIMEOUT_SECONDS = float(
|
||||||
|
os.environ.get("API_REQUEST_TIMEOUT_SECONDS") or os.environ.get("API_REQUEST_TIMEOUT") or "5"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_API_REQUEST_TIMEOUT_SECONDS = 5.0
|
||||||
|
_API_REQUEST_TIMEOUT_SECONDS = max(3.0, _API_REQUEST_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
_API_DIAGNOSTIC_LOG = str(os.environ.get("API_DIAGNOSTIC_LOG", "")).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
try:
|
||||||
|
_API_DIAGNOSTIC_SLOW_MS = int(os.environ.get("API_DIAGNOSTIC_SLOW_MS", "0") or "0")
|
||||||
|
except Exception:
|
||||||
|
_API_DIAGNOSTIC_SLOW_MS = 0
|
||||||
|
_API_DIAGNOSTIC_SLOW_MS = max(0, _API_DIAGNOSTIC_SLOW_MS)
|
||||||
|
|
||||||
|
_cookie_domain_fallback = urlsplit(BASE_URL).hostname or "postoa.aidunsoft.com"
|
||||||
|
_COOKIE_JAR_MAX_AGE_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookie_jar_path(username: str) -> str:
|
||||||
|
"""获取截图用的 cookies 文件路径(Netscape Cookie 格式)"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
os.makedirs(COOKIES_DIR, mode=0o700, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(COOKIES_DIR, 0o700)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + ".cookies.txt"
|
||||||
|
return os.path.join(COOKIES_DIR, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def is_cookie_jar_fresh(cookie_path: str, max_age_seconds: int = _COOKIE_JAR_MAX_AGE_SECONDS) -> bool:
|
||||||
|
"""判断 cookies 文件是否存在且未过期"""
|
||||||
|
if not cookie_path or not os.path.exists(cookie_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
file_age = time.time() - os.path.getmtime(cookie_path)
|
||||||
|
return file_age <= max(0, int(max_age_seconds or 0))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
|
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
|
||||||
|
|
||||||
@@ -35,6 +155,7 @@ atexit.register(_cleanup_api_browser_instances)
|
|||||||
@dataclass
|
@dataclass
|
||||||
class APIBrowseResult:
|
class APIBrowseResult:
|
||||||
"""API 浏览结果"""
|
"""API 浏览结果"""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
total_items: int = 0
|
total_items: int = 0
|
||||||
total_attachments: int = 0
|
total_attachments: int = 0
|
||||||
@@ -46,65 +167,107 @@ class APIBrowser:
|
|||||||
|
|
||||||
def __init__(self, log_callback: Optional[Callable] = None, proxy_config: Optional[dict] = None):
|
def __init__(self, log_callback: Optional[Callable] = None, proxy_config: Optional[dict] = None):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update(
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
{
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
})
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
}
|
||||||
|
)
|
||||||
self.logged_in = False
|
self.logged_in = False
|
||||||
self.log_callback = log_callback
|
self.log_callback = log_callback
|
||||||
self.stop_flag = False
|
self.stop_flag = False
|
||||||
self._closed = False # 防止重复关闭
|
self._closed = False # 防止重复关闭
|
||||||
|
self.last_total_records = 0
|
||||||
|
|
||||||
|
# 初始化HTML解析缓存
|
||||||
|
self._parse_cache = HTMLParseCache(ttl=300, maxsize=500) # 5分钟缓存,最多500条记录
|
||||||
|
|
||||||
# 设置代理
|
# 设置代理
|
||||||
if proxy_config and proxy_config.get("server"):
|
if proxy_config and proxy_config.get("server"):
|
||||||
proxy_server = proxy_config["server"]
|
proxy_server = proxy_config["server"]
|
||||||
self.session.proxies = {
|
self.session.proxies = {"http": proxy_server, "https": proxy_server}
|
||||||
"http": proxy_server,
|
|
||||||
"https": proxy_server
|
|
||||||
}
|
|
||||||
self.proxy_server = proxy_server
|
self.proxy_server = proxy_server
|
||||||
else:
|
else:
|
||||||
self.proxy_server = None
|
self.proxy_server = None
|
||||||
|
|
||||||
_api_browser_instances.add(self)
|
_api_browser_instances.add(self)
|
||||||
|
|
||||||
|
def _calculate_adaptive_delay(self, iteration: int, consecutive_failures: int) -> float:
|
||||||
|
"""
|
||||||
|
智能延迟计算:文章处理延迟
|
||||||
|
根据迭代次数和连续失败次数动态调整延迟
|
||||||
|
"""
|
||||||
|
# 基础延迟,显著降低
|
||||||
|
base_delay = 0.03
|
||||||
|
|
||||||
|
# 如果有连续失败,增加延迟但有上限
|
||||||
|
if consecutive_failures > 0:
|
||||||
|
delay = base_delay * (1.5 ** min(consecutive_failures, 3))
|
||||||
|
return min(delay, 0.2) # 最多200ms
|
||||||
|
|
||||||
|
# 根据处理进度调整延迟,开始时较慢,后来可以更快
|
||||||
|
progress_factor = min(iteration / 100.0, 1.0) # 100个文章后达到最大优化
|
||||||
|
optimized_delay = base_delay * (1.2 - 0.4 * progress_factor) # 从120%逐渐降低到80%
|
||||||
|
return max(optimized_delay, 0.02) # 最少20ms
|
||||||
|
|
||||||
|
def _calculate_page_delay(self, current_page: int, new_articles_in_page: int) -> float:
|
||||||
|
"""
|
||||||
|
智能延迟计算:页面处理延迟
|
||||||
|
根据页面位置和新文章数量调整延迟
|
||||||
|
"""
|
||||||
|
base_delay = 0.08 # 基础延迟,降低50%
|
||||||
|
|
||||||
|
# 如果当前页有大量新文章,可以稍微增加延迟
|
||||||
|
if new_articles_in_page > 10:
|
||||||
|
return base_delay * 1.2
|
||||||
|
|
||||||
|
# 如果是新页面,降低延迟(内容可能需要加载)
|
||||||
|
if current_page <= 3:
|
||||||
|
return base_delay * 1.1
|
||||||
|
|
||||||
|
# 后续页面可以更快
|
||||||
|
return base_delay * 0.8
|
||||||
|
|
||||||
def log(self, message: str):
|
def log(self, message: str):
|
||||||
"""记录日志"""
|
"""记录日志"""
|
||||||
if self.log_callback:
|
if self.log_callback:
|
||||||
self.log_callback(message)
|
self.log_callback(message)
|
||||||
def save_cookies_for_playwright(self, username: str):
|
|
||||||
"""保存cookies供Playwright使用"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
cookies_dir = '/app/data/cookies'
|
|
||||||
os.makedirs(cookies_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
|
||||||
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
|
|
||||||
cookies_path = os.path.join(cookies_dir, filename)
|
|
||||||
|
|
||||||
|
def save_cookies_for_screenshot(self, username: str):
|
||||||
|
"""保存 cookies 供 wkhtmltoimage 使用(Netscape Cookie 格式)"""
|
||||||
|
cookies_path = get_cookie_jar_path(username)
|
||||||
try:
|
try:
|
||||||
# 获取requests session的cookies
|
lines = [
|
||||||
cookies_list = []
|
"# Netscape HTTP Cookie File",
|
||||||
|
"# This file was generated by zsglpt",
|
||||||
|
]
|
||||||
for cookie in self.session.cookies:
|
for cookie in self.session.cookies:
|
||||||
cookies_list.append({
|
domain = cookie.domain or _cookie_domain_fallback
|
||||||
'name': cookie.name,
|
include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
|
||||||
'value': cookie.value,
|
path = cookie.path or "/"
|
||||||
'domain': cookie.domain or 'postoa.aidunsoft.com',
|
secure = "TRUE" if getattr(cookie, "secure", False) else "FALSE"
|
||||||
'path': cookie.path or '/',
|
expires = int(getattr(cookie, "expires", 0) or 0)
|
||||||
})
|
lines.append(
|
||||||
|
"\t".join(
|
||||||
|
[
|
||||||
|
domain,
|
||||||
|
include_subdomains,
|
||||||
|
path,
|
||||||
|
secure,
|
||||||
|
str(expires),
|
||||||
|
cookie.name,
|
||||||
|
cookie.value,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Playwright storage_state 格式
|
with open(cookies_path, "w", encoding="utf-8") as f:
|
||||||
storage_state = {
|
f.write("\n".join(lines) + "\n")
|
||||||
'cookies': cookies_list,
|
try:
|
||||||
'origins': []
|
os.chmod(cookies_path, 0o600)
|
||||||
}
|
except Exception:
|
||||||
|
pass
|
||||||
with open(cookies_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(storage_state, f)
|
|
||||||
|
|
||||||
self.log(f"[API] Cookies已保存供截图使用")
|
self.log(f"[API] Cookies已保存供截图使用")
|
||||||
return True
|
return True
|
||||||
@@ -112,25 +275,43 @@ class APIBrowser:
|
|||||||
self.log(f"[API] 保存cookies失败: {e}")
|
self.log(f"[API] 保存cookies失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _request_with_retry(self, method, url, max_retries=3, retry_delay=1, **kwargs):
|
def _request_with_retry(self, method, url, max_retries=3, retry_delay=1, **kwargs):
|
||||||
"""带重试机制的请求方法"""
|
"""带重试机制的请求方法"""
|
||||||
kwargs.setdefault('timeout', 10)
|
# 启动后 60 秒内使用更长超时(15秒),之后使用配置的超时
|
||||||
|
if (_time_module.time() - _MODULE_START_TIME) < _WARMUP_PERIOD_SECONDS:
|
||||||
|
kwargs.setdefault("timeout", _WARMUP_TIMEOUT_SECONDS)
|
||||||
|
else:
|
||||||
|
kwargs.setdefault("timeout", _API_REQUEST_TIMEOUT_SECONDS)
|
||||||
last_error = None
|
last_error = None
|
||||||
|
timeout_value = kwargs.get("timeout")
|
||||||
|
diag_enabled = _API_DIAGNOSTIC_LOG
|
||||||
|
slow_ms = _API_DIAGNOSTIC_SLOW_MS
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
for attempt in range(1, max_retries + 1):
|
||||||
|
start_ts = _time_module.time()
|
||||||
try:
|
try:
|
||||||
if method.lower() == 'get':
|
if method.lower() == "get":
|
||||||
resp = self.session.get(url, **kwargs)
|
resp = self.session.get(url, **kwargs)
|
||||||
else:
|
else:
|
||||||
resp = self.session.post(url, **kwargs)
|
resp = self.session.post(url, **kwargs)
|
||||||
|
if diag_enabled:
|
||||||
|
elapsed_ms = int((_time_module.time() - start_ts) * 1000)
|
||||||
|
if slow_ms <= 0 or elapsed_ms >= slow_ms:
|
||||||
|
self.log(
|
||||||
|
f"[API][trace] {method.upper()} {url} ok status={resp.status_code} elapsed_ms={elapsed_ms} timeout={timeout_value} attempt={attempt}/{max_retries}"
|
||||||
|
)
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
|
if diag_enabled:
|
||||||
|
elapsed_ms = int((_time_module.time() - start_ts) * 1000)
|
||||||
|
self.log(
|
||||||
|
f"[API][trace] {method.upper()} {url} err={type(e).__name__} elapsed_ms={elapsed_ms} timeout={timeout_value} attempt={attempt}/{max_retries}"
|
||||||
|
)
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
|
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(retry_delay)
|
time.sleep(retry_delay)
|
||||||
else:
|
else:
|
||||||
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
|
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
|
||||||
@@ -140,10 +321,10 @@ class APIBrowser:
|
|||||||
def _get_aspnet_fields(self, soup):
|
def _get_aspnet_fields(self, soup):
|
||||||
"""获取 ASP.NET 隐藏字段"""
|
"""获取 ASP.NET 隐藏字段"""
|
||||||
fields = {}
|
fields = {}
|
||||||
for name in ['__VIEWSTATE', '__VIEWSTATEGENERATOR', '__EVENTVALIDATION']:
|
for name in ["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"]:
|
||||||
field = soup.find('input', {'name': name})
|
field = soup.find("input", {"name": name})
|
||||||
if field:
|
if field:
|
||||||
fields[name] = field.get('value', '')
|
fields[name] = field.get("value", "")
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
def get_real_name(self) -> Optional[str]:
|
def get_real_name(self) -> Optional[str]:
|
||||||
@@ -157,18 +338,18 @@ class APIBrowser:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{BASE_URL}/admin/center.aspx"
|
url = f"{BASE_URL}/admin/center.aspx"
|
||||||
resp = self._request_with_retry('get', url)
|
resp = self._request_with_retry("get", url)
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
|
|
||||||
# 查找包含"姓名:"的元素
|
# 查找包含"姓名:"的元素
|
||||||
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
|
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
|
||||||
nlist = soup.find('div', {'class': 'nlist-5'})
|
nlist = soup.find("div", {"class": "nlist-5"})
|
||||||
if nlist:
|
if nlist:
|
||||||
first_li = nlist.find('li')
|
first_li = nlist.find("li")
|
||||||
if first_li:
|
if first_li:
|
||||||
text = first_li.get_text()
|
text = first_li.get_text()
|
||||||
# 解析姓名:格式为 "姓名:XXX(手机号)"
|
# 解析姓名:格式为 "姓名:XXX(手机号)"
|
||||||
match = re.search(r'姓名[::]\s*([^\((]+)', text)
|
match = re.search(r"姓名[::]\s*([^\((]+)", text)
|
||||||
if match:
|
if match:
|
||||||
real_name = match.group(1).strip()
|
real_name = match.group(1).strip()
|
||||||
if real_name:
|
if real_name:
|
||||||
@@ -182,37 +363,36 @@ class APIBrowser:
|
|||||||
self.log(f"[API] 登录: {username}")
|
self.log(f"[API] 登录: {username}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
login_url = f"{BASE_URL}/admin/login.aspx"
|
resp = self._request_with_retry("get", LOGIN_URL)
|
||||||
resp = self._request_with_retry('get', login_url)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
fields = self._get_aspnet_fields(soup)
|
fields = self._get_aspnet_fields(soup)
|
||||||
|
|
||||||
data = fields.copy()
|
data = fields.copy()
|
||||||
data['txtUserName'] = username
|
data["txtUserName"] = username
|
||||||
data['txtPassword'] = password
|
data["txtPassword"] = password
|
||||||
data['btnSubmit'] = '登 录'
|
data["btnSubmit"] = "登 录"
|
||||||
|
|
||||||
resp = self._request_with_retry(
|
resp = self._request_with_retry(
|
||||||
'post',
|
"post",
|
||||||
login_url,
|
LOGIN_URL,
|
||||||
data=data,
|
data=data,
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Origin': BASE_URL,
|
"Origin": BASE_URL,
|
||||||
'Referer': login_url,
|
"Referer": LOGIN_URL,
|
||||||
},
|
},
|
||||||
allow_redirects=True
|
allow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'index.aspx' in resp.url:
|
if INDEX_URL_PATTERN in resp.url:
|
||||||
self.logged_in = True
|
self.logged_in = True
|
||||||
self.log(f"[API] 登录成功")
|
self.log(f"[API] 登录成功")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
error = soup.find(id='lblMsg')
|
error = soup.find(id="lblMsg")
|
||||||
error_msg = error.get_text().strip() if error else '未知错误'
|
error_msg = error.get_text().strip() if error else "未知错误"
|
||||||
self.log(f"[API] 登录失败: {error_msg}")
|
self.log(f"[API] 登录失败: {error_msg}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -225,104 +405,145 @@ class APIBrowser:
|
|||||||
if not self.logged_in:
|
if not self.logged_in:
|
||||||
return [], 0, None
|
return [], 0, None
|
||||||
|
|
||||||
try:
|
|
||||||
if base_url and page > 1:
|
if base_url and page > 1:
|
||||||
url = re.sub(r'page=\d+', f'page={page}', base_url)
|
url = re.sub(r"page=\d+", f"page={page}", base_url)
|
||||||
|
elif page > 1:
|
||||||
|
# 兼容兜底:若没有 next_url(极少数情况下页面不提供“下一页”链接),尝试直接拼 page 参数
|
||||||
|
url = f"{BASE_URL}/admin/center.aspx?bz={bz}&page={page}"
|
||||||
else:
|
else:
|
||||||
url = f"{BASE_URL}/admin/center.aspx?bz={bz}"
|
url = f"{BASE_URL}/admin/center.aspx?bz={bz}"
|
||||||
|
|
||||||
resp = self._request_with_retry('get', url)
|
resp = self._request_with_retry("get", url)
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
articles = []
|
articles = []
|
||||||
|
|
||||||
ltable = soup.find('table', {'class': 'ltable'})
|
ltable = soup.find("table", {"class": "ltable"})
|
||||||
if ltable:
|
if ltable:
|
||||||
rows = ltable.find_all('tr')[1:]
|
rows = ltable.find_all("tr")[1:]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# 检查是否是"暂无记录"
|
# 检查是否是"暂无记录"
|
||||||
if '暂无记录' in row.get_text():
|
if "暂无记录" in row.get_text():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
link = row.find('a', href=True)
|
link = row.find("a", href=True)
|
||||||
if link:
|
if link:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
title = link.get_text().strip()
|
title = link.get_text().strip()
|
||||||
|
|
||||||
match = re.search(r'id=(\d+)', href)
|
match = re.search(r"id=(\d+)", href)
|
||||||
article_id = match.group(1) if match else None
|
article_id = match.group(1) if match else None
|
||||||
|
|
||||||
articles.append({
|
articles.append(
|
||||||
'title': title,
|
{
|
||||||
'href': href,
|
"title": title,
|
||||||
'article_id': article_id,
|
"href": href,
|
||||||
})
|
"article_id": article_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 获取总页数
|
# 获取总页数
|
||||||
total_pages = 1
|
total_pages = 1
|
||||||
next_page_url = None
|
next_page_url = None
|
||||||
|
total_records = 0
|
||||||
|
|
||||||
page_content = soup.find(id='PageContent')
|
page_content = soup.find(id="PageContent")
|
||||||
if page_content:
|
if page_content:
|
||||||
text = page_content.get_text()
|
text = page_content.get_text()
|
||||||
total_match = re.search(r'共(\d+)记录', text)
|
total_match = re.search(r"共(\d+)记录", text)
|
||||||
if total_match:
|
if total_match:
|
||||||
total_records = int(total_match.group(1))
|
total_records = int(total_match.group(1))
|
||||||
total_pages = (total_records + 9) // 10
|
total_pages = (total_records + 9) // 10
|
||||||
|
|
||||||
next_link = page_content.find('a', string=re.compile('下一页'))
|
next_link = page_content.find("a", string=re.compile("下一页"))
|
||||||
if next_link:
|
if next_link:
|
||||||
next_href = next_link.get('href', '')
|
next_href = next_link.get("href", "")
|
||||||
if next_href:
|
if next_href:
|
||||||
next_page_url = f"{BASE_URL}/admin/{next_href}"
|
next_page_url = f"{BASE_URL}/admin/{next_href}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.last_total_records = int(total_records or 0)
|
||||||
|
except Exception:
|
||||||
|
self.last_total_records = 0
|
||||||
return articles, total_pages, next_page_url
|
return articles, total_pages, next_page_url
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"[API] 获取列表失败: {str(e)}")
|
|
||||||
return [], 0, None
|
|
||||||
|
|
||||||
def get_article_attachments(self, article_href: str):
|
def get_article_attachments(self, article_href: str):
|
||||||
"""获取文章的附件列表"""
|
"""获取文章的附件列表和文章信息"""
|
||||||
try:
|
if not article_href.startswith("http"):
|
||||||
if not article_href.startswith('http'):
|
|
||||||
url = f"{BASE_URL}/admin/{article_href}"
|
url = f"{BASE_URL}/admin/{article_href}"
|
||||||
else:
|
else:
|
||||||
url = article_href
|
url = article_href
|
||||||
|
|
||||||
resp = self._request_with_retry('get', url)
|
# 先检查缓存,避免不必要的请求
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
# 使用URL作为缓存键(简化版本)
|
||||||
|
cache_key = f"attachments_{hash(url)}"
|
||||||
|
cached_result = self._parse_cache.get(cache_key)
|
||||||
|
if cached_result:
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
resp = self._request_with_retry("get", url)
|
||||||
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
|
|
||||||
attachments = []
|
attachments = []
|
||||||
|
article_info = {"channel_id": None, "article_id": None}
|
||||||
|
|
||||||
attach_list = soup.find('div', {'class': 'attach-list2'})
|
# 从 saveread 按钮获取 channel_id 和 article_id
|
||||||
if attach_list:
|
for elem in soup.find_all(["button", "input"]):
|
||||||
items = attach_list.find_all('li')
|
onclick = elem.get("onclick", "")
|
||||||
for item in items:
|
match = re.search(r"saveread\((\d+),(\d+)\)", onclick)
|
||||||
download_links = item.find_all('a', onclick=re.compile(r'download\.ashx'))
|
if match:
|
||||||
for link in download_links:
|
article_info["channel_id"] = match.group(1)
|
||||||
onclick = link.get('onclick', '')
|
article_info["article_id"] = match.group(2)
|
||||||
id_match = re.search(r'id=(\d+)', onclick)
|
|
||||||
channel_match = re.search(r'channel_id=(\d+)', onclick)
|
|
||||||
if id_match:
|
|
||||||
attach_id = id_match.group(1)
|
|
||||||
channel_id = channel_match.group(1) if channel_match else '1'
|
|
||||||
h3 = item.find('h3')
|
|
||||||
filename = h3.get_text().strip() if h3 else f'附件{attach_id}'
|
|
||||||
attachments.append({
|
|
||||||
'id': attach_id,
|
|
||||||
'channel_id': channel_id,
|
|
||||||
'filename': filename
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return attachments
|
attach_list = soup.find("div", {"class": "attach-list2"})
|
||||||
|
if attach_list:
|
||||||
|
items = attach_list.find_all("li")
|
||||||
|
for item in items:
|
||||||
|
download_links = item.find_all("a", onclick=re.compile(r"download2?\.ashx"))
|
||||||
|
for link in download_links:
|
||||||
|
onclick = link.get("onclick", "")
|
||||||
|
id_match = re.search(r"id=(\d+)", onclick)
|
||||||
|
channel_match = re.search(r"channel_id=(\d+)", onclick)
|
||||||
|
if id_match:
|
||||||
|
attach_id = id_match.group(1)
|
||||||
|
channel_id = channel_match.group(1) if channel_match else "1"
|
||||||
|
h3 = item.find("h3")
|
||||||
|
filename = h3.get_text().strip() if h3 else f"附件{attach_id}"
|
||||||
|
attachments.append({"id": attach_id, "channel_id": channel_id, "filename": filename})
|
||||||
|
break
|
||||||
|
|
||||||
except Exception as e:
|
result = (attachments, article_info)
|
||||||
return []
|
# 存入缓存
|
||||||
|
self._parse_cache.set(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
def mark_read(self, attach_id: str, channel_id: str = '1') -> bool:
|
def mark_article_read(self, channel_id: str, article_id: str) -> bool:
|
||||||
"""通过访问下载链接标记已读"""
|
"""通过 saveread API 标记文章已读"""
|
||||||
download_url = f"{BASE_URL}/tools/download.ashx?site=main&id={attach_id}&channel_id={channel_id}"
|
if not channel_id or not article_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
saveread_url = (
|
||||||
|
f"{BASE_URL}/tools/submit_ajax.ashx?action=saveread&time={random.random()}&fl={channel_id}&id={article_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = self._request_with_retry("post", saveread_url)
|
||||||
|
# 检查响应是否成功
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("status") == 1
|
||||||
|
except:
|
||||||
|
return True # 如果不是 JSON 但状态码 200,也认为成功
|
||||||
|
return False
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_read(self, attach_id: str, channel_id: str = "1") -> bool:
|
||||||
|
"""通过访问预览通道标记附件已读"""
|
||||||
|
download_url = f"{BASE_URL}/tools/download2.ashx?site=main&id={attach_id}&channel_id={channel_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._request_with_retry("get", download_url, stream=True)
|
resp = self._request_with_retry("get", download_url, stream=True)
|
||||||
@@ -331,14 +552,19 @@ class APIBrowser:
|
|||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def browse_content(self, browse_type: str,
|
def browse_content(
|
||||||
should_stop_callback: Optional[Callable] = None) -> APIBrowseResult:
|
self,
|
||||||
|
browse_type: str,
|
||||||
|
should_stop_callback: Optional[Callable] = None,
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
) -> APIBrowseResult:
|
||||||
"""
|
"""
|
||||||
浏览内容并标记已读
|
浏览内容并标记已读
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
browse_type: 浏览类型 (应读/注册前未读)
|
browse_type: 浏览类型 (应读/注册前未读)
|
||||||
should_stop_callback: 检查是否应该停止的回调函数
|
should_stop_callback: 检查是否应该停止的回调函数
|
||||||
|
progress_callback: 进度回调(可选),用于实时上报已浏览内容数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
浏览结果
|
浏览结果
|
||||||
@@ -350,75 +576,149 @@ class APIBrowser:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# 根据浏览类型确定 bz 参数
|
# 根据浏览类型确定 bz 参数
|
||||||
# 网页实际选项: 0=注册前未读, 1=已读, 2=应读
|
# 网站更新后参数: 0=应读, 1=已读(注册前未读需通过页面交互切换)
|
||||||
# 前端选项: 注册前未读, 应读, 未读, 已读
|
# 当前前端选项: 注册前未读、应读(默认应读)
|
||||||
if '注册前' in browse_type:
|
browse_type_text = str(browse_type or "")
|
||||||
bz = 0 # 注册前未读
|
if "注册前" in browse_type_text:
|
||||||
elif browse_type == '已读':
|
bz = 0 # 注册前未读(暂与应读相同,网站通过页面状态区分)
|
||||||
bz = 1 # 已读
|
|
||||||
else:
|
else:
|
||||||
bz = 2 # 应读、未读 都映射到 bz=2
|
bz = 0 # 应读
|
||||||
|
|
||||||
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
|
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total_items = 0
|
total_items = 0
|
||||||
total_attachments = 0
|
total_attachments = 0
|
||||||
page = 1
|
skipped_items = 0
|
||||||
base_url = None
|
consecutive_failures = 0
|
||||||
|
max_consecutive_failures = 3
|
||||||
|
|
||||||
# 获取第一页
|
# 获取第一页,了解总记录数
|
||||||
articles, total_pages, next_url = self.get_article_list_page(bz, page)
|
try:
|
||||||
|
articles, total_pages, _ = self.get_article_list_page(bz, 1)
|
||||||
|
consecutive_failures = 0
|
||||||
|
except Exception as e:
|
||||||
|
result.error_message = str(e)
|
||||||
|
self.log(f"[API] 获取第1页列表失败: {str(e)}")
|
||||||
|
return result
|
||||||
|
|
||||||
if not articles:
|
if not articles:
|
||||||
self.log(f"[API] '{browse_type}' 没有待处理内容")
|
self.log(f"[API] '{browse_type}' 没有待处理内容")
|
||||||
result.success = True
|
result.success = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
self.log(f"[API] 共 {total_pages} 页,开始处理...")
|
total_records = int(getattr(self, "last_total_records", 0) or 0)
|
||||||
|
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
|
||||||
|
|
||||||
if next_url:
|
last_report_ts = 0.0
|
||||||
base_url = next_url
|
|
||||||
|
def report_progress(force: bool = False):
|
||||||
|
nonlocal last_report_ts
|
||||||
|
if not progress_callback:
|
||||||
|
return
|
||||||
|
now_ts = time.time()
|
||||||
|
if not force and now_ts - last_report_ts < 1.0:
|
||||||
|
return
|
||||||
|
last_report_ts = now_ts
|
||||||
|
try:
|
||||||
|
progress_callback({"total_items": total_records, "browsed_items": total_items})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
report_progress(force=True)
|
||||||
|
|
||||||
|
# 循环处理:遍历所有页面,跟踪已处理文章防止重复
|
||||||
|
max_iterations = total_records + 20 # 防止无限循环
|
||||||
|
iteration = 0
|
||||||
|
processed_hrefs = set() # 跟踪已处理的文章,防止重复处理
|
||||||
|
current_page = 1
|
||||||
|
|
||||||
|
while articles and iteration < max_iterations:
|
||||||
|
iteration += 1
|
||||||
|
|
||||||
# 处理所有页面
|
|
||||||
while True:
|
|
||||||
if should_stop_callback and should_stop_callback():
|
if should_stop_callback and should_stop_callback():
|
||||||
self.log("[API] 收到停止信号")
|
self.log("[API] 收到停止信号")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
new_articles_in_page = 0 # 本次迭代中新处理的文章数
|
||||||
|
|
||||||
for article in articles:
|
for article in articles:
|
||||||
if should_stop_callback and should_stop_callback():
|
if should_stop_callback and should_stop_callback():
|
||||||
break
|
break
|
||||||
|
|
||||||
title = article['title'][:30]
|
article_href = article["href"]
|
||||||
|
# 跳过已处理的文章
|
||||||
|
if article_href in processed_hrefs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_hrefs.add(article_href)
|
||||||
|
new_articles_in_page += 1
|
||||||
|
title = article["title"][:30]
|
||||||
|
|
||||||
|
# 获取附件和文章信息(文章详情页)
|
||||||
|
try:
|
||||||
|
attachments, article_info = self.get_article_attachments(article_href)
|
||||||
|
consecutive_failures = 0
|
||||||
|
except Exception as e:
|
||||||
|
skipped_items += 1
|
||||||
|
consecutive_failures += 1
|
||||||
|
self.log(
|
||||||
|
f"[API] 获取文章失败,跳过(连续失败{consecutive_failures}/{max_consecutive_failures}): {title} | {str(e)}"
|
||||||
|
)
|
||||||
|
if consecutive_failures >= max_consecutive_failures:
|
||||||
|
raise
|
||||||
|
continue
|
||||||
|
|
||||||
total_items += 1
|
total_items += 1
|
||||||
|
report_progress()
|
||||||
|
|
||||||
# 获取附件
|
# 标记文章已读(调用 saveread API)
|
||||||
attachments = self.get_article_attachments(article['href'])
|
article_marked = False
|
||||||
|
if article_info.get("channel_id") and article_info.get("article_id"):
|
||||||
|
article_marked = self.mark_article_read(article_info["channel_id"], article_info["article_id"])
|
||||||
|
|
||||||
|
# 处理附件(如果有)
|
||||||
if attachments:
|
if attachments:
|
||||||
for attach in attachments:
|
for attach in attachments:
|
||||||
if self.mark_read(attach['id'], attach['channel_id']):
|
if self.mark_read(attach["id"], attach["channel_id"]):
|
||||||
total_attachments += 1
|
total_attachments += 1
|
||||||
|
|
||||||
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
|
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
|
||||||
|
else:
|
||||||
|
# 没有附件的文章,只记录标记状态
|
||||||
|
status = "已标记" if article_marked else "标记失败"
|
||||||
|
self.log(f"[API] [{total_items}] {title} - 无附件({status})")
|
||||||
|
|
||||||
time.sleep(0.1)
|
# 智能延迟策略:根据连续失败次数和文章数量动态调整
|
||||||
|
time.sleep(self._calculate_adaptive_delay(total_items, consecutive_failures))
|
||||||
|
|
||||||
# 下一页
|
time.sleep(self._calculate_page_delay(current_page, new_articles_in_page))
|
||||||
page += 1
|
|
||||||
if page > total_pages:
|
# 决定下一步获取哪一页
|
||||||
|
if new_articles_in_page > 0:
|
||||||
|
# 有新文章被处理,重新获取第1页(因为已读文章会从列表消失,页面会上移)
|
||||||
|
current_page = 1
|
||||||
|
else:
|
||||||
|
# 当前页没有新文章,尝试下一页
|
||||||
|
current_page += 1
|
||||||
|
if current_page > total_pages:
|
||||||
|
self.log(f"[API] 已遍历所有 {total_pages} 页,结束循环")
|
||||||
break
|
break
|
||||||
|
|
||||||
articles, _, next_url = self.get_article_list_page(bz, page, base_url)
|
try:
|
||||||
if not articles:
|
articles, new_total_pages, _ = self.get_article_list_page(bz, current_page)
|
||||||
|
if new_total_pages > 0:
|
||||||
|
total_pages = new_total_pages
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
|
||||||
break
|
break
|
||||||
|
|
||||||
if next_url:
|
report_progress(force=True)
|
||||||
base_url = next_url
|
if skipped_items:
|
||||||
|
self.log(
|
||||||
time.sleep(0.2)
|
f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件(跳过 {skipped_items} 条内容)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
||||||
|
|
||||||
result.success = True
|
result.success = True
|
||||||
@@ -455,3 +755,28 @@ class APIBrowser:
|
|||||||
"""Context manager支持 - 退出"""
|
"""Context manager支持 - 退出"""
|
||||||
self.close()
|
self.close()
|
||||||
return False # 不抑制异常
|
return False # 不抑制异常
|
||||||
|
|
||||||
|
|
||||||
|
def warmup_api_connection(proxy_config: Optional[dict] = None, log_callback: Optional[Callable] = None):
|
||||||
|
"""预热 API 连接 - 建立 TCP/TLS 连接池"""
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
if log_callback:
|
||||||
|
log_callback(msg)
|
||||||
|
else:
|
||||||
|
print(f"[API预热] {msg}")
|
||||||
|
|
||||||
|
log("正在预热 API 连接...")
|
||||||
|
try:
|
||||||
|
session = requests.Session()
|
||||||
|
if proxy_config and proxy_config.get("server"):
|
||||||
|
session.proxies = {"http": proxy_config["server"], "https": proxy_config["server"]}
|
||||||
|
|
||||||
|
# 发送一个轻量级请求建立连接
|
||||||
|
resp = session.get(f"{BASE_URL}/admin/login.aspx", timeout=10, allow_redirects=False)
|
||||||
|
log(f"[OK] API 连接预热完成 (status={resp.status_code})")
|
||||||
|
session.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log(f"API 连接预热失败: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
5
app-frontend/.gitignore
vendored
Normal file
5
app-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
.vite
|
||||||
|
|
||||||
4
app-frontend/README.md
Normal file
4
app-frontend/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# app-frontend
|
||||||
|
|
||||||
|
前台(用户端)Vue3 + Vite 工程,构建产物输出到 `static/app/`。
|
||||||
|
|
||||||
14
app-frontend/index.html
Normal file
14
app-frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<title>知识管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>该页面需要启用 JavaScript 才能使用。</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
13
app-frontend/login.html
Normal file
13
app-frontend/login.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<title>知识管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>该页面需要启用 JavaScript 才能使用。</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/login-main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2466
app-frontend/package-lock.json
generated
Normal file
2466
app-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
app-frontend/package.json
Normal file
26
app-frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "app-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"element-plus": "^2.11.3",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"unplugin-auto-import": "^21.0.0",
|
||||||
|
"unplugin-vue-components": "^31.0.0",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app-frontend/src/App.vue
Normal file
6
app-frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
57
app-frontend/src/api/accounts.js
Normal file
57
app-frontend/src/api/accounts.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchAccounts(params = {}) {
|
||||||
|
const { data } = await publicApi.get('/accounts', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAccount(payload) {
|
||||||
|
const { data } = await publicApi.post('/accounts', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(accountId, payload) {
|
||||||
|
const { data } = await publicApi.put(`/accounts/${accountId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(accountId) {
|
||||||
|
const { data } = await publicApi.delete(`/accounts/${accountId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccountRemark(accountId, payload) {
|
||||||
|
const { data } = await publicApi.put(`/accounts/${accountId}/remark`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAccount(accountId, payload) {
|
||||||
|
const { data } = await publicApi.post(`/accounts/${accountId}/start`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopAccount(accountId) {
|
||||||
|
const { data } = await publicApi.post(`/accounts/${accountId}/stop`, {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchStartAccounts(payload) {
|
||||||
|
const { data } = await publicApi.post('/accounts/batch/start', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchStopAccounts(payload) {
|
||||||
|
const { data } = await publicApi.post('/accounts/batch/stop', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAccounts() {
|
||||||
|
const { data } = await publicApi.post('/accounts/clear', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function takeScreenshot(accountId, payload = {}) {
|
||||||
|
const { data } = await publicApi.post(`/accounts/${accountId}/screenshot`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
12
app-frontend/src/api/announcements.js
Normal file
12
app-frontend/src/api/announcements.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchActiveAnnouncement() {
|
||||||
|
const { data } = await publicApi.get('/announcements/active')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissAnnouncement(announcementId) {
|
||||||
|
const { data } = await publicApi.post(`/announcements/${announcementId}/dismiss`, {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
46
app-frontend/src/api/auth.js
Normal file
46
app-frontend/src/api/auth.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchEmailVerifyStatus() {
|
||||||
|
const { data } = await publicApi.get('/email/verify-status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCaptcha() {
|
||||||
|
const { data } = await publicApi.post('/generate_captcha', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(payload) {
|
||||||
|
const { data } = await publicApi.post('/login', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function passkeyLoginOptions(payload) {
|
||||||
|
const { data } = await publicApi.post('/passkeys/login/options', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function passkeyLoginVerify(payload) {
|
||||||
|
const { data } = await publicApi.post('/passkeys/login/verify', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(payload) {
|
||||||
|
const { data } = await publicApi.post('/register', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendVerifyEmail(payload) {
|
||||||
|
const { data } = await publicApi.post('/resend-verify-email', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPassword(payload) {
|
||||||
|
const { data } = await publicApi.post('/forgot-password', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmPasswordReset(payload) {
|
||||||
|
const { data } = await publicApi.post('/reset-password-confirm', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
12
app-frontend/src/api/feedback.js
Normal file
12
app-frontend/src/api/feedback.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function submitFeedback(payload) {
|
||||||
|
const { data } = await publicApi.post('/feedback', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyFeedbacks() {
|
||||||
|
const { data } = await publicApi.get('/feedback')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
168
app-frontend/src/api/http.js
Normal file
168
app-frontend/src/api/http.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
let lastToastKey = ''
|
||||||
|
let lastToastAt = 0
|
||||||
|
|
||||||
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||||
|
const MAX_RETRY_COUNT = 1
|
||||||
|
const RETRY_BASE_DELAY_MS = 300
|
||||||
|
const TOAST_STYLE_ID = 'zsglpt-lite-toast-style'
|
||||||
|
|
||||||
|
function ensureToastStyle() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (document.getElementById(TOAST_STYLE_ID)) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = TOAST_STYLE_ID
|
||||||
|
style.textContent = `
|
||||||
|
.zsglpt-lite-toast-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
top: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.zsglpt-lite-toast {
|
||||||
|
max-width: min(88vw, 420px);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.24);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition: all .18s ease;
|
||||||
|
}
|
||||||
|
.zsglpt-lite-toast.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.zsglpt-lite-toast.is-error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToastWrap() {
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
ensureToastStyle()
|
||||||
|
let wrap = document.querySelector('.zsglpt-lite-toast-wrap')
|
||||||
|
if (wrap) return wrap
|
||||||
|
wrap = document.createElement('div')
|
||||||
|
wrap.className = 'zsglpt-lite-toast-wrap'
|
||||||
|
document.body.appendChild(wrap)
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLiteToast(message) {
|
||||||
|
const wrap = ensureToastWrap()
|
||||||
|
if (!wrap) return
|
||||||
|
const node = document.createElement('div')
|
||||||
|
node.className = 'zsglpt-lite-toast is-error'
|
||||||
|
node.textContent = String(message || '请求失败')
|
||||||
|
wrap.appendChild(node)
|
||||||
|
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||||
|
window.setTimeout(() => node.classList.remove('is-visible'), 2300)
|
||||||
|
window.setTimeout(() => node.remove(), 2600)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastErrorOnce(key, message, minIntervalMs = 1500) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
|
||||||
|
lastToastKey = key
|
||||||
|
lastToastAt = now
|
||||||
|
showLiteToast(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
|
||||||
|
return match ? decodeURIComponent(match[1]) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdempotentMethod(method) {
|
||||||
|
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryRequest(error) {
|
||||||
|
const config = error?.config
|
||||||
|
if (!config || config.__no_retry) return false
|
||||||
|
if (!isIdempotentMethod(config.method)) return false
|
||||||
|
|
||||||
|
const retried = Number(config.__retry_count || 0)
|
||||||
|
if (retried >= MAX_RETRY_COUNT) return false
|
||||||
|
|
||||||
|
const code = String(error?.code || '')
|
||||||
|
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
|
||||||
|
|
||||||
|
const status = Number(error?.response?.status || 0)
|
||||||
|
return RETRYABLE_STATUS.has(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryRequestOnce(error, client) {
|
||||||
|
const config = error?.config || {}
|
||||||
|
const retried = Number(config.__retry_count || 0)
|
||||||
|
config.__retry_count = retried + 1
|
||||||
|
|
||||||
|
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
|
||||||
|
await delay(backoffMs)
|
||||||
|
return client.request(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicApi = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30_000,
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
publicApi.interceptors.request.use((config) => {
|
||||||
|
const method = String(config?.method || 'GET').toUpperCase()
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
const token = getCookie('csrf_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['X-CSRF-Token'] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
publicApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (shouldRetryRequest(error)) {
|
||||||
|
return retryRequestOnce(error, publicApi)
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error?.response?.status
|
||||||
|
const payload = error?.response?.data
|
||||||
|
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
const pathname = window.location?.pathname || ''
|
||||||
|
// 登录页面不弹通知,让 LoginPage.vue 自己处理错误显示
|
||||||
|
if (!pathname.startsWith('/login')) {
|
||||||
|
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
} else if (status === 403) {
|
||||||
|
toastErrorOnce('403', message || '无权限', 5000)
|
||||||
|
} else if (error?.code === 'ECONNABORTED') {
|
||||||
|
toastErrorOnce('timeout', '请求超时', 3000)
|
||||||
|
} else if (!status) {
|
||||||
|
toastErrorOnce(`net:${message}`, message, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
41
app-frontend/src/api/schedules.js
Normal file
41
app-frontend/src/api/schedules.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchSchedules(params = {}) {
|
||||||
|
const { data } = await publicApi.get('/schedules', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSchedule(payload) {
|
||||||
|
const { data } = await publicApi.post('/schedules', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSchedule(scheduleId, payload) {
|
||||||
|
const { data } = await publicApi.put(`/schedules/${scheduleId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSchedule(scheduleId) {
|
||||||
|
const { data } = await publicApi.delete(`/schedules/${scheduleId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSchedule(scheduleId, payload) {
|
||||||
|
const { data } = await publicApi.post(`/schedules/${scheduleId}/toggle`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScheduleNow(scheduleId) {
|
||||||
|
const { data } = await publicApi.post(`/schedules/${scheduleId}/run`, {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScheduleLogs(scheduleId, params = {}) {
|
||||||
|
const { data } = await publicApi.get(`/schedules/${scheduleId}/logs`, { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearScheduleLogs(scheduleId) {
|
||||||
|
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
16
app-frontend/src/api/screenshots.js
Normal file
16
app-frontend/src/api/screenshots.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchScreenshots(params = {}) {
|
||||||
|
const { data } = await publicApi.get('/screenshots', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteScreenshot(filename) {
|
||||||
|
const { data } = await publicApi.delete(`/screenshots/${encodeURIComponent(filename)}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearScreenshots() {
|
||||||
|
const { data } = await publicApi.post('/screenshots/clear', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
71
app-frontend/src/api/settings.js
Normal file
71
app-frontend/src/api/settings.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchUserEmail() {
|
||||||
|
const { data } = await publicApi.get('/user/email')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindEmail(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/bind-email', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbindEmail() {
|
||||||
|
const { data } = await publicApi.post('/user/unbind-email', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailNotify() {
|
||||||
|
const { data } = await publicApi.get('/user/email-notify')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmailNotify(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/email-notify', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/password', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKdocsSettings() {
|
||||||
|
const { data } = await publicApi.get('/user/kdocs')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKdocsSettings(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/kdocs', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKdocsStatus() {
|
||||||
|
const { data } = await publicApi.get('/kdocs/status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserPasskeys() {
|
||||||
|
const { data } = await publicApi.get('/user/passkeys')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserPasskeyOptions(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/passkeys/register/options', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserPasskeyVerify(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/passkeys/register/verify', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserPasskey(passkeyId) {
|
||||||
|
const { data } = await publicApi.delete(`/user/passkeys/${passkeyId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportUserPasskeyClientError(payload) {
|
||||||
|
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
7
app-frontend/src/api/stats.js
Normal file
7
app-frontend/src/api/stats.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchRunStats() {
|
||||||
|
const { data } = await publicApi.get('/run_stats')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
12
app-frontend/src/api/user.js
Normal file
12
app-frontend/src/api/user.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { publicApi } from './http'
|
||||||
|
|
||||||
|
export async function fetchVipInfo() {
|
||||||
|
const { data } = await publicApi.get('/user/vip')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const { data } = await publicApi.post('/logout', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
15
app-frontend/src/composables/useSocket.js
Normal file
15
app-frontend/src/composables/useSocket.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { io } from 'socket.io-client'
|
||||||
|
|
||||||
|
let socketInstance = null
|
||||||
|
|
||||||
|
export function useSocket() {
|
||||||
|
if (socketInstance) return socketInstance
|
||||||
|
|
||||||
|
socketInstance = io({
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return socketInstance
|
||||||
|
}
|
||||||
|
|
||||||
1138
app-frontend/src/layouts/AppLayout.vue
Normal file
1138
app-frontend/src/layouts/AppLayout.vue
Normal file
File diff suppressed because it is too large
Load Diff
6
app-frontend/src/login-main.js
Normal file
6
app-frontend/src/login-main.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
import LoginPage from './pages/LoginPage.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(LoginPage).mount('#app')
|
||||||
10
app-frontend/src/main.js
Normal file
10
app-frontend/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||||
1059
app-frontend/src/pages/AccountsPage.vue
Normal file
1059
app-frontend/src/pages/AccountsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
945
app-frontend/src/pages/LoginPage.vue
Normal file
945
app-frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
captcha: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const needCaptcha = ref(false)
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const captchaSession = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const passkeyLoading = ref(false)
|
||||||
|
|
||||||
|
const emailEnabled = ref(false)
|
||||||
|
const registerVerifyEnabled = ref(false)
|
||||||
|
|
||||||
|
const noticeType = ref('')
|
||||||
|
const noticeText = ref('')
|
||||||
|
|
||||||
|
const forgotOpen = ref(false)
|
||||||
|
const resendOpen = ref(false)
|
||||||
|
|
||||||
|
const forgotForm = reactive({
|
||||||
|
username: '',
|
||||||
|
captcha: '',
|
||||||
|
})
|
||||||
|
const forgotCaptchaImage = ref('')
|
||||||
|
const forgotCaptchaSession = ref('')
|
||||||
|
const forgotLoading = ref(false)
|
||||||
|
const forgotHint = ref('')
|
||||||
|
const forgotError = ref('')
|
||||||
|
|
||||||
|
const resendForm = reactive({
|
||||||
|
email: '',
|
||||||
|
captcha: '',
|
||||||
|
})
|
||||||
|
const resendCaptchaImage = ref('')
|
||||||
|
const resendCaptchaSession = ref('')
|
||||||
|
const resendLoading = ref(false)
|
||||||
|
const resendError = ref('')
|
||||||
|
|
||||||
|
const showResendLink = computed(() => true)
|
||||||
|
const verifyStatusLoaded = ref(false)
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
|
||||||
|
return match ? decodeURIComponent(match[1]) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(message, status, data) {
|
||||||
|
super(message || '请求失败')
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.response = {
|
||||||
|
status: Number(status || 0),
|
||||||
|
data: data || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest(path, options = {}) {
|
||||||
|
const method = String(options.method || 'GET').toUpperCase()
|
||||||
|
const headers = {
|
||||||
|
...(options.headers || {}),
|
||||||
|
}
|
||||||
|
const hasBody = Object.prototype.hasOwnProperty.call(options, 'body')
|
||||||
|
if (hasBody && !headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
const token = getCookie('csrf_token')
|
||||||
|
if (token) {
|
||||||
|
headers['X-CSRF-Token'] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
body: hasBody ? JSON.stringify(options.body ?? {}) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = {}
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
data = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(data?.error || data?.message || `请求失败 (${response.status})`, response.status, data)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchEmailVerifyStatus = () => apiRequest('/email/verify-status')
|
||||||
|
const generateCaptcha = () => apiRequest('/generate_captcha', { method: 'POST', body: {} })
|
||||||
|
const loginRequest = (payload) => apiRequest('/login', { method: 'POST', body: payload || {} })
|
||||||
|
const passkeyLoginOptions = (payload) => apiRequest('/passkeys/login/options', { method: 'POST', body: payload || {} })
|
||||||
|
const passkeyLoginVerify = (payload) => apiRequest('/passkeys/login/verify', { method: 'POST', body: payload || {} })
|
||||||
|
const resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
|
||||||
|
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
|
||||||
|
|
||||||
|
function base64UrlToUint8Array(base64url) {
|
||||||
|
const value = String(base64url || '')
|
||||||
|
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||||
|
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const raw = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
bytes[i] = raw.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToBase64Url(input) {
|
||||||
|
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
.btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicKeyOptions(options) {
|
||||||
|
if (!options || typeof options !== 'object') {
|
||||||
|
throw new Error('Passkey参数无效')
|
||||||
|
}
|
||||||
|
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequestOptions(rawOptions) {
|
||||||
|
const options = normalizePublicKeyOptions(rawOptions)
|
||||||
|
const normalized = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToUint8Array(options.challenge),
|
||||||
|
}
|
||||||
|
if (Array.isArray(options.allowCredentials)) {
|
||||||
|
normalized.allowCredentials = options.allowCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToUint8Array(item.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCredential(credential) {
|
||||||
|
const response = credential?.response || {}
|
||||||
|
const output = {
|
||||||
|
id: credential?.id,
|
||||||
|
rawId: uint8ArrayToBase64Url(credential?.rawId),
|
||||||
|
type: credential?.type,
|
||||||
|
authenticatorAttachment: credential?.authenticatorAttachment || undefined,
|
||||||
|
response: {},
|
||||||
|
}
|
||||||
|
if (response.clientDataJSON) output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||||
|
if (response.authenticatorData) output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||||
|
if (response.signature) output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||||
|
if (response.userHandle) {
|
||||||
|
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||||
|
} else {
|
||||||
|
output.response.userHandle = null
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasskeyAvailable() {
|
||||||
|
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateWithPasskey(rawOptions) {
|
||||||
|
const publicKey = toRequestOptions(rawOptions)
|
||||||
|
const credential = await navigator.credentials.get({ publicKey })
|
||||||
|
return serializeCredential(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVerifyStatus() {
|
||||||
|
if (verifyStatusLoaded.value) return
|
||||||
|
try {
|
||||||
|
const status = await fetchEmailVerifyStatus()
|
||||||
|
emailEnabled.value = Boolean(status?.email_enabled)
|
||||||
|
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||||||
|
} catch {
|
||||||
|
emailEnabled.value = false
|
||||||
|
registerVerifyEnabled.value = false
|
||||||
|
} finally {
|
||||||
|
verifyStatusLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotice(type, text) {
|
||||||
|
noticeType.value = String(type || '')
|
||||||
|
noticeText.value = String(text || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNotice() {
|
||||||
|
noticeType.value = ''
|
||||||
|
noticeText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLoginCaptcha() {
|
||||||
|
try {
|
||||||
|
const data = await generateCaptcha()
|
||||||
|
captchaSession.value = data?.session_id || ''
|
||||||
|
captchaImage.value = data?.captcha_image || ''
|
||||||
|
form.captcha = ''
|
||||||
|
} catch {
|
||||||
|
captchaSession.value = ''
|
||||||
|
captchaImage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEmailResetCaptcha() {
|
||||||
|
try {
|
||||||
|
const data = await generateCaptcha()
|
||||||
|
forgotCaptchaSession.value = data?.session_id || ''
|
||||||
|
forgotCaptchaImage.value = data?.captcha_image || ''
|
||||||
|
forgotForm.captcha = ''
|
||||||
|
} catch {
|
||||||
|
forgotCaptchaSession.value = ''
|
||||||
|
forgotCaptchaImage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshResendCaptcha() {
|
||||||
|
try {
|
||||||
|
const data = await generateCaptcha()
|
||||||
|
resendCaptchaSession.value = data?.session_id || ''
|
||||||
|
resendCaptchaImage.value = data?.captcha_image || ''
|
||||||
|
resendForm.captcha = ''
|
||||||
|
} catch {
|
||||||
|
resendCaptchaSession.value = ''
|
||||||
|
resendCaptchaImage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectAfterLogin() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search || '')
|
||||||
|
const next = String(urlParams.get('next') || '').trim()
|
||||||
|
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = safeNext || '/app'
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
clearNotice()
|
||||||
|
|
||||||
|
if (!form.username.trim() || !form.password.trim()) {
|
||||||
|
setNotice('error', '用户名和密码不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (needCaptcha.value && !form.captcha.trim()) {
|
||||||
|
setNotice('error', '请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const username = form.username.trim()
|
||||||
|
await loginRequest({
|
||||||
|
username,
|
||||||
|
password: form.password,
|
||||||
|
captcha_session: captchaSession.value,
|
||||||
|
captcha: form.captcha.trim(),
|
||||||
|
need_captcha: needCaptcha.value,
|
||||||
|
})
|
||||||
|
setNotice('success', '登录成功,正在跳转...')
|
||||||
|
redirectAfterLogin()
|
||||||
|
} catch (e) {
|
||||||
|
const status = e?.response?.status
|
||||||
|
const data = e?.response?.data
|
||||||
|
const message = data?.error || data?.message || '登录失败'
|
||||||
|
|
||||||
|
setNotice('error', message)
|
||||||
|
|
||||||
|
if (data?.need_captcha) {
|
||||||
|
needCaptcha.value = true
|
||||||
|
await refreshLoginCaptcha()
|
||||||
|
} else if (needCaptcha.value && status === 400) {
|
||||||
|
await refreshLoginCaptcha()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPasskeyLogin() {
|
||||||
|
clearNotice()
|
||||||
|
|
||||||
|
const username = form.username.trim()
|
||||||
|
if (!isPasskeyAvailable()) {
|
||||||
|
setNotice('error', '当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeyLoading.value = true
|
||||||
|
try {
|
||||||
|
const optionsRes = await passkeyLoginOptions(username ? { username } : {})
|
||||||
|
const credential = await authenticateWithPasskey(optionsRes?.publicKey || {})
|
||||||
|
await passkeyLoginVerify(username ? { username, credential } : { credential })
|
||||||
|
setNotice('success', 'Passkey 登录成功,正在跳转...')
|
||||||
|
redirectAfterLogin()
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
const message =
|
||||||
|
data?.error ||
|
||||||
|
(e?.name === 'NotAllowedError' ? 'Passkey验证未完成(可能取消、超时或设备未响应)' : e?.message || 'Passkey登录失败')
|
||||||
|
setNotice('error', message)
|
||||||
|
} finally {
|
||||||
|
passkeyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openForgot() {
|
||||||
|
await loadVerifyStatus()
|
||||||
|
forgotOpen.value = true
|
||||||
|
forgotHint.value = ''
|
||||||
|
forgotError.value = ''
|
||||||
|
forgotForm.username = ''
|
||||||
|
forgotForm.captcha = ''
|
||||||
|
await refreshEmailResetCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForgot() {
|
||||||
|
forgotError.value = ''
|
||||||
|
forgotHint.value = ''
|
||||||
|
|
||||||
|
if (!emailEnabled.value) {
|
||||||
|
forgotError.value = '邮件功能未启用,请联系管理员重置密码。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = forgotForm.username.trim()
|
||||||
|
if (!username) {
|
||||||
|
forgotError.value = '请输入用户名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!forgotForm.captcha.trim()) {
|
||||||
|
forgotError.value = '请输入验证码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forgotLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await forgotPassword({
|
||||||
|
username,
|
||||||
|
captcha_session: forgotCaptchaSession.value,
|
||||||
|
captcha: forgotForm.captcha.trim(),
|
||||||
|
})
|
||||||
|
setNotice('success', res?.message || '已发送重置邮件')
|
||||||
|
forgotOpen.value = false
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
const message = data?.error || '发送失败'
|
||||||
|
if (data?.code === 'email_not_bound') {
|
||||||
|
forgotHint.value = message
|
||||||
|
} else {
|
||||||
|
forgotError.value = message
|
||||||
|
}
|
||||||
|
await refreshEmailResetCaptcha()
|
||||||
|
} finally {
|
||||||
|
forgotLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openResend() {
|
||||||
|
await loadVerifyStatus()
|
||||||
|
if (!registerVerifyEnabled.value) {
|
||||||
|
setNotice('error', '当前未启用注册邮箱验证,无需重发验证邮件。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resendOpen.value = true
|
||||||
|
resendForm.email = ''
|
||||||
|
resendForm.captcha = ''
|
||||||
|
resendError.value = ''
|
||||||
|
await refreshResendCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResend() {
|
||||||
|
resendError.value = ''
|
||||||
|
|
||||||
|
const email = resendForm.email.trim()
|
||||||
|
if (!email) {
|
||||||
|
resendError.value = '请输入邮箱'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!resendForm.captcha.trim()) {
|
||||||
|
resendError.value = '请输入验证码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resendLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await resendVerifyEmail({
|
||||||
|
email,
|
||||||
|
captcha_session: resendCaptchaSession.value,
|
||||||
|
captcha: resendForm.captcha.trim(),
|
||||||
|
})
|
||||||
|
setNotice('success', res?.message || '验证邮件已发送,请查收')
|
||||||
|
resendOpen.value = false
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
resendError.value = data?.error || '发送失败'
|
||||||
|
await refreshResendCaptcha()
|
||||||
|
} finally {
|
||||||
|
resendLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRegister() {
|
||||||
|
window.location.href = '/register'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (needCaptcha.value) {
|
||||||
|
await refreshLoginCaptcha()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<span class="login-badge">用户登录</span>
|
||||||
|
<h1>用户登录系统</h1>
|
||||||
|
<p>知识管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="noticeText" class="notice" :class="noticeType === 'success' ? 'is-success' : 'is-error'">
|
||||||
|
{{ noticeText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">用户账号</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="form.username"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
class="text-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="needCaptcha" class="form-group">
|
||||||
|
<label for="captcha">验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input
|
||||||
|
id="captcha"
|
||||||
|
v-model="form.captcha"
|
||||||
|
class="text-input captcha-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="captchaImage"
|
||||||
|
class="captcha-img"
|
||||||
|
:src="captchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
title="点击刷新"
|
||||||
|
@click="refreshLoginCaptcha"
|
||||||
|
/>
|
||||||
|
<button type="button" class="captcha-refresh" @click="refreshLoginCaptcha">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
|
||||||
|
{{ loading ? '登录中...' : '登录系统' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-passkey" :disabled="passkeyLoading" @click="onPasskeyLogin">
|
||||||
|
{{ passkeyLoading ? 'Passkey验证中...' : '使用 Passkey 登录' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="action-links">
|
||||||
|
<button type="button" class="link-btn" @click="openForgot">忘记密码?</button>
|
||||||
|
<button v-if="showResendLink" type="button" class="link-btn" @click="openResend">重发验证邮件</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="register-row">
|
||||||
|
<span>还没有账号?</span>
|
||||||
|
<button type="button" class="link-btn" @click="goRegister">立即注册</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="forgotOpen" class="modal-mask" @click.self="forgotOpen = false">
|
||||||
|
<section class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>找回密码</h3>
|
||||||
|
<button type="button" class="modal-close" @click="forgotOpen = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="modal-tip" :class="{ warn: !emailEnabled }">
|
||||||
|
{{
|
||||||
|
emailEnabled
|
||||||
|
? '输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。'
|
||||||
|
: '邮件功能未启用,无法通过邮箱找回密码。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="forgotHint" class="modal-tip warn">{{ forgotHint }}</p>
|
||||||
|
<p v-if="forgotError" class="modal-tip error">{{ forgotError }}</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="forgot-username">用户名</label>
|
||||||
|
<input id="forgot-username" v-model="forgotForm.username" class="text-input" placeholder="请输入用户名" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="forgot-captcha">验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input
|
||||||
|
id="forgot-captcha"
|
||||||
|
v-model="forgotForm.captcha"
|
||||||
|
class="text-input captcha-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="forgotCaptchaImage"
|
||||||
|
class="captcha-img"
|
||||||
|
:src="forgotCaptchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
title="点击刷新"
|
||||||
|
@click="refreshEmailResetCaptcha"
|
||||||
|
/>
|
||||||
|
<button type="button" class="captcha-refresh" @click="refreshEmailResetCaptcha">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-ghost" @click="forgotOpen = false">取消</button>
|
||||||
|
<button type="button" class="btn-login" :disabled="forgotLoading || !emailEnabled" @click="submitForgot">
|
||||||
|
{{ forgotLoading ? '发送中...' : '发送重置邮件' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resendOpen" class="modal-mask" @click.self="resendOpen = false">
|
||||||
|
<section class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>重发验证邮件</h3>
|
||||||
|
<button type="button" class="modal-close" @click="resendOpen = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="modal-tip">用于注册邮箱验证:请输入邮箱并完成验证码。</p>
|
||||||
|
<p v-if="resendError" class="modal-tip error">{{ resendError }}</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="resend-email">邮箱</label>
|
||||||
|
<input id="resend-email" v-model="resendForm.email" class="text-input" placeholder="name@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="resend-captcha">验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input
|
||||||
|
id="resend-captcha"
|
||||||
|
v-model="resendForm.captcha"
|
||||||
|
class="text-input captcha-input"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="resendCaptchaImage"
|
||||||
|
class="captcha-img"
|
||||||
|
:src="resendCaptchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
title="点击刷新"
|
||||||
|
@click="refreshResendCaptcha"
|
||||||
|
/>
|
||||||
|
<button type="button" class="captcha-refresh" @click="refreshResendCaptcha">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-ghost" @click="resendOpen = false">取消</button>
|
||||||
|
<button type="button" class="btn-login" :disabled="resendLoading" @click="submitResend">
|
||||||
|
{{ resendLoading ? '发送中...' : '发送' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #eef2ff 0%, #f6f7fb 45%, #ecfeff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(800px 500px at 15% 20%, rgba(59, 130, 246, 0.18), transparent 60%),
|
||||||
|
radial-gradient(700px 420px at 85% 70%, rgba(124, 58, 237, 0.16), transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 60px rgba(17, 24, 39, 0.15);
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||||
|
padding: 36px 30px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #1d4ed8;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.is-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.is-success {
|
||||||
|
color: #065f46;
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.18);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.8);
|
||||||
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-passkey {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-passkey:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-passkey:disabled,
|
||||||
|
.btn-login:disabled,
|
||||||
|
.btn-ghost:disabled,
|
||||||
|
.captcha-refresh:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-links {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-refresh {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-refresh:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(560px, 96vw);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||||
|
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.28);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.16);
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tip {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1e3a8a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tip.warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tip.error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
min-width: 86px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.2);
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-page {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 26px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-refresh {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
app-frontend/src/pages/RegisterPage.vue
Normal file
283
app-frontend/src/pages/RegisterPage.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { fetchEmailVerifyStatus, generateCaptcha, register } from '../api/auth'
|
||||||
|
import { validateStrongPassword } from '../utils/password'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
email: '',
|
||||||
|
captcha: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emailVerifyEnabled = ref(false)
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const captchaSession = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const errorText = ref('')
|
||||||
|
const successTitle = ref('')
|
||||||
|
const successDesc = ref('')
|
||||||
|
|
||||||
|
const emailLabel = computed(() => (emailVerifyEnabled.value ? '邮箱 *' : '邮箱(可选)'))
|
||||||
|
const emailHint = computed(() => (emailVerifyEnabled.value ? '必填,用于账号验证' : '选填,用于找回密码和接收通知'))
|
||||||
|
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
try {
|
||||||
|
const data = await generateCaptcha()
|
||||||
|
captchaSession.value = data?.session_id || ''
|
||||||
|
captchaImage.value = data?.captcha_image || ''
|
||||||
|
form.captcha = ''
|
||||||
|
} catch {
|
||||||
|
captchaSession.value = ''
|
||||||
|
captchaImage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmailVerifyStatus() {
|
||||||
|
try {
|
||||||
|
const data = await fetchEmailVerifyStatus()
|
||||||
|
emailVerifyEnabled.value = Boolean(data?.register_verify_enabled)
|
||||||
|
} catch {
|
||||||
|
emailVerifyEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAlerts() {
|
||||||
|
errorText.value = ''
|
||||||
|
successTitle.value = ''
|
||||||
|
successDesc.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
clearAlerts()
|
||||||
|
|
||||||
|
const username = form.username.trim()
|
||||||
|
const password = form.password
|
||||||
|
const confirmPassword = form.confirm_password
|
||||||
|
const email = form.email.trim()
|
||||||
|
const captcha = form.captcha.trim()
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
errorText.value = '用户名至少3个字符'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const passwordCheck = validateStrongPassword(password)
|
||||||
|
if (!passwordCheck.ok) {
|
||||||
|
errorText.value = passwordCheck.message || '密码格式不正确'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
errorText.value = '两次输入的密码不一致'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (emailVerifyEnabled.value && !email) {
|
||||||
|
errorText.value = '请填写邮箱地址用于账号验证'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (email && !email.includes('@')) {
|
||||||
|
errorText.value = '邮箱格式不正确'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!captcha) {
|
||||||
|
errorText.value = '请输入验证码'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await register({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
captcha_session: captchaSession.value,
|
||||||
|
captcha,
|
||||||
|
})
|
||||||
|
|
||||||
|
successTitle.value = res?.message || '注册成功'
|
||||||
|
successDesc.value = res?.need_verify ? '请检查您的邮箱(包括垃圾邮件文件夹)' : ''
|
||||||
|
ElMessage.success('注册成功')
|
||||||
|
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.confirm_password = ''
|
||||||
|
form.email = ''
|
||||||
|
form.captcha = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}, 3000)
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
errorText.value = data?.error || '注册失败'
|
||||||
|
ElMessage.error(errorText.value)
|
||||||
|
await refreshCaptcha()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCaptcha()
|
||||||
|
await loadEmailVerifyStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-title">知识管理平台</div>
|
||||||
|
<div class="brand-sub app-muted">用户注册</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert v-if="errorText" type="error" :closable="false" :title="errorText" show-icon class="alert" />
|
||||||
|
<el-alert
|
||||||
|
v-if="successTitle"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
:title="successTitle"
|
||||||
|
:description="successDesc"
|
||||||
|
show-icon
|
||||||
|
class="alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="用户名 *">
|
||||||
|
<el-input v-model="form.username" placeholder="至少3个字符" autocomplete="username" />
|
||||||
|
<div class="hint app-muted">至少3个字符</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码 *">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="至少8位且包含字母和数字"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="hint app-muted">至少8位且包含字母和数字</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码 *">
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirm_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="emailLabel">
|
||||||
|
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" />
|
||||||
|
<div class="hint app-muted">{{ emailHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="验证码 *">
|
||||||
|
<div class="captcha-row">
|
||||||
|
<el-input v-model="form.captcha" placeholder="请输入验证码" @keyup.enter="onSubmit" />
|
||||||
|
<img
|
||||||
|
v-if="captchaImage"
|
||||||
|
class="captcha-img"
|
||||||
|
:src="captchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
title="点击刷新"
|
||||||
|
@click="refreshCaptcha"
|
||||||
|
/>
|
||||||
|
<el-button @click="refreshCaptcha">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-button type="primary" class="submit-btn" :loading="loading" @click="onSubmit">注册</el-button>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<span class="app-muted">已有账号?</span>
|
||||||
|
<el-button link type="primary" @click="goLogin">立即登录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
box-shadow: var(--app-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
210
app-frontend/src/pages/ResetPasswordPage.vue
Normal file
210
app-frontend/src/pages/ResetPasswordPage.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { confirmPasswordReset } from '../api/auth'
|
||||||
|
import { validateStrongPassword } from '../utils/password'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const token = ref(String(route.params.token || ''))
|
||||||
|
const valid = ref(true)
|
||||||
|
const invalidMessage = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const successText = ref('')
|
||||||
|
|
||||||
|
const redirectSeconds = ref(0)
|
||||||
|
let redirectTimer = null
|
||||||
|
|
||||||
|
function loadInitialState() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const state = window.__APP_INITIAL_STATE__
|
||||||
|
if (!state || typeof state !== 'object') return null
|
||||||
|
window.__APP_INITIAL_STATE__ = null
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = computed(() => Boolean(valid.value && token.value && !successText.value))
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRedirect() {
|
||||||
|
redirectSeconds.value = 3
|
||||||
|
redirectTimer = window.setInterval(() => {
|
||||||
|
redirectSeconds.value -= 1
|
||||||
|
if (redirectSeconds.value <= 0) {
|
||||||
|
window.clearInterval(redirectTimer)
|
||||||
|
redirectTimer = null
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
const newPassword = form.newPassword
|
||||||
|
const confirmPassword = form.confirmPassword
|
||||||
|
|
||||||
|
const check = validateStrongPassword(newPassword)
|
||||||
|
if (!check.ok) {
|
||||||
|
ElMessage.error(check.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
ElMessage.error('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await confirmPasswordReset({ token: token.value, new_password: newPassword })
|
||||||
|
successText.value = '密码重置成功!3秒后跳转到登录页面...'
|
||||||
|
ElMessage.success('密码重置成功')
|
||||||
|
startRedirect()
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '重置失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const init = loadInitialState()
|
||||||
|
if (init?.page === 'reset_password') {
|
||||||
|
token.value = String(init?.token || token.value || '')
|
||||||
|
valid.value = Boolean(init?.valid)
|
||||||
|
invalidMessage.value =
|
||||||
|
init?.error_message || (valid.value ? '' : '重置链接无效或已过期,请重新申请密码重置')
|
||||||
|
} else if (!token.value) {
|
||||||
|
valid.value = false
|
||||||
|
invalidMessage.value = '重置链接无效或已过期,请重新申请密码重置'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (redirectTimer) window.clearInterval(redirectTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-title">知识管理平台</div>
|
||||||
|
<div class="brand-sub app-muted">重置密码</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!valid">
|
||||||
|
<el-alert type="error" :closable="false" title="链接已失效" :description="invalidMessage" show-icon />
|
||||||
|
<div class="actions">
|
||||||
|
<el-button type="primary" @click="goLogin">返回登录</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<el-alert
|
||||||
|
v-if="successText"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
title="重置成功"
|
||||||
|
:description="successText"
|
||||||
|
show-icon
|
||||||
|
class="alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="新密码(至少8位且包含字母和数字)">
|
||||||
|
<el-input
|
||||||
|
v-model="form.newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-button type="primary" class="submit-btn" :loading="loading" :disabled="!canSubmit" @click="onSubmit">
|
||||||
|
确认重置
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<el-button link type="primary" @click="goLogin">返回登录</el-button>
|
||||||
|
<span v-if="redirectSeconds > 0" class="app-muted">{{ redirectSeconds }} 秒后自动跳转…</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
box-shadow: var(--app-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
703
app-frontend/src/pages/SchedulesPage.vue
Normal file
703
app-frontend/src/pages/SchedulesPage.vue
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { fetchAccounts } from '../api/accounts'
|
||||||
|
import {
|
||||||
|
clearScheduleLogs,
|
||||||
|
createSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
fetchScheduleLogs,
|
||||||
|
fetchSchedules,
|
||||||
|
runScheduleNow,
|
||||||
|
toggleSchedule,
|
||||||
|
updateSchedule,
|
||||||
|
} from '../api/schedules'
|
||||||
|
import { useUserStore } from '../stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const schedules = ref([])
|
||||||
|
const schedulePage = ref(1)
|
||||||
|
const scheduleTotal = ref(0)
|
||||||
|
const schedulePageSize = 12
|
||||||
|
|
||||||
|
const accountsLoading = ref(false)
|
||||||
|
const accountOptions = ref([])
|
||||||
|
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editorSaving = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
|
||||||
|
const logsOpen = ref(false)
|
||||||
|
const logsLoading = ref(false)
|
||||||
|
const logs = ref([])
|
||||||
|
const logsSchedule = ref(null)
|
||||||
|
|
||||||
|
const vipModalOpen = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
schedule_time: '08:00',
|
||||||
|
weekdays: ['1', '2', '3', '4', '5'],
|
||||||
|
browse_type: '应读',
|
||||||
|
enable_screenshot: true,
|
||||||
|
random_delay: false,
|
||||||
|
account_ids: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const browseTypeOptions = [
|
||||||
|
{ label: '应读', value: '应读' },
|
||||||
|
{ label: '注册前未读', value: '注册前未读' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeBrowseType(value) {
|
||||||
|
if (String(value) === '注册前未读') return '注册前未读'
|
||||||
|
return '应读'
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ label: '周一', value: '1' },
|
||||||
|
{ label: '周二', value: '2' },
|
||||||
|
{ label: '周三', value: '3' },
|
||||||
|
{ label: '周四', value: '4' },
|
||||||
|
{ label: '周五', value: '5' },
|
||||||
|
{ label: '周六', value: '6' },
|
||||||
|
{ label: '周日', value: '7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const canUseSchedule = computed(() => userStore.isVip)
|
||||||
|
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
|
||||||
|
|
||||||
|
function normalizeTime(value) {
|
||||||
|
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const hour = Number(match[1])
|
||||||
|
const minute = Number(match[2])
|
||||||
|
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdaysText(textOrArray) {
|
||||||
|
const raw = Array.isArray(textOrArray) ? textOrArray : String(textOrArray || '').split(',').filter(Boolean)
|
||||||
|
const map = Object.fromEntries(weekdayOptions.map((w) => [w.value, w.label]))
|
||||||
|
return raw.map((d) => map[String(d)] || String(d)).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
accountsLoading.value = true
|
||||||
|
try {
|
||||||
|
const list = await fetchAccounts({ refresh: false })
|
||||||
|
accountOptions.value = (list || []).map((acc) => ({ label: acc.username, value: acc.id }))
|
||||||
|
} catch {
|
||||||
|
accountOptions.value = []
|
||||||
|
} finally {
|
||||||
|
accountsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadSchedulesAfterMutate() {
|
||||||
|
if (schedulePage.value > 1 && schedules.value.length <= 1) {
|
||||||
|
schedulePage.value -= 1
|
||||||
|
}
|
||||||
|
await loadSchedules()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSchedulePageChange(page) {
|
||||||
|
schedulePage.value = page
|
||||||
|
await loadSchedules()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchedules() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
limit: schedulePageSize,
|
||||||
|
offset: (schedulePage.value - 1) * schedulePageSize,
|
||||||
|
}
|
||||||
|
const payload = await fetchSchedules(params)
|
||||||
|
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||||
|
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
|
||||||
|
schedules.value = rawItems.map((s) => ({
|
||||||
|
...s,
|
||||||
|
browse_type: normalizeBrowseType(s?.browse_type),
|
||||||
|
}))
|
||||||
|
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) window.location.href = '/login'
|
||||||
|
schedules.value = []
|
||||||
|
scheduleTotal.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.schedule_time = '08:00'
|
||||||
|
form.weekdays = ['1', '2', '3', '4', '5']
|
||||||
|
form.browse_type = '应读'
|
||||||
|
form.enable_screenshot = true
|
||||||
|
form.random_delay = false
|
||||||
|
form.account_ids = []
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(schedule) {
|
||||||
|
editingId.value = schedule.id
|
||||||
|
form.name = schedule.name || ''
|
||||||
|
form.schedule_time = normalizeTime(schedule.schedule_time) || '08:00'
|
||||||
|
form.weekdays = String(schedule.weekdays || '')
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((v) => String(v))
|
||||||
|
if (form.weekdays.length === 0) form.weekdays = ['1', '2', '3', '4', '5']
|
||||||
|
form.browse_type = normalizeBrowseType(schedule.browse_type)
|
||||||
|
form.enable_screenshot = Number(schedule.enable_screenshot ?? 1) !== 0
|
||||||
|
form.random_delay = Number(schedule.random_delay ?? 0) !== 0
|
||||||
|
form.account_ids = Array.isArray(schedule.account_ids) ? schedule.account_ids.slice() : []
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule() {
|
||||||
|
if (!canUseSchedule.value) {
|
||||||
|
vipModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTime = normalizeTime(form.schedule_time)
|
||||||
|
if (!normalizedTime) {
|
||||||
|
ElMessage.error('时间格式错误,请使用 HH:MM')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.weekdays || form.weekdays.length === 0) {
|
||||||
|
ElMessage.warning('请选择至少一个执行日期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editorSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim() || '我的定时任务',
|
||||||
|
schedule_time: normalizedTime,
|
||||||
|
weekdays: form.weekdays.join(','),
|
||||||
|
browse_type: form.browse_type,
|
||||||
|
enable_screenshot: form.enable_screenshot ? 1 : 0,
|
||||||
|
random_delay: form.random_delay ? 1 : 0,
|
||||||
|
account_ids: form.account_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateSchedule(editingId.value, payload)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createSchedule(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
schedulePage.value = 1
|
||||||
|
}
|
||||||
|
editorOpen.value = false
|
||||||
|
await loadSchedules()
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '保存失败')
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(schedule) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除定时任务「${schedule.name || '未命名任务'}」吗?`, '删除任务', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteSchedule(schedule.id)
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await reloadSchedulesAfterMutate()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(schedule, enabled) {
|
||||||
|
if (!canUseSchedule.value) {
|
||||||
|
vipModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await toggleSchedule(schedule.id, { enabled })
|
||||||
|
if (res?.success) {
|
||||||
|
schedule.enabled = enabled ? 1 : 0
|
||||||
|
ElMessage.success(enabled ? '已启用' : '已禁用')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunNow(schedule) {
|
||||||
|
if (!canUseSchedule.value) {
|
||||||
|
vipModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await runScheduleNow(schedule.id)
|
||||||
|
if (res?.success) ElMessage.success(res?.message || '已开始执行')
|
||||||
|
else ElMessage.error(res?.error || '执行失败')
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '执行失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogs(schedule) {
|
||||||
|
logsSchedule.value = schedule
|
||||||
|
logsOpen.value = true
|
||||||
|
logsLoading.value = true
|
||||||
|
try {
|
||||||
|
logs.value = await fetchScheduleLogs(schedule.id, { limit: 20 })
|
||||||
|
} catch {
|
||||||
|
logs.value = []
|
||||||
|
} finally {
|
||||||
|
logsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLogs() {
|
||||||
|
const schedule = logsSchedule.value
|
||||||
|
if (!schedule) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清空该任务的所有执行日志吗?', '清空日志', {
|
||||||
|
confirmButtonText: '清空',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await clearScheduleLogs(schedule.id)
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success(`已清空 ${res?.deleted || 0} 条日志`)
|
||||||
|
logs.value = []
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error || '操作失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(status) {
|
||||||
|
const text = String(status || '')
|
||||||
|
if (text === 'success' || text === 'completed') return 'success'
|
||||||
|
if (text === 'failed') return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const value = Number(seconds || 0)
|
||||||
|
const mins = Math.floor(value / 60)
|
||||||
|
const secs = value % 60
|
||||||
|
if (mins <= 0) return `${secs} 秒`
|
||||||
|
return `${mins} 分 ${secs} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userStore.vipInfo) {
|
||||||
|
userStore.refreshVipInfo().catch(() => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([loadAccounts(), loadSchedules()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<el-alert
|
||||||
|
v-if="!canUseSchedule"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
title="定时任务为 VIP 专属功能,升级后可使用。"
|
||||||
|
class="vip-alert"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="vip-actions">
|
||||||
|
<el-button type="primary" plain @click="vipModalOpen = true">了解VIP特权</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">定时任务</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<el-button :loading="loading" @click="loadSchedules">刷新</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canUseSchedule" @click="openCreate">新建任务</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading" :rows="6" animated />
|
||||||
|
<template v-else>
|
||||||
|
<el-empty v-if="schedules.length === 0" description="暂无定时任务" />
|
||||||
|
<div v-else class="grid">
|
||||||
|
<el-card v-for="s in schedules" :key="s.id" shadow="never" class="schedule-card" :body-style="{ padding: '14px' }">
|
||||||
|
<div class="schedule-top">
|
||||||
|
<div class="schedule-main">
|
||||||
|
<div class="schedule-title">
|
||||||
|
<span class="schedule-name">{{ s.name || '未命名任务' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-meta app-muted">
|
||||||
|
<span>⏰ {{ normalizeTime(s.schedule_time) || s.schedule_time }}</span>
|
||||||
|
<span>📅 {{ weekdaysText(s.weekdays) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-meta app-muted">
|
||||||
|
<span>📋 {{ s.browse_type || '应读' }}</span>
|
||||||
|
<span>👥 {{ (s.account_ids || []).length }} 个账号</span>
|
||||||
|
<span>{{ Number(s.enable_screenshot ?? 1) !== 0 ? '📸 截图' : '📷 不截图' }}</span>
|
||||||
|
<span v-if="Number(s.random_delay ?? 0) !== 0">🎲 随机±15分钟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="schedule-switch">
|
||||||
|
<el-switch
|
||||||
|
:model-value="Boolean(Number(s.enabled))"
|
||||||
|
:disabled="!canUseSchedule"
|
||||||
|
inline-prompt
|
||||||
|
active-text="启用"
|
||||||
|
inactive-text="停用"
|
||||||
|
@change="(val) => onToggle(s, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="schedule-actions">
|
||||||
|
<el-button size="small" type="primary" :disabled="!canUseSchedule" @click="onRunNow(s)">立即执行</el-button>
|
||||||
|
<el-button size="small" @click="openLogs(s)">日志</el-button>
|
||||||
|
<el-button size="small" :disabled="!canUseSchedule" @click="openEdit(s)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" text :disabled="!canUseSchedule" @click="onDelete(s)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="schedulePage"
|
||||||
|
:page-size="schedulePageSize"
|
||||||
|
:total="scheduleTotal"
|
||||||
|
layout="prev, pager, next, jumper, ->, total"
|
||||||
|
@current-change="onSchedulePageChange"
|
||||||
|
/>
|
||||||
|
<div class="page-hint app-muted">第 {{ schedulePage }} / {{ scheduleTotalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="editorOpen" :title="editingId ? '编辑定时任务' : '新建定时任务'" width="min(720px, 92vw)">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="任务名称">
|
||||||
|
<el-input v-model="form.name" placeholder="我的定时任务" :disabled="!canUseSchedule" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="执行时间(HH:MM)">
|
||||||
|
<el-time-picker
|
||||||
|
v-model="form.schedule_time"
|
||||||
|
placeholder="选择时间"
|
||||||
|
format="HH:mm"
|
||||||
|
value-format="HH:mm"
|
||||||
|
style="width: 180px"
|
||||||
|
:disabled="!canUseSchedule"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="执行日期">
|
||||||
|
<el-checkbox-group v-model="form.weekdays" :disabled="!canUseSchedule">
|
||||||
|
<el-checkbox v-for="w in weekdayOptions" :key="w.value" :label="w.value">{{ w.label }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="浏览类型">
|
||||||
|
<el-select v-model="form.browse_type" style="width: 160px" :disabled="!canUseSchedule">
|
||||||
|
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="截图">
|
||||||
|
<div class="switch-row">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.enable_screenshot"
|
||||||
|
:disabled="!canUseSchedule"
|
||||||
|
inline-prompt
|
||||||
|
active-text="截图"
|
||||||
|
inactive-text="不截图"
|
||||||
|
/>
|
||||||
|
<el-switch
|
||||||
|
v-model="form.random_delay"
|
||||||
|
:disabled="!canUseSchedule"
|
||||||
|
inline-prompt
|
||||||
|
active-text="随机±15分钟"
|
||||||
|
inactive-text="固定时间"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参与账号">
|
||||||
|
<el-select
|
||||||
|
v-model="form.account_ids"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择账号(可多选)"
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="accountsLoading"
|
||||||
|
:disabled="!canUseSchedule"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in accountOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editorOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="editorSaving" :disabled="!canUseSchedule" @click="saveSchedule">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="logsOpen" :title="logsSchedule ? `【${logsSchedule.name || '未命名任务'}】执行日志` : '执行日志'" width="min(760px, 92vw)">
|
||||||
|
<el-skeleton v-if="logsLoading" :rows="6" animated />
|
||||||
|
<template v-else>
|
||||||
|
<el-empty v-if="logs.length === 0" description="暂无执行日志" />
|
||||||
|
<div v-else class="logs">
|
||||||
|
<el-card v-for="log in logs" :key="log.id" shadow="never" class="log-card" :body-style="{ padding: '12px' }">
|
||||||
|
<div class="log-head">
|
||||||
|
<el-tag size="small" effect="light" :type="statusTagType(log.status)">
|
||||||
|
{{ log.status === 'failed' ? '失败' : log.status === 'running' ? '进行中' : '成功' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="app-muted">{{ log.created_at || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-body">
|
||||||
|
<div>账号数:{{ log.total_accounts || 0 }} 个</div>
|
||||||
|
<div>成功:{{ log.success_count || 0 }} 个 · 失败:{{ log.failed_count || 0 }} 个</div>
|
||||||
|
<div>耗时:{{ formatDuration(log.duration || 0) }}</div>
|
||||||
|
<div v-if="log.error_message" class="log-error">错误:{{ log.error_message }}</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="logsOpen = false">关闭</el-button>
|
||||||
|
<el-button type="danger" plain :disabled="logs.length === 0" @click="clearLogs">清空日志</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="vipModalOpen" title="VIP 特权" width="min(560px, 92vw)">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="升级 VIP 后可解锁:无限账号、优先排队、定时任务、批量操作。"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="vip-body">
|
||||||
|
<div class="vip-tip app-muted">升级方式:请通过“反馈”联系管理员开通。</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="vipModalOpen = false">我知道了</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-alert {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-body {
|
||||||
|
padding: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-tip {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-switch {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
406
app-frontend/src/pages/ScreenshotsPage.vue
Normal file
406
app-frontend/src/pages/ScreenshotsPage.vue
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const screenshots = ref([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const pageSize = 24
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
|
||||||
|
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewTitle = ref('')
|
||||||
|
|
||||||
|
function buildUrl(filename) {
|
||||||
|
return `/screenshots/${encodeURIComponent(filename)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThumbUrl(filename) {
|
||||||
|
return `/screenshots/thumb/${encodeURIComponent(filename)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (currentPage.value - 1) * pageSize,
|
||||||
|
}
|
||||||
|
const payload = await fetchScreenshots(params)
|
||||||
|
const items = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||||
|
const payloadTotal = Array.isArray(payload) ? items.length : Number(payload?.total ?? items.length)
|
||||||
|
|
||||||
|
screenshots.value = items
|
||||||
|
total.value = Number.isFinite(payloadTotal) ? Math.max(0, payloadTotal) : items.length
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) window.location.href = '/login'
|
||||||
|
screenshots.value = []
|
||||||
|
total.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageChange(page) {
|
||||||
|
currentPage.value = page
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(item) {
|
||||||
|
previewTitle.value = item.display_name || item.filename || '截图预览'
|
||||||
|
previewUrl.value = buildUrl(item.filename)
|
||||||
|
previewOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbError(event, item) {
|
||||||
|
const imageEl = event?.target
|
||||||
|
if (!imageEl) return
|
||||||
|
if (imageEl.dataset.fullLoaded === '1') return
|
||||||
|
|
||||||
|
imageEl.dataset.fullLoaded = '1'
|
||||||
|
imageEl.src = buildUrl(item.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToPngBlob(canvas) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob_failed'))), 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageElementToPngBlob(imgEl) {
|
||||||
|
if (!imgEl) throw new Error('no_image')
|
||||||
|
if (!imgEl.complete || imgEl.naturalWidth <= 0) {
|
||||||
|
if (typeof imgEl.decode === 'function') await imgEl.decode()
|
||||||
|
else {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
imgEl.addEventListener('load', resolve, { once: true })
|
||||||
|
imgEl.addEventListener('error', reject, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = imgEl.naturalWidth
|
||||||
|
canvas.height = imgEl.naturalHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('no_canvas')
|
||||||
|
ctx.drawImage(imgEl, 0, 0)
|
||||||
|
return await canvasToPngBlob(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToPng(blob) {
|
||||||
|
if (!blob) throw new Error('no_blob')
|
||||||
|
if (blob.type === 'image/png') return blob
|
||||||
|
|
||||||
|
if (typeof createImageBitmap === 'function') {
|
||||||
|
const bitmap = await createImageBitmap(blob)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = bitmap.width
|
||||||
|
canvas.height = bitmap.height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('no_canvas')
|
||||||
|
ctx.drawImage(bitmap, 0, 0)
|
||||||
|
return await canvasToPngBlob(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
if (typeof img.decode === 'function') await img.decode()
|
||||||
|
return await imageElementToPngBlob(img)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenshotUrlToPngBlob(url) {
|
||||||
|
// 复制时始终拉取原图,避免复制到缩略图
|
||||||
|
const resp = await fetch(url, { credentials: 'include', cache: 'no-store' })
|
||||||
|
if (!resp.ok) throw new Error('fetch_failed')
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const mime = resp.headers.get('Content-Type') || blob.type || ''
|
||||||
|
if (!mime.startsWith('image/')) throw new Error('not_image')
|
||||||
|
return await blobToPng(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearAll() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清空全部截图吗?', '清空截图', {
|
||||||
|
confirmButtonText: '清空',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await clearScreenshots()
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
|
||||||
|
screenshots.value = []
|
||||||
|
total.value = 0
|
||||||
|
currentPage.value = 1
|
||||||
|
previewOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(res?.error || '操作失败')
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(item) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除截图「${item.display_name || item.filename}」吗?`, '删除截图', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteScreenshot(item.filename)
|
||||||
|
if (res?.success) {
|
||||||
|
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
|
||||||
|
if (currentPage.value > 1 && screenshots.value.length <= 1) currentPage.value -= 1
|
||||||
|
await load()
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(res?.error || '删除失败')
|
||||||
|
} catch (e) {
|
||||||
|
const data = e?.response?.data
|
||||||
|
ElMessage.error(data?.error || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImage(item) {
|
||||||
|
const url = buildUrl(item.filename)
|
||||||
|
if (
|
||||||
|
!navigator.clipboard ||
|
||||||
|
typeof navigator.clipboard.write !== 'function' ||
|
||||||
|
typeof window.ClipboardItem === 'undefined'
|
||||||
|
) {
|
||||||
|
ElMessage.warning('当前环境不支持复制图片(建议使用 Chrome/Edge 并通过 HTTPS 访问);可用“下载”。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 关键点:用 Promise 形式的数据源,让 clipboard.write 在用户手势内立即发生(更稳)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': screenshotUrlToPngBlob(url),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
const pngBlob = await screenshotUrlToPngBlob(url)
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
|
}
|
||||||
|
ElMessage.success('图片已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
|
||||||
|
ElMessage.warning('复制图片失败,已复制图片链接(可直接粘贴到浏览器打开)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ElMessage.warning('复制图片失败:请确认允许剪贴板权限;可用“下载”。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(item) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = buildUrl(item.filename)
|
||||||
|
link.download = item.display_name || item.filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">截图管理</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||||
|
<el-button type="danger" plain :disabled="total === 0" @click="onClearAll">清空全部</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading" :rows="6" animated />
|
||||||
|
<template v-else>
|
||||||
|
<el-empty v-if="total === 0" description="暂无截图" />
|
||||||
|
|
||||||
|
<div v-else class="grid">
|
||||||
|
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
|
||||||
|
<img
|
||||||
|
class="shot-img"
|
||||||
|
:src="buildThumbUrl(item.filename)"
|
||||||
|
:alt="item.display_name || item.filename"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onThumbError($event, item)"
|
||||||
|
@click="openPreview(item)"
|
||||||
|
/>
|
||||||
|
<div class="shot-body">
|
||||||
|
<div class="shot-name" :title="item.display_name || item.filename">{{ item.display_name || item.filename }}</div>
|
||||||
|
<div class="shot-meta app-muted">{{ item.created || '' }}</div>
|
||||||
|
<div class="shot-actions">
|
||||||
|
<el-button size="small" text type="primary" @click="copyImage(item)">复制图片</el-button>
|
||||||
|
<el-button size="small" text @click="download(item)">下载</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="onDelete(item)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > pageSize" class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="prev, pager, next, jumper, ->, total"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
<div class="page-hint app-muted">第 {{ currentPage }} / {{ totalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
|
||||||
|
<div class="preview">
|
||||||
|
<img :src="previewUrl" :alt="previewTitle" class="preview-img" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="previewOpen = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 78vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
app-frontend/src/pages/VerifyResultPage.vue
Normal file
157
app-frontend/src/pages/VerifyResultPage.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const success = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
|
||||||
|
const primaryLabel = ref('')
|
||||||
|
const primaryUrl = ref('')
|
||||||
|
const secondaryLabel = ref('')
|
||||||
|
const secondaryUrl = ref('')
|
||||||
|
|
||||||
|
const redirectUrl = ref('')
|
||||||
|
const secondsLeft = ref(0)
|
||||||
|
let countdownTimer = null
|
||||||
|
|
||||||
|
function loadInitialState() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const state = window.__APP_INITIAL_STATE__
|
||||||
|
if (!state || typeof state !== 'object') return null
|
||||||
|
window.__APP_INITIAL_STATE__ = null
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(state) {
|
||||||
|
const ok = Boolean(state?.success)
|
||||||
|
success.value = ok
|
||||||
|
|
||||||
|
title.value = state?.title || (ok ? '验证成功' : '验证失败')
|
||||||
|
message.value =
|
||||||
|
state?.message || state?.error_message || (ok ? '操作已完成,现在可以继续使用系统。' : '操作失败,请稍后重试。')
|
||||||
|
|
||||||
|
primaryLabel.value = state?.primary_label || (ok ? '立即登录' : '重新注册')
|
||||||
|
primaryUrl.value = state?.primary_url || (ok ? '/login' : '/register')
|
||||||
|
secondaryLabel.value = state?.secondary_label || (ok ? '' : '返回登录')
|
||||||
|
secondaryUrl.value = state?.secondary_url || (ok ? '' : '/login')
|
||||||
|
|
||||||
|
redirectUrl.value = state?.redirect_url || (ok ? '/login' : '')
|
||||||
|
secondsLeft.value = Number(state?.redirect_seconds || (ok ? 5 : 0)) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSecondary = computed(() => Boolean(secondaryLabel.value && secondaryUrl.value))
|
||||||
|
const hasCountdown = computed(() => Boolean(redirectUrl.value && secondsLeft.value > 0))
|
||||||
|
|
||||||
|
async function go(url) {
|
||||||
|
if (!url) return
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
window.location.href = url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await router.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
if (!hasCountdown.value) return
|
||||||
|
countdownTimer = window.setInterval(() => {
|
||||||
|
secondsLeft.value -= 1
|
||||||
|
if (secondsLeft.value <= 0) {
|
||||||
|
window.clearInterval(countdownTimer)
|
||||||
|
countdownTimer = null
|
||||||
|
window.location.href = redirectUrl.value
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const state = loadInitialState()
|
||||||
|
normalize(state)
|
||||||
|
startCountdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (countdownTimer) window.clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-title">知识管理平台</div>
|
||||||
|
<div class="brand-sub app-muted">验证结果</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-result
|
||||||
|
:icon="success ? 'success' : 'error'"
|
||||||
|
:title="title"
|
||||||
|
:sub-title="message"
|
||||||
|
class="result"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button type="primary" @click="go(primaryUrl)">{{ primaryLabel }}</el-button>
|
||||||
|
<el-button v-if="hasSecondary" @click="go(secondaryUrl)">{{ secondaryLabel }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasCountdown" class="countdown app-muted">
|
||||||
|
{{ secondsLeft }} 秒后自动跳转...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
border-radius: var(--app-radius);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
box-shadow: var(--app-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
38
app-frontend/src/router/index.js
Normal file
38
app-frontend/src/router/index.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const LoginPage = () => import('../pages/LoginPage.vue')
|
||||||
|
const RegisterPage = () => import('../pages/RegisterPage.vue')
|
||||||
|
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
|
||||||
|
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
|
||||||
|
const AppLayout = () => import('../layouts/AppLayout.vue')
|
||||||
|
|
||||||
|
const AccountsPage = () => import('../pages/AccountsPage.vue')
|
||||||
|
const SchedulesPage = () => import('../pages/SchedulesPage.vue')
|
||||||
|
const ScreenshotsPage = () => import('../pages/ScreenshotsPage.vue')
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', redirect: '/login' },
|
||||||
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
|
{ path: '/register', name: 'register', component: RegisterPage },
|
||||||
|
{ path: '/reset-password/:token', name: 'reset_password', component: ResetPasswordPage },
|
||||||
|
{ path: '/api/verify-email/:token', name: 'verify_email', component: VerifyResultPage },
|
||||||
|
{ path: '/api/verify-bind-email/:token', name: 'verify_bind_email', component: VerifyResultPage },
|
||||||
|
{
|
||||||
|
path: '/app',
|
||||||
|
component: AppLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/app/accounts' },
|
||||||
|
{ path: 'accounts', name: 'accounts', component: AccountsPage },
|
||||||
|
{ path: 'schedules', name: 'schedules', component: SchedulesPage },
|
||||||
|
{ path: 'screenshots', name: 'screenshots', component: ScreenshotsPage },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/login' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
34
app-frontend/src/stores/user.js
Normal file
34
app-frontend/src/stores/user.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { fetchVipInfo as apiFetchVipInfo, logout as apiLogout } from '../api/user'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', {
|
||||||
|
state: () => ({
|
||||||
|
vipInfo: null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
username: (state) => state.vipInfo?.username || '',
|
||||||
|
isVip: (state) => Boolean(state.vipInfo?.is_vip),
|
||||||
|
vipDaysLeft: (state) => Number(state.vipInfo?.days_left || 0),
|
||||||
|
vipExpireTime: (state) => state.vipInfo?.expire_time || '',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async refreshVipInfo() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
this.vipInfo = await apiFetchVipInfo()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await apiLogout()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
75
app-frontend/src/style.css
Normal file
75
app-frontend/src/style.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
:root {
|
||||||
|
--app-bg: #f6f7fb;
|
||||||
|
--app-text: #111827;
|
||||||
|
--app-muted: #6b7280;
|
||||||
|
--app-border: rgba(17, 24, 39, 0.08);
|
||||||
|
--app-radius: 12px;
|
||||||
|
--app-shadow: 0 8px 24px rgba(17, 24, 39, 0.06);
|
||||||
|
|
||||||
|
/* Element Plus: switch 在白底/禁用态下更易辨识 */
|
||||||
|
--el-switch-off-color: var(--el-border-color-darker);
|
||||||
|
--el-switch-border-color: var(--el-border-color-darker);
|
||||||
|
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--app-bg);
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-muted {
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element Plus: 关闭态(inline-prompt)文字不再是白色,避免与浅灰底色“融为一体” */
|
||||||
|
.el-switch:not(.is-checked) .el-switch__core .el-switch__inner .is-icon,
|
||||||
|
.el-switch:not(.is-checked) .el-switch__core .el-switch__inner .is-text {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element Plus: 禁用态开关默认 0.6 透明度太淡,白底下容易看不见 */
|
||||||
|
.el-switch.is-disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.el-dialog {
|
||||||
|
max-width: 92vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
width: auto !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding: 0 0 6px !important;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app-frontend/src/utils/passkey.js
Normal file
153
app-frontend/src/utils/passkey.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
function ensurePublicKeyOptions(options) {
|
||||||
|
if (!options || typeof options !== 'object') {
|
||||||
|
throw new Error('Passkey参数无效')
|
||||||
|
}
|
||||||
|
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToUint8Array(base64url) {
|
||||||
|
const value = String(base64url || '')
|
||||||
|
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||||
|
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const raw = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
bytes[i] = raw.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToBase64Url(input) {
|
||||||
|
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
.btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCreationOptions(rawOptions) {
|
||||||
|
const options = ensurePublicKeyOptions(rawOptions)
|
||||||
|
const normalized = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToUint8Array(options.challenge),
|
||||||
|
user: {
|
||||||
|
...options.user,
|
||||||
|
id: base64UrlToUint8Array(options.user?.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(options.excludeCredentials)) {
|
||||||
|
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToUint8Array(item.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequestOptions(rawOptions) {
|
||||||
|
const options = ensurePublicKeyOptions(rawOptions)
|
||||||
|
const normalized = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToUint8Array(options.challenge),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(options.allowCredentials)) {
|
||||||
|
normalized.allowCredentials = options.allowCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToUint8Array(item.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCredential(credential) {
|
||||||
|
if (!credential) return null
|
||||||
|
|
||||||
|
const response = credential.response || {}
|
||||||
|
const output = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: uint8ArrayToBase64Url(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
authenticatorAttachment: credential.authenticatorAttachment || undefined,
|
||||||
|
response: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.clientDataJSON) {
|
||||||
|
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||||
|
}
|
||||||
|
if (response.attestationObject) {
|
||||||
|
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
|
||||||
|
}
|
||||||
|
if (response.authenticatorData) {
|
||||||
|
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||||
|
}
|
||||||
|
if (response.signature) {
|
||||||
|
output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.userHandle) {
|
||||||
|
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||||
|
} else {
|
||||||
|
output.response.userHandle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.getTransports === 'function') {
|
||||||
|
output.response.transports = response.getTransports() || []
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasskeyAvailable() {
|
||||||
|
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMiuiBrowser() {
|
||||||
|
const ua = String(window?.navigator?.userAgent || '')
|
||||||
|
return /MiuiBrowser|XiaoMi\/MiuiBrowser/i.test(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasskeyClientErrorMessage(error, actionLabel = 'Passkey操作') {
|
||||||
|
const name = String(error?.name || '').trim()
|
||||||
|
const message = String(error?.message || '').trim()
|
||||||
|
|
||||||
|
if (name === 'NotAllowedError') {
|
||||||
|
return `${actionLabel}未完成(可能已取消、超时或设备未响应)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'NotReadableError') {
|
||||||
|
if (/credential manager/i.test(message) && isMiuiBrowser()) {
|
||||||
|
return '当前小米浏览器与系统凭据管理器兼容性较差,请改用系统 Chrome 或 Edge 后重试。'
|
||||||
|
}
|
||||||
|
if (/credential manager/i.test(message)) {
|
||||||
|
return '系统凭据管理器返回异常,请确认已设置系统锁屏并改用系统 Chrome/Edge 后重试。'
|
||||||
|
}
|
||||||
|
return message || `${actionLabel}失败(设备读取异常)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'SecurityError') {
|
||||||
|
return '当前环境安全策略不满足 Passkey 要求,请确认使用 HTTPS 且证书有效。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || `${actionLabel}失败`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPasskey(rawOptions) {
|
||||||
|
const publicKey = toCreationOptions(rawOptions)
|
||||||
|
const credential = await navigator.credentials.create({ publicKey })
|
||||||
|
return serializeCredential(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateWithPasskey(rawOptions) {
|
||||||
|
const publicKey = toRequestOptions(rawOptions)
|
||||||
|
const credential = await navigator.credentials.get({ publicKey })
|
||||||
|
return serializeCredential(credential)
|
||||||
|
}
|
||||||
7
app-frontend/src/utils/password.js
Normal file
7
app-frontend/src/utils/password.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function validateStrongPassword(value) {
|
||||||
|
const text = String(value || '')
|
||||||
|
if (text.length < 8) return { ok: false, message: '密码长度至少8位' }
|
||||||
|
if (!/[a-zA-Z]/.test(text) || !/\d/.test(text)) return { ok: false, message: '密码必须包含字母和数字' }
|
||||||
|
return { ok: true, message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
62
app-frontend/vite.config.js
Normal file
62
app-frontend/vite.config.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||||
|
dts: false,
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||||
|
dts: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: '../static/app',
|
||||||
|
emptyOutDir: true,
|
||||||
|
manifest: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
app: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||||
|
login: fileURLToPath(new URL('./login.html', import.meta.url)),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
id.includes('/node_modules/vue/') ||
|
||||||
|
id.includes('/node_modules/@vue/') ||
|
||||||
|
id.includes('/node_modules/vue-router/') ||
|
||||||
|
id.includes('/node_modules/pinia/')
|
||||||
|
) {
|
||||||
|
return 'vendor-vue'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.includes('/node_modules/axios/')) {
|
||||||
|
return 'vendor-axios'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
id.includes('/node_modules/socket.io-client/') ||
|
||||||
|
id.includes('/node_modules/engine.io-client/') ||
|
||||||
|
id.includes('/node_modules/socket.io-parser/')
|
||||||
|
) {
|
||||||
|
return 'vendor-realtime'
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
261
app_config.py
261
app_config.py
@@ -8,46 +8,98 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
# 尝试加载.env文件(如果存在)
|
# 尝试加载.env文件(如果存在)
|
||||||
# Bug fix: 添加警告日志,避免静默失败
|
# Bug fix: 添加警告日志,避免静默失败
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
env_path = Path(__file__).parent / '.env'
|
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
if env_path.exists():
|
if env_path.exists():
|
||||||
load_dotenv(dotenv_path=env_path)
|
load_dotenv(dotenv_path=env_path)
|
||||||
print(f"✓ 已加载环境变量文件: {env_path}")
|
print(f"[OK] 已加载环境变量文件: {env_path}")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# python-dotenv未安装,记录警告
|
# python-dotenv未安装,记录警告
|
||||||
import sys
|
import sys
|
||||||
print("⚠ 警告: python-dotenv未安装,将不会加载.env文件。如需使用.env文件,请运行: pip install python-dotenv", file=sys.stderr)
|
|
||||||
|
print(
|
||||||
|
"⚠ 警告: python-dotenv未安装,将不会加载.env文件。如需使用.env文件,请运行: pip install python-dotenv",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 常量定义
|
# 常量定义
|
||||||
SECRET_KEY_FILE = 'data/secret_key.txt'
|
SECRET_KEY_FILE = "data/secret_key.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_private_dir(path: str) -> None:
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
os.makedirs(path, mode=0o700, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_private_file(path: str) -> None:
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_secret_key():
|
def get_secret_key():
|
||||||
"""获取SECRET_KEY(优先环境变量)"""
|
"""获取SECRET_KEY(优先环境变量)"""
|
||||||
# 优先从环境变量读取
|
# 优先从环境变量读取
|
||||||
secret_key = os.environ.get('SECRET_KEY')
|
secret_key = os.environ.get("SECRET_KEY")
|
||||||
if secret_key:
|
if secret_key:
|
||||||
return secret_key
|
return secret_key
|
||||||
|
|
||||||
# 从文件读取
|
# 从文件读取
|
||||||
if os.path.exists(SECRET_KEY_FILE):
|
if os.path.exists(SECRET_KEY_FILE):
|
||||||
with open(SECRET_KEY_FILE, 'r') as f:
|
_ensure_private_file(SECRET_KEY_FILE)
|
||||||
|
with open(SECRET_KEY_FILE, "r") as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
||||||
# 生成新的
|
# 生成新的
|
||||||
new_key = os.urandom(24).hex()
|
new_key = os.urandom(24).hex()
|
||||||
os.makedirs('data', exist_ok=True)
|
_ensure_private_dir("data")
|
||||||
with open(SECRET_KEY_FILE, 'w') as f:
|
with open(SECRET_KEY_FILE, "w") as f:
|
||||||
f.write(new_key)
|
f.write(new_key)
|
||||||
print(f"✓ 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
|
_ensure_private_file(SECRET_KEY_FILE)
|
||||||
|
print(f"[OK] 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
|
||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_base_url_from_full_url(url: str, fallback: str) -> str:
|
||||||
|
"""从完整 URL 推导出 base_url(scheme://netloc)。"""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(str(url or "").strip())
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_sibling_url(full_url: str, filename: str, fallback: str) -> str:
|
||||||
|
"""把 full_url 的最后路径段替换为 filename(忽略 query/fragment)。"""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(str(full_url or "").strip())
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return fallback
|
||||||
|
path = parsed.path or "/"
|
||||||
|
if path.endswith("/"):
|
||||||
|
new_path = path + filename
|
||||||
|
else:
|
||||||
|
new_path = path.rsplit("/", 1)[0] + "/" + filename
|
||||||
|
return urlunsplit((parsed.scheme, parsed.netloc, new_path, "", ""))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""应用配置基类"""
|
"""应用配置基类"""
|
||||||
|
|
||||||
@@ -57,27 +109,30 @@ class Config:
|
|||||||
# ==================== 会话安全配置 ====================
|
# ==================== 会话安全配置 ====================
|
||||||
# 安全修复: 根据环境自动选择安全配置
|
# 安全修复: 根据环境自动选择安全配置
|
||||||
# 生产环境(FLASK_ENV=production)时自动启用更严格的安全设置
|
# 生产环境(FLASK_ENV=production)时自动启用更严格的安全设置
|
||||||
_is_production = os.environ.get('FLASK_ENV', 'production') == 'production'
|
_is_production = os.environ.get("FLASK_ENV", "production") == "production"
|
||||||
_force_secure = os.environ.get('SESSION_COOKIE_SECURE', '').lower() == 'true'
|
_force_secure = os.environ.get("SESSION_COOKIE_SECURE", "").lower() == "true"
|
||||||
SESSION_COOKIE_SECURE = _force_secure or (_is_production and os.environ.get('HTTPS_ENABLED', 'false').lower() == 'true')
|
SESSION_COOKIE_SECURE = _force_secure or (
|
||||||
|
_is_production and os.environ.get("HTTPS_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
SESSION_COOKIE_HTTPONLY = True # 防止XSS攻击
|
SESSION_COOKIE_HTTPONLY = True # 防止XSS攻击
|
||||||
# SameSite配置:HTTPS环境使用None,HTTP环境使用Lax
|
# SameSite配置:HTTPS环境使用None,HTTP环境使用Lax
|
||||||
SESSION_COOKIE_SAMESITE = 'None' if SESSION_COOKIE_SECURE else 'Lax'
|
SESSION_COOKIE_SAMESITE = "None" if SESSION_COOKIE_SECURE else "Lax"
|
||||||
# 自定义cookie名称,避免与其他应用冲突
|
# 自定义cookie名称,避免与其他应用冲突
|
||||||
SESSION_COOKIE_NAME = os.environ.get('SESSION_COOKIE_NAME', 'zsglpt_session')
|
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "zsglpt_session")
|
||||||
# Cookie路径,确保整个应用都能访问
|
# Cookie路径,确保整个应用都能访问
|
||||||
SESSION_COOKIE_PATH = '/'
|
SESSION_COOKIE_PATH = "/"
|
||||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=int(os.environ.get('SESSION_LIFETIME_HOURS', '24')))
|
PERMANENT_SESSION_LIFETIME = timedelta(hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "24")))
|
||||||
|
|
||||||
# 安全警告检查
|
# 安全警告检查
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_security_warnings(cls):
|
def check_security_warnings(cls):
|
||||||
"""检查安全配置,输出警告"""
|
"""检查安全配置,输出警告"""
|
||||||
import sys
|
import sys
|
||||||
warnings = []
|
|
||||||
env = os.environ.get('FLASK_ENV', 'production')
|
|
||||||
|
|
||||||
if env == 'production':
|
warnings = []
|
||||||
|
env = os.environ.get("FLASK_ENV", "production")
|
||||||
|
|
||||||
|
if env == "production":
|
||||||
if not cls.SESSION_COOKIE_SECURE:
|
if not cls.SESSION_COOKIE_SECURE:
|
||||||
warnings.append("SESSION_COOKIE_SECURE=False: 生产环境建议启用HTTPS并设置SESSION_COOKIE_SECURE=true")
|
warnings.append("SESSION_COOKIE_SECURE=False: 生产环境建议启用HTTPS并设置SESSION_COOKIE_SECURE=true")
|
||||||
|
|
||||||
@@ -88,59 +143,125 @@ class Config:
|
|||||||
print("", file=sys.stderr)
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DB_FILE = os.environ.get('DB_FILE', 'data/app_data.db')
|
DB_FILE = os.environ.get("DB_FILE", "data/app_data.db")
|
||||||
DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '5'))
|
DB_POOL_SIZE = int(os.environ.get("DB_POOL_SIZE", "5"))
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS = int(os.environ.get("DB_CONNECT_TIMEOUT_SECONDS", "10"))
|
||||||
|
DB_BUSY_TIMEOUT_MS = int(os.environ.get("DB_BUSY_TIMEOUT_MS", "10000"))
|
||||||
|
DB_CACHE_SIZE_KB = int(os.environ.get("DB_CACHE_SIZE_KB", "8192"))
|
||||||
|
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.environ.get("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
|
||||||
|
DB_MMAP_SIZE_MB = int(os.environ.get("DB_MMAP_SIZE_MB", "256"))
|
||||||
|
DB_LOCK_RETRY_COUNT = int(os.environ.get("DB_LOCK_RETRY_COUNT", "3"))
|
||||||
|
DB_LOCK_RETRY_BASE_MS = int(os.environ.get("DB_LOCK_RETRY_BASE_MS", "50"))
|
||||||
|
DB_SLOW_QUERY_MS = int(os.environ.get("DB_SLOW_QUERY_MS", "120"))
|
||||||
|
DB_SLOW_QUERY_SQL_MAX_LEN = int(os.environ.get("DB_SLOW_QUERY_SQL_MAX_LEN", "240"))
|
||||||
|
DB_SLOW_SQL_WINDOW_SECONDS = int(os.environ.get("DB_SLOW_SQL_WINDOW_SECONDS", "86400"))
|
||||||
|
DB_SLOW_SQL_TOP_LIMIT = int(os.environ.get("DB_SLOW_SQL_TOP_LIMIT", "12"))
|
||||||
|
DB_SLOW_SQL_RECENT_LIMIT = int(os.environ.get("DB_SLOW_SQL_RECENT_LIMIT", "50"))
|
||||||
|
DB_SLOW_SQL_MAX_EVENTS = int(os.environ.get("DB_SLOW_SQL_MAX_EVENTS", "20000"))
|
||||||
|
DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS = int(os.environ.get("DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS", "21600"))
|
||||||
|
DB_ANALYZE_INTERVAL_SECONDS = int(os.environ.get("DB_ANALYZE_INTERVAL_SECONDS", "86400"))
|
||||||
|
DB_WAL_CHECKPOINT_INTERVAL_SECONDS = int(os.environ.get("DB_WAL_CHECKPOINT_INTERVAL_SECONDS", "43200"))
|
||||||
|
DB_WAL_CHECKPOINT_MODE = os.environ.get("DB_WAL_CHECKPOINT_MODE", "PASSIVE")
|
||||||
|
|
||||||
# ==================== 浏览器配置 ====================
|
# ==================== 浏览器配置 ====================
|
||||||
SCREENSHOTS_DIR = os.environ.get('SCREENSHOTS_DIR', '截图')
|
SCREENSHOTS_DIR = os.environ.get("SCREENSHOTS_DIR", "截图")
|
||||||
|
COOKIES_DIR = os.environ.get("COOKIES_DIR", "data/cookies")
|
||||||
|
KDOCS_LOGIN_STATE_FILE = os.environ.get("KDOCS_LOGIN_STATE_FILE", "data/kdocs_login_state.json")
|
||||||
|
|
||||||
|
# ==================== 公告图片上传配置 ====================
|
||||||
|
ANNOUNCEMENT_IMAGE_DIR = os.environ.get("ANNOUNCEMENT_IMAGE_DIR", "static/announcements")
|
||||||
|
ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||||
|
MAX_ANNOUNCEMENT_IMAGE_SIZE = int(os.environ.get("MAX_ANNOUNCEMENT_IMAGE_SIZE", "5242880")) # 5MB
|
||||||
|
|
||||||
# ==================== 并发控制配置 ====================
|
# ==================== 并发控制配置 ====================
|
||||||
MAX_CONCURRENT_GLOBAL = int(os.environ.get('MAX_CONCURRENT_GLOBAL', '2'))
|
MAX_CONCURRENT_GLOBAL = int(os.environ.get("MAX_CONCURRENT_GLOBAL", "2"))
|
||||||
MAX_CONCURRENT_PER_ACCOUNT = int(os.environ.get('MAX_CONCURRENT_PER_ACCOUNT', '1'))
|
MAX_CONCURRENT_PER_ACCOUNT = int(os.environ.get("MAX_CONCURRENT_PER_ACCOUNT", "1"))
|
||||||
|
|
||||||
# ==================== 日志缓存配置 ====================
|
# ==================== 日志缓存配置 ====================
|
||||||
MAX_LOGS_PER_USER = int(os.environ.get('MAX_LOGS_PER_USER', '100'))
|
MAX_LOGS_PER_USER = int(os.environ.get("MAX_LOGS_PER_USER", "100"))
|
||||||
MAX_TOTAL_LOGS = int(os.environ.get('MAX_TOTAL_LOGS', '1000'))
|
MAX_TOTAL_LOGS = int(os.environ.get("MAX_TOTAL_LOGS", "1000"))
|
||||||
|
|
||||||
|
# ==================== 内存/缓存清理配置 ====================
|
||||||
|
USER_ACCOUNTS_EXPIRE_SECONDS = int(os.environ.get("USER_ACCOUNTS_EXPIRE_SECONDS", "3600"))
|
||||||
|
BATCH_TASK_EXPIRE_SECONDS = int(os.environ.get("BATCH_TASK_EXPIRE_SECONDS", "21600")) # 默认6小时
|
||||||
|
PENDING_RANDOM_EXPIRE_SECONDS = int(os.environ.get("PENDING_RANDOM_EXPIRE_SECONDS", "7200")) # 默认2小时
|
||||||
|
|
||||||
# ==================== 验证码配置 ====================
|
# ==================== 验证码配置 ====================
|
||||||
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get('MAX_CAPTCHA_ATTEMPTS', '5'))
|
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get("MAX_CAPTCHA_ATTEMPTS", "5"))
|
||||||
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get('CAPTCHA_EXPIRE_SECONDS', '300'))
|
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get("CAPTCHA_EXPIRE_SECONDS", "300"))
|
||||||
|
|
||||||
# ==================== IP限流配置 ====================
|
# ==================== IP限流配置 ====================
|
||||||
MAX_IP_ATTEMPTS_PER_HOUR = int(os.environ.get('MAX_IP_ATTEMPTS_PER_HOUR', '10'))
|
MAX_IP_ATTEMPTS_PER_HOUR = int(os.environ.get("MAX_IP_ATTEMPTS_PER_HOUR", "10"))
|
||||||
IP_LOCK_DURATION = int(os.environ.get('IP_LOCK_DURATION', '3600')) # 秒
|
IP_LOCK_DURATION = int(os.environ.get("IP_LOCK_DURATION", "3600")) # 秒
|
||||||
|
IP_RATE_LIMIT_LOGIN_MAX = int(os.environ.get("IP_RATE_LIMIT_LOGIN_MAX", "20"))
|
||||||
|
IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS", "60"))
|
||||||
|
IP_RATE_LIMIT_REGISTER_MAX = int(os.environ.get("IP_RATE_LIMIT_REGISTER_MAX", "10"))
|
||||||
|
IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS", "3600"))
|
||||||
|
IP_RATE_LIMIT_EMAIL_MAX = int(os.environ.get("IP_RATE_LIMIT_EMAIL_MAX", "20"))
|
||||||
|
IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS", "3600"))
|
||||||
|
|
||||||
# ==================== 超时配置 ====================
|
# ==================== 超时配置 ====================
|
||||||
PAGE_LOAD_TIMEOUT = int(os.environ.get('PAGE_LOAD_TIMEOUT', '60000')) # 毫秒
|
PAGE_LOAD_TIMEOUT = int(os.environ.get("PAGE_LOAD_TIMEOUT", "60000")) # 毫秒
|
||||||
DEFAULT_TIMEOUT = int(os.environ.get('DEFAULT_TIMEOUT', '60000')) # 毫秒
|
DEFAULT_TIMEOUT = int(os.environ.get("DEFAULT_TIMEOUT", "60000")) # 毫秒
|
||||||
|
|
||||||
# ==================== 知识管理平台配置 ====================
|
# ==================== 知识管理平台配置 ====================
|
||||||
ZSGL_LOGIN_URL = os.environ.get('ZSGL_LOGIN_URL', 'https://postoa.aidunsoft.com/admin/login.aspx')
|
ZSGL_LOGIN_URL = os.environ.get("ZSGL_LOGIN_URL", "https://postoa.aidunsoft.com/admin/login.aspx")
|
||||||
ZSGL_INDEX_URL_PATTERN = os.environ.get('ZSGL_INDEX_URL_PATTERN', 'index.aspx')
|
ZSGL_INDEX_URL_PATTERN = os.environ.get("ZSGL_INDEX_URL_PATTERN", "index.aspx")
|
||||||
MAX_CONCURRENT_CONTEXTS = int(os.environ.get('MAX_CONCURRENT_CONTEXTS', '100'))
|
ZSGL_BASE_URL = os.environ.get("ZSGL_BASE_URL") or _derive_base_url_from_full_url(
|
||||||
|
ZSGL_LOGIN_URL, "https://postoa.aidunsoft.com"
|
||||||
|
)
|
||||||
|
ZSGL_INDEX_URL = os.environ.get("ZSGL_INDEX_URL") or _derive_sibling_url(
|
||||||
|
ZSGL_LOGIN_URL,
|
||||||
|
ZSGL_INDEX_URL_PATTERN,
|
||||||
|
f"{ZSGL_BASE_URL}/admin/{ZSGL_INDEX_URL_PATTERN}",
|
||||||
|
)
|
||||||
|
MAX_CONCURRENT_CONTEXTS = int(os.environ.get("MAX_CONCURRENT_CONTEXTS", "100"))
|
||||||
|
|
||||||
# ==================== 服务器配置 ====================
|
# ==================== 服务器配置 ====================
|
||||||
SERVER_HOST = os.environ.get('SERVER_HOST', '0.0.0.0')
|
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
|
||||||
SERVER_PORT = int(os.environ.get('SERVER_PORT', '51233'))
|
SERVER_PORT = int(os.environ.get("SERVER_PORT", "51233"))
|
||||||
|
|
||||||
# ==================== SocketIO配置 ====================
|
# ==================== SocketIO配置 ====================
|
||||||
SOCKETIO_CORS_ALLOWED_ORIGINS = os.environ.get('SOCKETIO_CORS_ALLOWED_ORIGINS', '*')
|
SOCKETIO_CORS_ALLOWED_ORIGINS = os.environ.get("SOCKETIO_CORS_ALLOWED_ORIGINS", "")
|
||||||
|
|
||||||
# ==================== 网站基础URL配置 ====================
|
# ==================== 网站基础URL配置 ====================
|
||||||
# 用于生成邮件中的验证链接等
|
# 用于生成邮件中的验证链接等
|
||||||
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:51233')
|
BASE_URL = os.environ.get("BASE_URL", "http://localhost:51233")
|
||||||
|
|
||||||
# ==================== 日志配置 ====================
|
# ==================== 日志配置 ====================
|
||||||
# 安全修复: 生产环境默认使用INFO级别,避免泄露敏感调试信息
|
# 安全修复: 生产环境默认使用INFO级别,避免泄露敏感调试信息
|
||||||
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
|
||||||
LOG_FILE = os.environ.get('LOG_FILE', 'logs/app.log')
|
LOG_FILE = os.environ.get("LOG_FILE", "logs/app.log")
|
||||||
LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', '10485760')) # 10MB
|
LOG_MAX_BYTES = int(os.environ.get("LOG_MAX_BYTES", "10485760")) # 10MB
|
||||||
LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', '5'))
|
LOG_BACKUP_COUNT = int(os.environ.get("LOG_BACKUP_COUNT", "5"))
|
||||||
|
|
||||||
# ==================== 安全配置 ====================
|
# ==================== 安全配置 ====================
|
||||||
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
DEBUG = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
|
||||||
ALLOWED_SCREENSHOT_EXTENSIONS = {'.png', '.jpg', '.jpeg'}
|
ALLOWED_SCREENSHOT_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
||||||
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
|
MAX_SCREENSHOT_SIZE = int(os.environ.get("MAX_SCREENSHOT_SIZE", "10485760")) # 10MB
|
||||||
|
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get("LOGIN_CAPTCHA_AFTER_FAILURES", "3"))
|
||||||
|
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get("LOGIN_CAPTCHA_WINDOW_SECONDS", "900"))
|
||||||
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "900"))
|
||||||
|
LOGIN_IP_MAX_ATTEMPTS = int(os.environ.get("LOGIN_IP_MAX_ATTEMPTS", "60"))
|
||||||
|
LOGIN_USERNAME_MAX_ATTEMPTS = int(os.environ.get("LOGIN_USERNAME_MAX_ATTEMPTS", "30"))
|
||||||
|
LOGIN_IP_USERNAME_MAX_ATTEMPTS = int(os.environ.get("LOGIN_IP_USERNAME_MAX_ATTEMPTS", "12"))
|
||||||
|
LOGIN_FAIL_DELAY_BASE_MS = int(os.environ.get("LOGIN_FAIL_DELAY_BASE_MS", "200"))
|
||||||
|
LOGIN_FAIL_DELAY_MAX_MS = int(os.environ.get("LOGIN_FAIL_DELAY_MAX_MS", "1200"))
|
||||||
|
LOGIN_ACCOUNT_LOCK_FAILURES = int(os.environ.get("LOGIN_ACCOUNT_LOCK_FAILURES", "6"))
|
||||||
|
LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS = int(os.environ.get("LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS", "900"))
|
||||||
|
LOGIN_ACCOUNT_LOCK_SECONDS = int(os.environ.get("LOGIN_ACCOUNT_LOCK_SECONDS", "600"))
|
||||||
|
LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD = int(os.environ.get("LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD", "8"))
|
||||||
|
LOGIN_SCAN_WINDOW_SECONDS = int(os.environ.get("LOGIN_SCAN_WINDOW_SECONDS", "600"))
|
||||||
|
LOGIN_SCAN_COOLDOWN_SECONDS = int(os.environ.get("LOGIN_SCAN_COOLDOWN_SECONDS", "600"))
|
||||||
|
EMAIL_RATE_LIMIT_MAX = int(os.environ.get("EMAIL_RATE_LIMIT_MAX", "6"))
|
||||||
|
EMAIL_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("EMAIL_RATE_LIMIT_WINDOW_SECONDS", "3600"))
|
||||||
|
LOGIN_ALERT_ENABLED = os.environ.get("LOGIN_ALERT_ENABLED", "true").lower() == "true"
|
||||||
|
LOGIN_ALERT_MIN_INTERVAL_SECONDS = int(os.environ.get("LOGIN_ALERT_MIN_INTERVAL_SECONDS", "3600"))
|
||||||
|
ADMIN_REAUTH_WINDOW_SECONDS = int(os.environ.get("ADMIN_REAUTH_WINDOW_SECONDS", "600"))
|
||||||
|
SECURITY_ENABLED = os.environ.get("SECURITY_ENABLED", "true").lower() == "true"
|
||||||
|
SECURITY_LOG_LEVEL = os.environ.get("SECURITY_LOG_LEVEL", "INFO")
|
||||||
|
HONEYPOT_ENABLED = os.environ.get("HONEYPOT_ENABLED", "true").lower() == "true"
|
||||||
|
AUTO_BAN_ENABLED = os.environ.get("AUTO_BAN_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls):
|
def validate(cls):
|
||||||
@@ -164,11 +285,40 @@ class Config:
|
|||||||
|
|
||||||
if cls.DB_POOL_SIZE < 1:
|
if cls.DB_POOL_SIZE < 1:
|
||||||
errors.append("DB_POOL_SIZE必须大于0")
|
errors.append("DB_POOL_SIZE必须大于0")
|
||||||
|
if cls.DB_CONNECT_TIMEOUT_SECONDS < 1:
|
||||||
|
errors.append("DB_CONNECT_TIMEOUT_SECONDS必须大于0")
|
||||||
|
if cls.DB_BUSY_TIMEOUT_MS < 100:
|
||||||
|
errors.append("DB_BUSY_TIMEOUT_MS必须至少100毫秒")
|
||||||
|
if cls.DB_CACHE_SIZE_KB < 1024:
|
||||||
|
errors.append("DB_CACHE_SIZE_KB建议至少1024")
|
||||||
|
if cls.DB_WAL_AUTOCHECKPOINT_PAGES < 100:
|
||||||
|
errors.append("DB_WAL_AUTOCHECKPOINT_PAGES建议至少100")
|
||||||
|
if cls.DB_MMAP_SIZE_MB < 0:
|
||||||
|
errors.append("DB_MMAP_SIZE_MB不能为负数")
|
||||||
|
if cls.DB_LOCK_RETRY_COUNT < 0:
|
||||||
|
errors.append("DB_LOCK_RETRY_COUNT不能为负数")
|
||||||
|
if cls.DB_LOCK_RETRY_BASE_MS < 10:
|
||||||
|
errors.append("DB_LOCK_RETRY_BASE_MS建议至少10毫秒")
|
||||||
|
if cls.DB_SLOW_QUERY_MS < 0:
|
||||||
|
errors.append("DB_SLOW_QUERY_MS不能为负数")
|
||||||
|
if cls.DB_SLOW_QUERY_SQL_MAX_LEN < 80:
|
||||||
|
errors.append("DB_SLOW_QUERY_SQL_MAX_LEN建议至少80")
|
||||||
|
if cls.DB_SLOW_SQL_WINDOW_SECONDS < 600:
|
||||||
|
errors.append("DB_SLOW_SQL_WINDOW_SECONDS建议至少600")
|
||||||
|
if cls.DB_SLOW_SQL_TOP_LIMIT < 5:
|
||||||
|
errors.append("DB_SLOW_SQL_TOP_LIMIT建议至少5")
|
||||||
|
if cls.DB_SLOW_SQL_RECENT_LIMIT < 10:
|
||||||
|
errors.append("DB_SLOW_SQL_RECENT_LIMIT建议至少10")
|
||||||
|
if cls.DB_SLOW_SQL_MAX_EVENTS < cls.DB_SLOW_SQL_RECENT_LIMIT:
|
||||||
|
errors.append("DB_SLOW_SQL_MAX_EVENTS必须不小于DB_SLOW_SQL_RECENT_LIMIT")
|
||||||
|
|
||||||
# 验证日志配置
|
# 验证日志配置
|
||||||
if cls.LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
if cls.LOG_LEVEL not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||||
errors.append(f"LOG_LEVEL无效: {cls.LOG_LEVEL}")
|
errors.append(f"LOG_LEVEL无效: {cls.LOG_LEVEL}")
|
||||||
|
|
||||||
|
if cls.SECURITY_LOG_LEVEL not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||||
|
errors.append(f"SECURITY_LOG_LEVEL无效: {cls.SECURITY_LOG_LEVEL}")
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -192,12 +342,14 @@ class Config:
|
|||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""开发环境配置"""
|
"""开发环境配置"""
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
# 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
|
# 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
"""生产环境配置"""
|
"""生产环境配置"""
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
# 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
|
# 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
|
||||||
# 如需HTTPS,请在环境变量中设置 SESSION_COOKIE_SECURE=true
|
# 如需HTTPS,请在环境变量中设置 SESSION_COOKIE_SECURE=true
|
||||||
@@ -205,26 +357,27 @@ class ProductionConfig(Config):
|
|||||||
|
|
||||||
class TestingConfig(Config):
|
class TestingConfig(Config):
|
||||||
"""测试环境配置"""
|
"""测试环境配置"""
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
TESTING = True
|
TESTING = True
|
||||||
DB_FILE = 'data/test_app_data.db'
|
DB_FILE = "data/test_app_data.db"
|
||||||
|
|
||||||
|
|
||||||
# 根据环境变量选择配置
|
# 根据环境变量选择配置
|
||||||
config_map = {
|
config_map = {
|
||||||
'development': DevelopmentConfig,
|
"development": DevelopmentConfig,
|
||||||
'production': ProductionConfig,
|
"production": ProductionConfig,
|
||||||
'testing': TestingConfig,
|
"testing": TestingConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_config():
|
def get_config():
|
||||||
"""获取当前环境的配置"""
|
"""获取当前环境的配置"""
|
||||||
env = os.environ.get('FLASK_ENV', 'production')
|
env = os.environ.get("FLASK_ENV", "production")
|
||||||
return config_map.get(env, ProductionConfig)
|
return config_map.get(env, ProductionConfig)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# 配置验证测试
|
# 配置验证测试
|
||||||
config = get_config()
|
config = get_config()
|
||||||
errors = config.validate()
|
errors = config.validate()
|
||||||
@@ -234,5 +387,5 @@ if __name__ == '__main__':
|
|||||||
for error in errors:
|
for error in errors:
|
||||||
print(f" ✗ {error}")
|
print(f" ✗ {error}")
|
||||||
else:
|
else:
|
||||||
print("✓ 配置验证通过")
|
print("[OK] 配置验证通过")
|
||||||
config.print_config()
|
config.print_config()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import threading
|
import threading
|
||||||
@@ -45,6 +46,31 @@ class ColoredFormatter(logging.Formatter):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class SensitiveDataFilter(logging.Filter):
|
||||||
|
"""对日志中的敏感字段做统一脱敏处理。"""
|
||||||
|
|
||||||
|
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
||||||
|
_PAIR_PATTERNS = (
|
||||||
|
(re.compile(r"(?i)\b(password|passwd|pwd)\s*[:=]\s*([^,\s]+)"), r"\1=[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)\b(token|csrf_token|session|authorization)\s*[:=]\s*([^,\s]+)"), r"\1=[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)\b(user_id|admin_id|token_id)\s*=\s*\d+\b"), r"\1=[MASKED]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
try:
|
||||||
|
message = record.getMessage()
|
||||||
|
sanitized = self._EMAIL_RE.sub("[REDACTED_EMAIL]", message)
|
||||||
|
for pattern, replacement in self._PAIR_PATTERNS:
|
||||||
|
sanitized = pattern.sub(replacement, sanitized)
|
||||||
|
if sanitized != message:
|
||||||
|
record.msg = sanitized
|
||||||
|
record.args = ()
|
||||||
|
except Exception:
|
||||||
|
# 日志过滤异常不应影响业务日志输出
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def setup_logger(name='app', level=None, log_file=None, max_bytes=10*1024*1024, backup_count=5):
|
def setup_logger(name='app', level=None, log_file=None, max_bytes=10*1024*1024, backup_count=5):
|
||||||
"""
|
"""
|
||||||
设置日志记录器
|
设置日志记录器
|
||||||
@@ -74,6 +100,17 @@ def setup_logger(name='app', level=None, log_file=None, max_bytes=10*1024*1024,
|
|||||||
|
|
||||||
# 清除已有的处理器(避免重复)
|
# 清除已有的处理器(避免重复)
|
||||||
logger.handlers.clear()
|
logger.handlers.clear()
|
||||||
|
logger.filters.clear()
|
||||||
|
|
||||||
|
# 全局敏感日志脱敏(默认开启,可通过 LOG_REDACT_SENSITIVE=0 关闭)
|
||||||
|
redact_enabled = str(os.environ.get("LOG_REDACT_SENSITIVE", "1")).strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
if redact_enabled:
|
||||||
|
logger.addFilter(SensitiveDataFilter())
|
||||||
|
|
||||||
# 日志格式
|
# 日志格式
|
||||||
detailed_formatter = logging.Formatter(
|
detailed_formatter = logging.Formatter(
|
||||||
@@ -280,7 +317,10 @@ def init_logging(log_level='INFO', log_file='logs/app.log'):
|
|||||||
|
|
||||||
# 创建审计日志器(已在AuditLogger中创建)
|
# 创建审计日志器(已在AuditLogger中创建)
|
||||||
|
|
||||||
print("✓ 日志系统初始化完成")
|
try:
|
||||||
|
get_logger('app').info("[OK] 日志系统初始化完成")
|
||||||
|
except Exception:
|
||||||
|
print("[OK] 日志系统初始化完成")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
223
app_security.py
223
app_security.py
@@ -9,10 +9,14 @@ import os
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from urllib.parse import urlparse
|
||||||
from flask import request, jsonify, session
|
from flask import request, jsonify, session
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import threading
|
import threading
|
||||||
@@ -75,7 +79,13 @@ def sanitize_filename(filename):
|
|||||||
class IPRateLimiter:
|
class IPRateLimiter:
|
||||||
"""IP访问频率限制器"""
|
"""IP访问频率限制器"""
|
||||||
|
|
||||||
def __init__(self, max_attempts=10, window_seconds=3600, lock_duration=3600):
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_attempts=10,
|
||||||
|
window_seconds=3600,
|
||||||
|
lock_duration=3600,
|
||||||
|
max_tracked_ips=20000,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
初始化限流器
|
初始化限流器
|
||||||
|
|
||||||
@@ -87,6 +97,7 @@ class IPRateLimiter:
|
|||||||
self.max_attempts = max_attempts
|
self.max_attempts = max_attempts
|
||||||
self.window_seconds = window_seconds
|
self.window_seconds = window_seconds
|
||||||
self.lock_duration = lock_duration
|
self.lock_duration = lock_duration
|
||||||
|
self.max_tracked_ips = max(1000, int(max_tracked_ips or 0))
|
||||||
|
|
||||||
# IP访问记录: {ip: [(timestamp, success), ...]}
|
# IP访问记录: {ip: [(timestamp, success), ...]}
|
||||||
self._attempts = defaultdict(list)
|
self._attempts = defaultdict(list)
|
||||||
@@ -94,6 +105,47 @@ class IPRateLimiter:
|
|||||||
self._locked = {}
|
self._locked = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_if_oversized(self, now_ts: float) -> None:
|
||||||
|
"""限制内部映射大小,避免在高频随机IP攻击下持续膨胀。"""
|
||||||
|
tracked = len(self._attempts) + len(self._locked)
|
||||||
|
if tracked <= self.max_tracked_ips:
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff_time = now_ts - self.window_seconds
|
||||||
|
for ip in list(self._attempts.keys()):
|
||||||
|
self._attempts[ip] = [
|
||||||
|
(ts, succ) for ts, succ in self._attempts[ip]
|
||||||
|
if ts > cutoff_time
|
||||||
|
]
|
||||||
|
if not self._attempts[ip]:
|
||||||
|
del self._attempts[ip]
|
||||||
|
|
||||||
|
for ip in list(self._locked.keys()):
|
||||||
|
if now_ts >= self._locked[ip]:
|
||||||
|
del self._locked[ip]
|
||||||
|
|
||||||
|
tracked = len(self._attempts) + len(self._locked)
|
||||||
|
if tracked <= self.max_tracked_ips:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 优先按“最近访问时间最早”淘汰 attempts 中的 IP 记录。
|
||||||
|
overflow = tracked - self.max_tracked_ips
|
||||||
|
oldest = []
|
||||||
|
for ip, attempt_items in self._attempts.items():
|
||||||
|
if attempt_items:
|
||||||
|
oldest.append((attempt_items[-1][0], ip))
|
||||||
|
else:
|
||||||
|
oldest.append((0.0, ip))
|
||||||
|
oldest.sort(key=lambda item: item[0])
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
for _, ip in oldest:
|
||||||
|
self._attempts.pop(ip, None)
|
||||||
|
self._locked.pop(ip, None)
|
||||||
|
removed += 1
|
||||||
|
if removed >= overflow:
|
||||||
|
break
|
||||||
|
|
||||||
def is_locked(self, ip_address):
|
def is_locked(self, ip_address):
|
||||||
"""
|
"""
|
||||||
检查IP是否被锁定
|
检查IP是否被锁定
|
||||||
@@ -126,6 +178,7 @@ class IPRateLimiter:
|
|||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
self._prune_if_oversized(now)
|
||||||
|
|
||||||
# 清理过期记录
|
# 清理过期记录
|
||||||
cutoff_time = now - self.window_seconds
|
cutoff_time = now - self.window_seconds
|
||||||
@@ -194,18 +247,43 @@ class IPRateLimiter:
|
|||||||
# 全局IP限流器实例
|
# 全局IP限流器实例
|
||||||
ip_rate_limiter = IPRateLimiter()
|
ip_rate_limiter = IPRateLimiter()
|
||||||
|
|
||||||
|
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
_TRUST_PROXY_HEADERS = str(os.environ.get("TRUST_PROXY_HEADERS", "false") or "").strip().lower() in _TRUTHY_VALUES
|
||||||
|
|
||||||
|
|
||||||
def require_ip_not_locked(f):
|
def require_ip_not_locked(f):
|
||||||
"""装饰器:检查IP是否被锁定"""
|
"""装饰器:检查IP是否被锁定"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
ip_address = request.remote_addr
|
ip_address = get_rate_limit_ip()
|
||||||
|
|
||||||
|
# P0 / O-01:统一使用 services.state 的线程安全限流状态
|
||||||
|
try:
|
||||||
|
from services.state import check_ip_rate_limit, safe_get_ip_lock_until
|
||||||
|
|
||||||
|
allowed, error_msg = check_ip_rate_limit(ip_address)
|
||||||
|
if not allowed:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": error_msg or "由于多次失败尝试,您的IP已被临时锁定",
|
||||||
|
"locked_until": safe_get_ip_lock_until(ip_address),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 兜底:沿用旧实现(避免极端情况下阻断业务)
|
||||||
if ip_rate_limiter.is_locked(ip_address):
|
if ip_rate_limiter.is_locked(ip_address):
|
||||||
return jsonify({
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
"error": "由于多次失败尝试,您的IP已被临时锁定",
|
"error": "由于多次失败尝试,您的IP已被临时锁定",
|
||||||
"locked_until": ip_rate_limiter._locked.get(ip_address, 0)
|
"locked_until": ip_rate_limiter._locked.get(ip_address, 0),
|
||||||
}), 429
|
}
|
||||||
|
),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
@@ -329,7 +407,19 @@ def generate_csrf_token():
|
|||||||
|
|
||||||
def validate_csrf_token(token):
|
def validate_csrf_token(token):
|
||||||
"""验证CSRF令牌"""
|
"""验证CSRF令牌"""
|
||||||
return token == session.get('csrf_token')
|
expected = session.get("csrf_token")
|
||||||
|
if (token is None) or (expected is None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
provided_text = str(token or "")
|
||||||
|
expected_text = str(expected or "")
|
||||||
|
if (not provided_text) or (not expected_text):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return hmac.compare_digest(
|
||||||
|
provided_text.encode("utf-8"),
|
||||||
|
expected_text.encode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== 内容安全 ====================
|
# ==================== 内容安全 ====================
|
||||||
@@ -418,7 +508,7 @@ def get_client_ip(trust_proxy=False):
|
|||||||
"""
|
"""
|
||||||
# 安全说明:X-Forwarded-For 可被伪造
|
# 安全说明:X-Forwarded-For 可被伪造
|
||||||
# 仅在确认请求来自可信代理时才使用代理头
|
# 仅在确认请求来自可信代理时才使用代理头
|
||||||
if trust_proxy:
|
if trust_proxy and _TRUST_PROXY_HEADERS:
|
||||||
if request.headers.get('X-Forwarded-For'):
|
if request.headers.get('X-Forwarded-For'):
|
||||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
elif request.headers.get('X-Real-IP'):
|
elif request.headers.get('X-Real-IP'):
|
||||||
@@ -428,6 +518,125 @@ def get_client_ip(trust_proxy=False):
|
|||||||
return request.remote_addr
|
return request.remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def _load_trusted_proxy_networks():
|
||||||
|
"""加载可信代理 CIDR 列表。"""
|
||||||
|
default_cidrs = "127.0.0.1/32,::1/128"
|
||||||
|
raw = str(os.environ.get("TRUSTED_PROXY_CIDRS", default_cidrs) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
networks = []
|
||||||
|
for segment in raw.split(","):
|
||||||
|
cidr_text = str(segment or "").strip()
|
||||||
|
if not cidr_text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
networks.append(ipaddress.ip_network(cidr_text, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return networks
|
||||||
|
|
||||||
|
|
||||||
|
_TRUSTED_PROXY_NETWORKS = _load_trusted_proxy_networks()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ip_address(candidate: str):
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(str(candidate or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_proxy_ip(ip_obj) -> bool:
|
||||||
|
if ip_obj is None:
|
||||||
|
return False
|
||||||
|
for network in _TRUSTED_PROXY_NETWORKS:
|
||||||
|
try:
|
||||||
|
if ip_obj.version != network.version:
|
||||||
|
continue
|
||||||
|
if ip_obj in network:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_real_ip_from_forwarded_chain() -> str | None:
|
||||||
|
"""基于 X-Forwarded-For 链反向提取最靠近应用侧的“非代理”来源 IP。"""
|
||||||
|
forwarded = str(request.headers.get("X-Forwarded-For", "") or "")
|
||||||
|
candidates = []
|
||||||
|
for segment in forwarded.split(","):
|
||||||
|
ip_text = str(segment or "").strip()
|
||||||
|
ip_obj = _parse_ip_address(ip_text)
|
||||||
|
if ip_obj is None:
|
||||||
|
continue
|
||||||
|
candidates.append((str(ip_obj), ip_obj))
|
||||||
|
|
||||||
|
# 若存在 X-Forwarded-For,按“从右到左”剥离可信代理。
|
||||||
|
if candidates:
|
||||||
|
for ip_text, ip_obj in reversed(candidates):
|
||||||
|
if _is_trusted_proxy_ip(ip_obj):
|
||||||
|
continue
|
||||||
|
return ip_text
|
||||||
|
return candidates[0][0]
|
||||||
|
|
||||||
|
real_ip_text = str(request.headers.get("X-Real-IP", "") or "").strip()
|
||||||
|
real_ip_obj = _parse_ip_address(real_ip_text)
|
||||||
|
if real_ip_obj is None:
|
||||||
|
return None
|
||||||
|
return str(real_ip_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limit_ip() -> str:
|
||||||
|
"""在可信代理场景下取真实IP,用于限流/风控。"""
|
||||||
|
remote_addr = request.remote_addr or ""
|
||||||
|
if not _TRUST_PROXY_HEADERS:
|
||||||
|
return remote_addr
|
||||||
|
|
||||||
|
remote_ip = _parse_ip_address(remote_addr)
|
||||||
|
if remote_ip is None:
|
||||||
|
return remote_addr
|
||||||
|
|
||||||
|
# 仅当请求来自可信代理时才信任转发头。
|
||||||
|
if _is_trusted_proxy_ip(remote_ip):
|
||||||
|
forwarded_real_ip = _extract_real_ip_from_forwarded_chain()
|
||||||
|
if forwarded_real_ip:
|
||||||
|
return forwarded_real_ip
|
||||||
|
|
||||||
|
return remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_outbound_url(url: str) -> bool:
|
||||||
|
"""限制向内网/保留地址发起请求,降低SSRF风险。"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(str(url or "").strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
ips = [ipaddress.ip_address(host)]
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
ips = [ipaddress.ip_address(info[4][0]) for info in infos]
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# 测试文件路径安全
|
# 测试文件路径安全
|
||||||
print("文件路径安全测试:")
|
print("文件路径安全测试:")
|
||||||
|
|||||||
328
app_state.py
328
app_state.py
@@ -1,328 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
应用状态管理模块
|
|
||||||
提供线程安全的全局状态管理
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from typing import Tuple
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from app_logger import get_logger
|
|
||||||
|
|
||||||
logger = get_logger('app_state')
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadSafeDict:
|
|
||||||
"""线程安全的字典包装类"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._dict = {}
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def get(self, key, default=None):
|
|
||||||
"""获取值"""
|
|
||||||
with self._lock:
|
|
||||||
return self._dict.get(key, default)
|
|
||||||
|
|
||||||
def set(self, key, value):
|
|
||||||
"""设置值"""
|
|
||||||
with self._lock:
|
|
||||||
self._dict[key] = value
|
|
||||||
|
|
||||||
def delete(self, key):
|
|
||||||
"""删除键"""
|
|
||||||
with self._lock:
|
|
||||||
if key in self._dict:
|
|
||||||
del self._dict[key]
|
|
||||||
|
|
||||||
def pop(self, key, default=None):
|
|
||||||
"""弹出键值"""
|
|
||||||
with self._lock:
|
|
||||||
return self._dict.pop(key, default)
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
"""获取所有键(返回副本)"""
|
|
||||||
with self._lock:
|
|
||||||
return list(self._dict.keys())
|
|
||||||
|
|
||||||
def items(self):
|
|
||||||
"""获取所有键值对(返回副本)"""
|
|
||||||
with self._lock:
|
|
||||||
return list(self._dict.items())
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
"""检查键是否存在"""
|
|
||||||
with self._lock:
|
|
||||||
return key in self._dict
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""清空字典"""
|
|
||||||
with self._lock:
|
|
||||||
self._dict.clear()
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
"""获取长度"""
|
|
||||||
with self._lock:
|
|
||||||
return len(self._dict)
|
|
||||||
|
|
||||||
|
|
||||||
class LogCacheManager:
|
|
||||||
"""日志缓存管理器(线程安全)"""
|
|
||||||
|
|
||||||
def __init__(self, max_logs_per_user=100, max_total_logs=1000):
|
|
||||||
self._cache = {} # {user_id: [logs]}
|
|
||||||
self._total_count = 0
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._max_logs_per_user = max_logs_per_user
|
|
||||||
self._max_total_logs = max_total_logs
|
|
||||||
|
|
||||||
def add_log(self, user_id: int, log_entry: Dict[str, Any]) -> bool:
|
|
||||||
"""添加日志到缓存"""
|
|
||||||
with self._lock:
|
|
||||||
# 检查总数限制
|
|
||||||
if self._total_count >= self._max_total_logs:
|
|
||||||
logger.warning(f"日志缓存已满 ({self._max_total_logs}),拒绝添加")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 初始化用户日志列表
|
|
||||||
if user_id not in self._cache:
|
|
||||||
self._cache[user_id] = []
|
|
||||||
|
|
||||||
user_logs = self._cache[user_id]
|
|
||||||
|
|
||||||
# 检查用户日志数限制
|
|
||||||
if len(user_logs) >= self._max_logs_per_user:
|
|
||||||
# 移除最旧的日志
|
|
||||||
user_logs.pop(0)
|
|
||||||
self._total_count -= 1
|
|
||||||
|
|
||||||
# 添加新日志
|
|
||||||
user_logs.append(log_entry)
|
|
||||||
self._total_count += 1
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_logs(self, user_id: int) -> list:
|
|
||||||
"""获取用户的所有日志(返回副本)"""
|
|
||||||
with self._lock:
|
|
||||||
return list(self._cache.get(user_id, []))
|
|
||||||
|
|
||||||
def clear_user_logs(self, user_id: int):
|
|
||||||
"""清空用户的日志"""
|
|
||||||
with self._lock:
|
|
||||||
if user_id in self._cache:
|
|
||||||
count = len(self._cache[user_id])
|
|
||||||
del self._cache[user_id]
|
|
||||||
self._total_count -= count
|
|
||||||
logger.info(f"清空用户 {user_id} 的 {count} 条日志")
|
|
||||||
|
|
||||||
def get_total_count(self) -> int:
|
|
||||||
"""获取总日志数"""
|
|
||||||
with self._lock:
|
|
||||||
return self._total_count
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, int]:
|
|
||||||
"""获取统计信息"""
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
'total_count': self._total_count,
|
|
||||||
'user_count': len(self._cache),
|
|
||||||
'max_per_user': self._max_logs_per_user,
|
|
||||||
'max_total': self._max_total_logs
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CaptchaManager:
|
|
||||||
"""验证码管理器(线程安全)"""
|
|
||||||
|
|
||||||
def __init__(self, expire_seconds=300):
|
|
||||||
self._storage = {} # {identifier: {'code': str, 'expire': datetime}}
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._expire_seconds = expire_seconds
|
|
||||||
|
|
||||||
def create(self, identifier: str, code: str) -> None:
|
|
||||||
"""创建验证码"""
|
|
||||||
with self._lock:
|
|
||||||
self._storage[identifier] = {
|
|
||||||
'code': code,
|
|
||||||
'expire': datetime.now() + timedelta(seconds=self._expire_seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
def verify(self, identifier: str, code: str) -> Tuple[bool, str]:
|
|
||||||
"""验证验证码"""
|
|
||||||
with self._lock:
|
|
||||||
if identifier not in self._storage:
|
|
||||||
return False, "验证码不存在或已过期"
|
|
||||||
|
|
||||||
captcha_data = self._storage[identifier]
|
|
||||||
|
|
||||||
# 检查是否过期
|
|
||||||
if datetime.now() > captcha_data['expire']:
|
|
||||||
del self._storage[identifier]
|
|
||||||
return False, "验证码已过期,请重新获取"
|
|
||||||
|
|
||||||
# 验证码码值
|
|
||||||
if captcha_data['code'] != code:
|
|
||||||
return False, "验证码错误"
|
|
||||||
|
|
||||||
# 验证成功,删除验证码
|
|
||||||
del self._storage[identifier]
|
|
||||||
return True, "验证成功"
|
|
||||||
|
|
||||||
def cleanup_expired(self) -> int:
|
|
||||||
"""清理过期的验证码"""
|
|
||||||
with self._lock:
|
|
||||||
now = datetime.now()
|
|
||||||
expired_keys = [
|
|
||||||
key for key, data in self._storage.items()
|
|
||||||
if now > data['expire']
|
|
||||||
]
|
|
||||||
for key in expired_keys:
|
|
||||||
del self._storage[key]
|
|
||||||
|
|
||||||
if expired_keys:
|
|
||||||
logger.info(f"清理了 {len(expired_keys)} 个过期验证码")
|
|
||||||
|
|
||||||
return len(expired_keys)
|
|
||||||
|
|
||||||
def get_count(self) -> int:
|
|
||||||
"""获取当前验证码数量"""
|
|
||||||
with self._lock:
|
|
||||||
return len(self._storage)
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicationState:
|
|
||||||
"""应用全局状态管理器(单例模式)"""
|
|
||||||
|
|
||||||
_instance = None
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
def __new__(cls):
|
|
||||||
if cls._instance is None:
|
|
||||||
with cls._lock:
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
cls._instance._initialized = False
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if self._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 浏览器管理器
|
|
||||||
self.browser_manager = None
|
|
||||||
self._browser_lock = threading.Lock()
|
|
||||||
|
|
||||||
# 用户账号管理 {user_id: {account_id: Account对象}}
|
|
||||||
self.user_accounts = ThreadSafeDict()
|
|
||||||
|
|
||||||
# 活动任务管理 {account_id: Thread对象}
|
|
||||||
self.active_tasks = ThreadSafeDict()
|
|
||||||
|
|
||||||
# 日志缓存管理
|
|
||||||
self.log_cache = LogCacheManager()
|
|
||||||
|
|
||||||
# 验证码管理
|
|
||||||
self.captcha = CaptchaManager()
|
|
||||||
|
|
||||||
# 用户信号量管理 {account_id: Semaphore}
|
|
||||||
self.user_semaphores = ThreadSafeDict()
|
|
||||||
|
|
||||||
# 全局信号量
|
|
||||||
self.global_semaphore = None
|
|
||||||
self.screenshot_semaphore = threading.Semaphore(1)
|
|
||||||
|
|
||||||
self._initialized = True
|
|
||||||
logger.info("应用状态管理器初始化完成")
|
|
||||||
|
|
||||||
def set_browser_manager(self, manager):
|
|
||||||
"""设置浏览器管理器"""
|
|
||||||
with self._browser_lock:
|
|
||||||
self.browser_manager = manager
|
|
||||||
|
|
||||||
def get_browser_manager(self):
|
|
||||||
"""获取浏览器管理器"""
|
|
||||||
with self._browser_lock:
|
|
||||||
return self.browser_manager
|
|
||||||
|
|
||||||
def get_user_semaphore(self, account_id: int, max_concurrent: int = 1):
|
|
||||||
"""获取或创建用户信号量"""
|
|
||||||
if account_id not in self.user_semaphores:
|
|
||||||
self.user_semaphores.set(account_id, threading.Semaphore(max_concurrent))
|
|
||||||
return self.user_semaphores.get(account_id)
|
|
||||||
|
|
||||||
def set_global_semaphore(self, max_concurrent: int):
|
|
||||||
"""设置全局信号量"""
|
|
||||||
self.global_semaphore = threading.Semaphore(max_concurrent)
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
|
||||||
"""获取状态统计信息"""
|
|
||||||
return {
|
|
||||||
'user_accounts_count': len(self.user_accounts),
|
|
||||||
'active_tasks_count': len(self.active_tasks),
|
|
||||||
'log_cache_stats': self.log_cache.get_stats(),
|
|
||||||
'captcha_count': self.captcha.get_count(),
|
|
||||||
'user_semaphores_count': len(self.user_semaphores),
|
|
||||||
'browser_manager': 'initialized' if self.browser_manager else 'not_initialized'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 全局单例实例
|
|
||||||
app_state = ApplicationState()
|
|
||||||
|
|
||||||
|
|
||||||
# 向后兼容的辅助函数
|
|
||||||
def verify_captcha(identifier: str, code: str) -> Tuple[bool, str]:
|
|
||||||
"""验证验证码(向后兼容接口)"""
|
|
||||||
return app_state.captcha.verify(identifier, code)
|
|
||||||
|
|
||||||
|
|
||||||
def create_captcha(identifier: str, code: str) -> None:
|
|
||||||
"""创建验证码(向后兼容接口)"""
|
|
||||||
app_state.captcha.create(identifier, code)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired_captchas() -> int:
|
|
||||||
"""清理过期验证码(向后兼容接口)"""
|
|
||||||
return app_state.captcha.cleanup_expired()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# 测试代码
|
|
||||||
print("测试线程安全状态管理器...")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 测试 ThreadSafeDict
|
|
||||||
print("\n1. 测试 ThreadSafeDict:")
|
|
||||||
td = ThreadSafeDict()
|
|
||||||
td.set('key1', 'value1')
|
|
||||||
print(f" 设置 key1 = {td.get('key1')}")
|
|
||||||
print(f" 长度: {len(td)}")
|
|
||||||
|
|
||||||
# 测试 LogCacheManager
|
|
||||||
print("\n2. 测试 LogCacheManager:")
|
|
||||||
lcm = LogCacheManager(max_logs_per_user=3, max_total_logs=10)
|
|
||||||
for i in range(5):
|
|
||||||
lcm.add_log(1, {'message': f'log {i}'})
|
|
||||||
print(f" 用户1日志数: {len(lcm.get_logs(1))}")
|
|
||||||
print(f" 总日志数: {lcm.get_total_count()}")
|
|
||||||
print(f" 统计: {lcm.get_stats()}")
|
|
||||||
|
|
||||||
# 测试 CaptchaManager
|
|
||||||
print("\n3. 测试 CaptchaManager:")
|
|
||||||
cm = CaptchaManager(expire_seconds=2)
|
|
||||||
cm.create('test@example.com', '1234')
|
|
||||||
success, msg = cm.verify('test@example.com', '1234')
|
|
||||||
print(f" 验证结果: {success}, {msg}")
|
|
||||||
|
|
||||||
# 测试 ApplicationState
|
|
||||||
print("\n4. 测试 ApplicationState (单例):")
|
|
||||||
state1 = ApplicationState()
|
|
||||||
state2 = ApplicationState()
|
|
||||||
print(f" 单例验证: {state1 is state2}")
|
|
||||||
print(f" 状态统计: {state1.get_stats()}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("✓ 所有测试通过!")
|
|
||||||
366
app_utils.py
366
app_utils.py
@@ -1,366 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
应用工具模块
|
|
||||||
提取重复的业务逻辑
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, Any, Optional, Tuple
|
|
||||||
from flask import session, jsonify
|
|
||||||
from app_logger import get_logger, audit_logger
|
|
||||||
from app_security import get_client_ip
|
|
||||||
import database
|
|
||||||
|
|
||||||
logger = get_logger('app_utils')
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(Exception):
|
|
||||||
"""验证错误异常"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def verify_user_file_permission(user_id: int, filename: str) -> Tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
验证用户文件访问权限
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
filename: 文件名
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(是否有权限, 错误消息)
|
|
||||||
"""
|
|
||||||
# 获取用户信息
|
|
||||||
user = database.get_user_by_id(user_id)
|
|
||||||
if not user:
|
|
||||||
return False, "用户不存在"
|
|
||||||
|
|
||||||
username = user['username']
|
|
||||||
|
|
||||||
# 检查文件名是否以用户名开头
|
|
||||||
if not filename.startswith(f"{username}_"):
|
|
||||||
logger.warning(f"用户 {username} (ID:{user_id}) 尝试访问未授权文件: {filename}")
|
|
||||||
return False, "无权访问此文件"
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def log_task_event(account_id: int, status: str, message: str,
|
|
||||||
browse_type: Optional[str] = None,
|
|
||||||
screenshot_path: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
记录任务日志(统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account_id: 账号ID
|
|
||||||
status: 状态(running/completed/failed/stopped)
|
|
||||||
message: 消息
|
|
||||||
browse_type: 浏览类型
|
|
||||||
screenshot_path: 截图路径
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return database.create_task_log(
|
|
||||||
account_id=account_id,
|
|
||||||
status=status,
|
|
||||||
message=message,
|
|
||||||
browse_type=browse_type,
|
|
||||||
screenshot_path=screenshot_path
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"记录任务日志失败: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def update_account_status(account_id: int, status: str,
|
|
||||||
error_message: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
更新账号状态(统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account_id: 账号ID
|
|
||||||
status: 状态(idle/running/error/stopped)
|
|
||||||
error_message: 错误消息(仅当status=error时)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return database.update_account_status(
|
|
||||||
account_id=account_id,
|
|
||||||
status=status,
|
|
||||||
error_message=error_message
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"更新账号状态失败 (account_id={account_id}): {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_config_cache() -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取或创建系统配置缓存
|
|
||||||
|
|
||||||
缓存存储在session中,避免重复查询数据库
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
配置字典,失败返回None
|
|
||||||
"""
|
|
||||||
# 尝试从session获取缓存
|
|
||||||
if '_system_config' in session:
|
|
||||||
return session['_system_config']
|
|
||||||
|
|
||||||
# 从数据库加载
|
|
||||||
try:
|
|
||||||
config = database.get_system_config()
|
|
||||||
if config:
|
|
||||||
# 存入session缓存
|
|
||||||
session['_system_config'] = config
|
|
||||||
return config
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取系统配置失败: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def clear_config_cache():
|
|
||||||
"""清除配置缓存(配置变更时调用)"""
|
|
||||||
if '_system_config' in session:
|
|
||||||
del session['_system_config']
|
|
||||||
logger.debug("已清除系统配置缓存")
|
|
||||||
|
|
||||||
|
|
||||||
def safe_close_browser(automation_obj, account_id: int):
|
|
||||||
"""
|
|
||||||
安全关闭浏览器(统一错误处理)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
automation_obj: PlaywrightAutomation对象
|
|
||||||
account_id: 账号ID
|
|
||||||
"""
|
|
||||||
if automation_obj:
|
|
||||||
try:
|
|
||||||
automation_obj.close()
|
|
||||||
logger.info(f"账号 {account_id} 的浏览器已关闭")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"关闭账号 {account_id} 的浏览器失败: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def format_error_response(error: str, status_code: int = 400,
|
|
||||||
need_captcha: bool = False,
|
|
||||||
extra_data: Optional[Dict] = None) -> Tuple[Any, int]:
|
|
||||||
"""
|
|
||||||
格式化错误响应(统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: 错误消息
|
|
||||||
status_code: HTTP状态码
|
|
||||||
need_captcha: 是否需要验证码
|
|
||||||
extra_data: 额外数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(jsonify响应, 状态码)
|
|
||||||
"""
|
|
||||||
response_data = {"error": error}
|
|
||||||
|
|
||||||
if need_captcha:
|
|
||||||
response_data["need_captcha"] = True
|
|
||||||
|
|
||||||
if extra_data:
|
|
||||||
response_data.update(extra_data)
|
|
||||||
|
|
||||||
return jsonify(response_data), status_code
|
|
||||||
|
|
||||||
|
|
||||||
def format_success_response(message: str = "操作成功",
|
|
||||||
extra_data: Optional[Dict] = None) -> Any:
|
|
||||||
"""
|
|
||||||
格式化成功响应(统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: 成功消息
|
|
||||||
extra_data: 额外数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
jsonify响应
|
|
||||||
"""
|
|
||||||
response_data = {"success": True, "message": message}
|
|
||||||
|
|
||||||
if extra_data:
|
|
||||||
response_data.update(extra_data)
|
|
||||||
|
|
||||||
return jsonify(response_data)
|
|
||||||
|
|
||||||
|
|
||||||
def log_user_action(action: str, user_id: int, username: str,
|
|
||||||
success: bool, details: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
记录用户操作到审计日志(统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: 操作类型(login/register/logout等)
|
|
||||||
user_id: 用户ID
|
|
||||||
username: 用户名
|
|
||||||
success: 是否成功
|
|
||||||
details: 详细信息
|
|
||||||
"""
|
|
||||||
ip = get_client_ip()
|
|
||||||
|
|
||||||
if action == 'login':
|
|
||||||
audit_logger.log_user_login(user_id, username, ip, success)
|
|
||||||
elif action == 'logout':
|
|
||||||
audit_logger.log_user_logout(user_id, username, ip)
|
|
||||||
elif action == 'register':
|
|
||||||
audit_logger.log_user_created(user_id, username, created_by='self')
|
|
||||||
|
|
||||||
if details:
|
|
||||||
logger.info(f"用户操作: {action}, 用户={username}, 成功={success}, 详情={details}")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pagination(page: Any, page_size: Any,
|
|
||||||
max_page_size: int = 100) -> Tuple[int, int, Optional[str]]:
|
|
||||||
"""
|
|
||||||
验证分页参数
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page: 页码
|
|
||||||
page_size: 每页大小
|
|
||||||
max_page_size: 最大每页大小
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(页码, 每页大小, 错误消息)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page = int(page) if page else 1
|
|
||||||
page_size = int(page_size) if page_size else 20
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return 1, 20, "无效的分页参数"
|
|
||||||
|
|
||||||
if page < 1:
|
|
||||||
return 1, 20, "页码必须大于0"
|
|
||||||
|
|
||||||
if page_size < 1 or page_size > max_page_size:
|
|
||||||
return page, 20, f"每页大小必须在1-{max_page_size}之间"
|
|
||||||
|
|
||||||
return page, page_size, None
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_ownership(user_id: int, resource_type: str,
|
|
||||||
resource_id: int) -> Tuple[bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
检查用户是否拥有资源
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
resource_type: 资源类型(account/task等)
|
|
||||||
resource_id: 资源ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(是否拥有, 错误消息)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if resource_type == 'account':
|
|
||||||
account = database.get_account_by_id(resource_id)
|
|
||||||
if not account:
|
|
||||||
return False, "账号不存在"
|
|
||||||
if account['user_id'] != user_id:
|
|
||||||
return False, "无权访问此账号"
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
elif resource_type == 'task':
|
|
||||||
# 通过account查询所属用户
|
|
||||||
# 这里需要根据实际数据库结构实现
|
|
||||||
pass
|
|
||||||
|
|
||||||
return False, "不支持的资源类型"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"检查资源所有权失败: {e}", exc_info=True)
|
|
||||||
return False, "系统错误"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict, max_attempts: int = 5) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
验证并消费验证码(安全增强版)
|
|
||||||
|
|
||||||
安全特性:
|
|
||||||
- 先删除验证码再验证,防止重放攻击
|
|
||||||
- 异常情况下也确保验证码被删除
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: 验证码会话ID
|
|
||||||
code: 用户输入的验证码
|
|
||||||
captcha_storage: 验证码存储字典
|
|
||||||
max_attempts: 最大尝试次数,默认5次
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple[bool, str]: (是否成功, 消息)
|
|
||||||
- 成功时返回 (True, "验证成功")
|
|
||||||
- 失败时返回 (False, 错误消息)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
success, message = verify_and_consume_captcha(
|
|
||||||
captcha_session,
|
|
||||||
captcha_code,
|
|
||||||
captcha_storage,
|
|
||||||
max_attempts=5
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
return jsonify({"error": message}), 400
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
# 安全修复:先取出并删除验证码,无论验证是否成功都不能重用
|
|
||||||
captcha_data = captcha_storage.pop(session_id, None)
|
|
||||||
|
|
||||||
# 检查验证码是否存在
|
|
||||||
if captcha_data is None:
|
|
||||||
return False, "验证码已过期或不存在,请重新获取"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 检查过期时间
|
|
||||||
if captcha_data["expire_time"] < time.time():
|
|
||||||
return False, "验证码已过期,请重新获取"
|
|
||||||
|
|
||||||
# 检查尝试次数
|
|
||||||
if captcha_data.get("failed_attempts", 0) >= max_attempts:
|
|
||||||
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
|
|
||||||
|
|
||||||
# 验证代码(不区分大小写)
|
|
||||||
if captcha_data["code"].lower() != code.lower():
|
|
||||||
# 验证失败,增加失败计数后放回(允许继续尝试)
|
|
||||||
captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
|
|
||||||
# 只有未超过最大尝试次数才放回
|
|
||||||
if captcha_data["failed_attempts"] < max_attempts:
|
|
||||||
captcha_storage[session_id] = captcha_data
|
|
||||||
return False, "验证码错误"
|
|
||||||
|
|
||||||
# 验证成功,验证码已被删除,不会被重用
|
|
||||||
return True, "验证成功"
|
|
||||||
except Exception as e:
|
|
||||||
# 异常情况下确保验证码不会被重用(已在函数开头删除)
|
|
||||||
logger.error(f"验证码验证异常: {e}")
|
|
||||||
return False, "验证码验证失败,请重新获取"
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# 测试代码
|
|
||||||
print("测试应用工具模块...")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 测试分页验证
|
|
||||||
print("\n1. 测试分页验证:")
|
|
||||||
page, page_size, error = validate_pagination("2", "50")
|
|
||||||
print(f" 页码={page}, 每页={page_size}, 错误={error}")
|
|
||||||
|
|
||||||
page, page_size, error = validate_pagination("invalid", "50")
|
|
||||||
print(f" 无效输入: 页码={page}, 每页={page_size}, 错误={error}")
|
|
||||||
|
|
||||||
# 测试响应格式化
|
|
||||||
print("\n2. 测试响应格式化:")
|
|
||||||
print(f" 错误响应: {format_error_response('测试错误', need_captcha=True)}")
|
|
||||||
print(f" 成功响应: {format_success_response('测试成功', {'data': [1, 2, 3]})}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("✓ 工具模块加载成功!")
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
浏览器自动下载安装模块
|
|
||||||
检测本地是否有Playwright浏览器,如果没有则自动下载安装
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 设置浏览器安装路径(支持Docker和本地环境)
|
|
||||||
# Docker环境: PLAYWRIGHT_BROWSERS_PATH环境变量已设置为 /ms-playwright
|
|
||||||
# 本地环境: 使用Playwright默认路径
|
|
||||||
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
|
|
||||||
BROWSERS_PATH = os.environ['PLAYWRIGHT_BROWSERS_PATH']
|
|
||||||
else:
|
|
||||||
# Windows: %USERPROFILE%\AppData\Local\ms-playwright
|
|
||||||
# Linux: ~/.cache/ms-playwright
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
BROWSERS_PATH = str(Path.home() / "AppData" / "Local" / "ms-playwright")
|
|
||||||
else:
|
|
||||||
BROWSERS_PATH = str(Path.home() / ".cache" / "ms-playwright")
|
|
||||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = BROWSERS_PATH
|
|
||||||
|
|
||||||
|
|
||||||
class BrowserInstaller:
|
|
||||||
"""浏览器安装器"""
|
|
||||||
|
|
||||||
def __init__(self, log_callback=None):
|
|
||||||
"""
|
|
||||||
初始化安装器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_callback: 日志回调函数
|
|
||||||
"""
|
|
||||||
self.log_callback = log_callback
|
|
||||||
|
|
||||||
def log(self, message):
|
|
||||||
"""输出日志"""
|
|
||||||
if self.log_callback:
|
|
||||||
self.log_callback(message)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
print(message)
|
|
||||||
except UnicodeEncodeError:
|
|
||||||
# 如果打印Unicode字符失败,替换特殊字符
|
|
||||||
safe_message = message.replace('✓', '[OK]').replace('✗', '[X]')
|
|
||||||
print(safe_message)
|
|
||||||
|
|
||||||
def check_playwright_installed(self):
|
|
||||||
"""检查Playwright是否已安装"""
|
|
||||||
try:
|
|
||||||
import playwright
|
|
||||||
self.log("✓ Playwright已安装")
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
self.log("✗ Playwright未安装")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_chromium_installed(self):
|
|
||||||
"""检查Chromium浏览器是否已安装"""
|
|
||||||
try:
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
# 尝试启动浏览器检查是否可用
|
|
||||||
with sync_playwright() as p:
|
|
||||||
try:
|
|
||||||
# 使用超时快速检查
|
|
||||||
browser = p.chromium.launch(headless=True, timeout=5000)
|
|
||||||
browser.close()
|
|
||||||
self.log("✓ Chromium浏览器已安装且可用")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
self.log(f"✗ Chromium浏览器不可用: {error_msg}")
|
|
||||||
|
|
||||||
# 检查是否是路径不存在的错误
|
|
||||||
if "Executable doesn't exist" in error_msg:
|
|
||||||
self.log("检测到浏览器文件缺失,需要重新安装")
|
|
||||||
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"✗ 检查浏览器时出错: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def install_chromium(self):
|
|
||||||
"""安装Chromium浏览器"""
|
|
||||||
try:
|
|
||||||
self.log("正在安装 Chromium 浏览器...")
|
|
||||||
|
|
||||||
# 查找 playwright 可执行文件
|
|
||||||
playwright_cli = None
|
|
||||||
possible_paths = [
|
|
||||||
os.path.join(os.path.dirname(sys.executable), "Scripts", "playwright.exe"),
|
|
||||||
os.path.join(os.path.dirname(sys.executable), "playwright.exe"),
|
|
||||||
os.path.join(os.path.dirname(sys.executable), "Scripts", "playwright"),
|
|
||||||
os.path.join(os.path.dirname(sys.executable), "playwright"),
|
|
||||||
"playwright", # 系统PATH中
|
|
||||||
]
|
|
||||||
|
|
||||||
for path in possible_paths:
|
|
||||||
if os.path.exists(path) or shutil.which(path):
|
|
||||||
playwright_cli = path
|
|
||||||
break
|
|
||||||
|
|
||||||
# 如果找到了 playwright CLI,直接调用
|
|
||||||
if playwright_cli:
|
|
||||||
self.log(f"使用 Playwright CLI: {playwright_cli}")
|
|
||||||
result = subprocess.run(
|
|
||||||
[playwright_cli, "install", "chromium"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 检测是否是 Nuitka 编译的程序
|
|
||||||
is_nuitka = hasattr(sys, 'frozen') or '__compiled__' in globals()
|
|
||||||
|
|
||||||
if is_nuitka:
|
|
||||||
self.log("检测到 Nuitka 编译环境")
|
|
||||||
self.log("✗ 无法找到 playwright CLI 工具")
|
|
||||||
self.log("请手动运行: playwright install chromium")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# 使用 python -m
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.log("✓ Chromium浏览器安装成功")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.log(f"✗ 浏览器安装失败: {result.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.log("✗ 浏览器安装超时")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"✗ 浏览器安装出错: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def auto_install(self):
|
|
||||||
"""
|
|
||||||
自动检测并安装所需环境
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功安装或已安装
|
|
||||||
"""
|
|
||||||
self.log("=" * 60)
|
|
||||||
self.log("检查浏览器环境...")
|
|
||||||
self.log("=" * 60)
|
|
||||||
|
|
||||||
# 1. 检查Playwright是否安装
|
|
||||||
if not self.check_playwright_installed():
|
|
||||||
self.log("✗ Playwright未安装,无法继续")
|
|
||||||
self.log("请确保程序包含 Playwright 库")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 2. 检查Chromium浏览器是否安装
|
|
||||||
if not self.check_chromium_installed():
|
|
||||||
self.log("\n未检测到Chromium浏览器,开始自动安装...")
|
|
||||||
|
|
||||||
# 安装浏览器
|
|
||||||
if not self.install_chromium():
|
|
||||||
self.log("✗ 浏览器安装失败")
|
|
||||||
self.log("\n您可以尝试以下方法:")
|
|
||||||
self.log("1. 手动执行: playwright install chromium")
|
|
||||||
self.log("2. 检查网络连接后重试")
|
|
||||||
self.log("3. 检查防火墙设置")
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.log("\n" + "=" * 60)
|
|
||||||
self.log("✓ 浏览器环境检查完成,一切就绪!")
|
|
||||||
self.log("=" * 60 + "\n")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_and_install_browser(log_callback=None):
|
|
||||||
"""
|
|
||||||
便捷函数:检查并安装浏览器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_callback: 日志回调函数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功
|
|
||||||
"""
|
|
||||||
installer = BrowserInstaller(log_callback)
|
|
||||||
return installer.auto_install()
|
|
||||||
|
|
||||||
|
|
||||||
# 测试代码
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("浏览器自动安装工具")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
installer = BrowserInstaller()
|
|
||||||
success = installer.auto_install()
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print("\n✓ 安装成功!您现在可以运行主程序了。")
|
|
||||||
else:
|
|
||||||
print("\n✗ 安装失败,请查看上方错误信息。")
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
160
browser_pool.py
160
browser_pool.py
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import nest_asyncio
|
|
||||||
nest_asyncio.apply()
|
|
||||||
|
|
||||||
# 线程本地存储
|
|
||||||
_thread_local = threading.local()
|
|
||||||
|
|
||||||
|
|
||||||
class BrowserPool:
|
|
||||||
"""浏览器池 - 使用线程本地存储,每个线程有自己的浏览器"""
|
|
||||||
|
|
||||||
def __init__(self, pool_size=3, log_callback=None):
|
|
||||||
self.pool_size = pool_size
|
|
||||||
self.log_callback = log_callback
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
self.all_browsers = [] # 追踪所有浏览器(用于关闭)
|
|
||||||
self.initialized = True
|
|
||||||
|
|
||||||
def log(self, message):
|
|
||||||
if self.log_callback:
|
|
||||||
self.log_callback(message)
|
|
||||||
else:
|
|
||||||
print(f"[浏览器池] {message}")
|
|
||||||
|
|
||||||
def initialize(self):
|
|
||||||
"""初始化(线程本地模式下不预热)"""
|
|
||||||
self.log(f"浏览器池已就绪(线程本地模式,每线程独立浏览器)")
|
|
||||||
self.initialized = True
|
|
||||||
|
|
||||||
def _create_browser(self):
|
|
||||||
"""创建一个浏览器实例"""
|
|
||||||
try:
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
playwright = sync_playwright().start()
|
|
||||||
browser = playwright.chromium.launch(
|
|
||||||
headless=True,
|
|
||||||
args=[
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-gpu',
|
|
||||||
'--single-process'
|
|
||||||
]
|
|
||||||
)
|
|
||||||
instance = {
|
|
||||||
'playwright': playwright,
|
|
||||||
'browser': browser,
|
|
||||||
'thread_id': threading.current_thread().ident,
|
|
||||||
'created_at': time.time(),
|
|
||||||
'use_count': 0
|
|
||||||
}
|
|
||||||
with self.lock:
|
|
||||||
self.all_browsers.append(instance)
|
|
||||||
return instance
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"创建浏览器失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def acquire(self, timeout=60):
|
|
||||||
"""获取当前线程的浏览器实例(如果没有则创建)"""
|
|
||||||
# 检查当前线程是否已有浏览器
|
|
||||||
browser_instance = getattr(_thread_local, 'browser_instance', None)
|
|
||||||
|
|
||||||
if browser_instance:
|
|
||||||
# 检查浏览器是否还有效
|
|
||||||
try:
|
|
||||||
if browser_instance['browser'].is_connected():
|
|
||||||
browser_instance['use_count'] += 1
|
|
||||||
self.log(f"复用线程浏览器(第{browser_instance['use_count']}次使用)")
|
|
||||||
return browser_instance
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# 浏览器已失效,清理
|
|
||||||
self._close_browser(browser_instance)
|
|
||||||
_thread_local.browser_instance = None
|
|
||||||
|
|
||||||
# 为当前线程创建新浏览器
|
|
||||||
self.log("为当前线程创建新浏览器...")
|
|
||||||
browser_instance = self._create_browser()
|
|
||||||
if browser_instance:
|
|
||||||
browser_instance['use_count'] = 1
|
|
||||||
_thread_local.browser_instance = browser_instance
|
|
||||||
return browser_instance
|
|
||||||
|
|
||||||
def release(self, browser_instance):
|
|
||||||
"""释放浏览器(线程本地模式下保留不关闭)"""
|
|
||||||
if browser_instance is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 检查浏览器是否还有效
|
|
||||||
try:
|
|
||||||
if browser_instance['browser'].is_connected():
|
|
||||||
self.log(f"浏览器保持活跃(已使用{browser_instance['use_count']}次)")
|
|
||||||
return
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 浏览器已断开,清理
|
|
||||||
self.log("浏览器已断开,清理资源")
|
|
||||||
self._close_browser(browser_instance)
|
|
||||||
if getattr(_thread_local, 'browser_instance', None) == browser_instance:
|
|
||||||
_thread_local.browser_instance = None
|
|
||||||
|
|
||||||
def _close_browser(self, browser_instance):
|
|
||||||
"""关闭单个浏览器实例"""
|
|
||||||
try:
|
|
||||||
if browser_instance.get('browser'):
|
|
||||||
browser_instance['browser'].close()
|
|
||||||
if browser_instance.get('playwright'):
|
|
||||||
browser_instance['playwright'].stop()
|
|
||||||
with self.lock:
|
|
||||||
if browser_instance in self.all_browsers:
|
|
||||||
self.all_browsers.remove(browser_instance)
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"关闭浏览器失败: {e}")
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
"""关闭所有浏览器"""
|
|
||||||
self.log("正在关闭所有浏览器...")
|
|
||||||
for browser_instance in list(self.all_browsers):
|
|
||||||
self._close_browser(browser_instance)
|
|
||||||
self.all_browsers.clear()
|
|
||||||
self.initialized = False
|
|
||||||
self.log("浏览器池已关闭")
|
|
||||||
|
|
||||||
def get_status(self):
|
|
||||||
"""获取池状态"""
|
|
||||||
return {
|
|
||||||
'pool_size': self.pool_size,
|
|
||||||
'total_browsers': len(self.all_browsers),
|
|
||||||
'initialized': self.initialized,
|
|
||||||
'mode': 'thread_local'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 全局浏览器池实例
|
|
||||||
_browser_pool = None
|
|
||||||
_pool_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_browser_pool(pool_size=3, log_callback=None):
|
|
||||||
"""获取全局浏览器池实例"""
|
|
||||||
global _browser_pool
|
|
||||||
with _pool_lock:
|
|
||||||
if _browser_pool is None:
|
|
||||||
_browser_pool = BrowserPool(pool_size=pool_size, log_callback=log_callback)
|
|
||||||
return _browser_pool
|
|
||||||
|
|
||||||
|
|
||||||
def init_browser_pool(pool_size=3, log_callback=None):
|
|
||||||
"""初始化浏览器池"""
|
|
||||||
pool = get_browser_pool(pool_size, log_callback)
|
|
||||||
pool.initialize()
|
|
||||||
return pool
|
|
||||||
@@ -1,26 +1,108 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""浏览器池管理 - 工作线程池模式(真正的浏览器复用)"""
|
"""截图线程池管理 - 工作线程池模式(并发执行截图任务)"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Optional, Dict, Any
|
from typing import Callable, Optional, Dict, Any
|
||||||
import nest_asyncio
|
|
||||||
nest_asyncio.apply()
|
|
||||||
|
|
||||||
# 安全修复: 将魔法数字提取为可配置常量
|
# 安全修复: 将魔法数字提取为可配置常量
|
||||||
BROWSER_IDLE_TIMEOUT = int(os.environ.get('BROWSER_IDLE_TIMEOUT', '300')) # 空闲超时(秒),默认5分钟
|
BROWSER_IDLE_TIMEOUT = int(os.environ.get("BROWSER_IDLE_TIMEOUT", "300")) # 空闲超时(秒),默认5分钟
|
||||||
TASK_QUEUE_TIMEOUT = int(os.environ.get('TASK_QUEUE_TIMEOUT', '10')) # 队列获取超时(秒)
|
TASK_QUEUE_TIMEOUT = int(os.environ.get("TASK_QUEUE_TIMEOUT", "10")) # 队列获取超时(秒)
|
||||||
TASK_QUEUE_MAXSIZE = int(os.environ.get('BROWSER_TASK_QUEUE_MAXSIZE', '200')) # 队列最大长度(0表示无限制)
|
TASK_QUEUE_MAXSIZE = int(os.environ.get("BROWSER_TASK_QUEUE_MAXSIZE", "200")) # 队列最大长度(0表示无限制)
|
||||||
BROWSER_MAX_USE_COUNT = int(os.environ.get('BROWSER_MAX_USE_COUNT', '0')) # 每个浏览器最大复用次数(0表示不限制)
|
BROWSER_MAX_USE_COUNT = int(os.environ.get("BROWSER_MAX_USE_COUNT", "0")) # 每个执行环境最大复用次数(0表示不限制)
|
||||||
|
|
||||||
|
# 新增:自适应资源配置
|
||||||
|
ADAPTIVE_CONFIG = os.environ.get("BROWSER_ADAPTIVE_CONFIG", "1").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
LOAD_HISTORY_SIZE = 50 # 负载历史记录大小
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveResourceManager:
|
||||||
|
"""自适应资源管理器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._load_history = []
|
||||||
|
self._current_load = 0
|
||||||
|
self._last_adjustment = 0
|
||||||
|
self._adjustment_cooldown = 30 # 调整冷却时间30秒
|
||||||
|
|
||||||
|
def record_task_interval(self, interval: float):
|
||||||
|
"""记录任务间隔,更新负载历史"""
|
||||||
|
if len(self._load_history) >= LOAD_HISTORY_SIZE:
|
||||||
|
self._load_history.pop(0)
|
||||||
|
self._load_history.append(interval)
|
||||||
|
|
||||||
|
# 计算当前负载
|
||||||
|
if len(self._load_history) >= 2:
|
||||||
|
recent_intervals = self._load_history[-10:] # 最近10个任务
|
||||||
|
avg_interval = sum(recent_intervals) / len(recent_intervals)
|
||||||
|
# 负载越高,间隔越短
|
||||||
|
self._current_load = 1.0 / max(avg_interval, 0.1)
|
||||||
|
|
||||||
|
def should_adjust_timeout(self) -> bool:
|
||||||
|
"""判断是否应该调整超时配置"""
|
||||||
|
if not ADAPTIVE_CONFIG:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._last_adjustment < self._adjustment_cooldown:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return len(self._load_history) >= 10 # 至少需要10个数据点
|
||||||
|
|
||||||
|
def calculate_optimal_idle_timeout(self) -> int:
|
||||||
|
"""基于历史负载计算最优空闲超时"""
|
||||||
|
if not self._load_history:
|
||||||
|
return BROWSER_IDLE_TIMEOUT
|
||||||
|
|
||||||
|
# 计算最近任务间隔的平均值
|
||||||
|
recent_intervals = self._load_history[-20:] # 最近20个任务
|
||||||
|
if len(recent_intervals) < 2:
|
||||||
|
return BROWSER_IDLE_TIMEOUT
|
||||||
|
|
||||||
|
avg_interval = sum(recent_intervals) / len(recent_intervals)
|
||||||
|
|
||||||
|
# 根据负载动态调整超时
|
||||||
|
# 高负载时缩短超时,低负载时延长超时
|
||||||
|
if self._current_load > 2.0: # 高负载
|
||||||
|
optimal_timeout = min(avg_interval * 1.5, 600) # 最多10分钟
|
||||||
|
elif self._current_load < 0.5: # 低负载
|
||||||
|
optimal_timeout = min(avg_interval * 3.0, 1800) # 最多30分钟
|
||||||
|
else: # 正常负载
|
||||||
|
optimal_timeout = min(avg_interval * 2.0, 900) # 最多15分钟
|
||||||
|
|
||||||
|
return max(int(optimal_timeout), 60) # 最少1分钟
|
||||||
|
|
||||||
|
def get_optimal_queue_timeout(self) -> int:
|
||||||
|
"""获取最优队列超时"""
|
||||||
|
if not self._load_history:
|
||||||
|
return TASK_QUEUE_TIMEOUT
|
||||||
|
|
||||||
|
# 根据任务频率调整队列超时
|
||||||
|
if self._current_load > 2.0: # 高负载时减少等待
|
||||||
|
return max(TASK_QUEUE_TIMEOUT // 2, 3)
|
||||||
|
elif self._current_load < 0.5: # 低负载时可以增加等待
|
||||||
|
return min(TASK_QUEUE_TIMEOUT * 2, 30)
|
||||||
|
else:
|
||||||
|
return TASK_QUEUE_TIMEOUT
|
||||||
|
|
||||||
|
def record_adjustment(self):
|
||||||
|
"""记录一次调整操作"""
|
||||||
|
self._last_adjustment = time.time()
|
||||||
|
|
||||||
|
|
||||||
class BrowserWorker(threading.Thread):
|
class BrowserWorker(threading.Thread):
|
||||||
"""浏览器工作线程 - 每个worker维护自己的浏览器"""
|
"""截图工作线程 - 每个worker维护自己的执行环境"""
|
||||||
|
|
||||||
def __init__(self, worker_id: int, task_queue: queue.Queue, log_callback: Optional[Callable] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
worker_id: int,
|
||||||
|
task_queue: queue.Queue,
|
||||||
|
log_callback: Optional[Callable] = None,
|
||||||
|
pre_warm: bool = False,
|
||||||
|
):
|
||||||
super().__init__(daemon=True)
|
super().__init__(daemon=True)
|
||||||
self.worker_id = worker_id
|
self.worker_id = worker_id
|
||||||
self.task_queue = task_queue
|
self.task_queue = task_queue
|
||||||
@@ -30,97 +112,95 @@ class BrowserWorker(threading.Thread):
|
|||||||
self.idle = True
|
self.idle = True
|
||||||
self.total_tasks = 0
|
self.total_tasks = 0
|
||||||
self.failed_tasks = 0
|
self.failed_tasks = 0
|
||||||
|
self.pre_warm = pre_warm
|
||||||
|
self.last_activity_ts = 0.0
|
||||||
|
self.task_start_time = 0.0
|
||||||
|
|
||||||
|
# 初始化自适应资源管理器
|
||||||
|
if ADAPTIVE_CONFIG:
|
||||||
|
self._adaptive_mgr = AdaptiveResourceManager()
|
||||||
|
else:
|
||||||
|
self._adaptive_mgr = None
|
||||||
|
|
||||||
def log(self, message: str):
|
def log(self, message: str):
|
||||||
"""日志输出"""
|
"""日志输出"""
|
||||||
if self.log_callback:
|
if self.log_callback:
|
||||||
self.log_callback(f"[Worker-{self.worker_id}] {message}")
|
self.log_callback(f"[Worker-{self.worker_id}] {message}")
|
||||||
else:
|
else:
|
||||||
print(f"[浏览器池][Worker-{self.worker_id}] {message}")
|
print(f"[截图池][Worker-{self.worker_id}] {message}")
|
||||||
|
|
||||||
def _create_browser(self):
|
def _create_browser(self):
|
||||||
"""创建浏览器实例"""
|
"""创建截图执行环境(逻辑占位,无需真实浏览器)"""
|
||||||
try:
|
created_at = time.time()
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
self.log("正在创建浏览器...")
|
|
||||||
playwright = sync_playwright().start()
|
|
||||||
browser = playwright.chromium.launch(
|
|
||||||
headless=True,
|
|
||||||
args=[
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-gpu',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.browser_instance = {
|
self.browser_instance = {
|
||||||
'playwright': playwright,
|
"created_at": created_at,
|
||||||
'browser': browser,
|
"use_count": 0,
|
||||||
'created_at': time.time(),
|
"worker_id": self.worker_id,
|
||||||
'use_count': 0,
|
|
||||||
'worker_id': self.worker_id
|
|
||||||
}
|
}
|
||||||
self.log(f"浏览器创建成功")
|
self.last_activity_ts = created_at
|
||||||
|
self.log("截图执行环境就绪")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"创建浏览器失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _close_browser(self):
|
def _close_browser(self):
|
||||||
"""关闭浏览器"""
|
"""关闭截图执行环境"""
|
||||||
if self.browser_instance:
|
if self.browser_instance:
|
||||||
try:
|
self.log(f"执行环境已释放(共处理{self.browser_instance.get('use_count', 0)}个任务)")
|
||||||
self.log("正在关闭浏览器...")
|
|
||||||
if self.browser_instance['browser']:
|
|
||||||
self.browser_instance['browser'].close()
|
|
||||||
if self.browser_instance['playwright']:
|
|
||||||
self.browser_instance['playwright'].stop()
|
|
||||||
self.log(f"浏览器已关闭(共处理{self.browser_instance['use_count']}个任务)")
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"关闭浏览器时出错: {e}")
|
|
||||||
finally:
|
|
||||||
self.browser_instance = None
|
self.browser_instance = None
|
||||||
|
|
||||||
def _check_browser_health(self) -> bool:
|
def _check_browser_health(self) -> bool:
|
||||||
"""检查浏览器是否健康"""
|
"""检查执行环境是否就绪"""
|
||||||
if not self.browser_instance:
|
return bool(self.browser_instance)
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self.browser_instance['browser'].is_connected()
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _ensure_browser(self) -> bool:
|
def _ensure_browser(self) -> bool:
|
||||||
"""确保浏览器可用(如果不可用则重新创建)"""
|
"""确保执行环境可用"""
|
||||||
if self._check_browser_health():
|
if self._check_browser_health():
|
||||||
return True
|
return True
|
||||||
|
self.log("执行环境不可用,尝试重新创建...")
|
||||||
# 浏览器不可用,尝试重新创建
|
|
||||||
self.log("浏览器不可用,尝试重新创建...")
|
|
||||||
self._close_browser()
|
self._close_browser()
|
||||||
return self._create_browser()
|
return self._create_browser()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""工作线程主循环 - 按需启动浏览器模式"""
|
"""工作线程主循环 - 按需启动执行环境模式"""
|
||||||
self.log("Worker启动(按需模式,等待任务时不占用浏览器资源)")
|
if self.pre_warm:
|
||||||
last_task_time = 0
|
self.log("Worker启动(预热模式,启动即准备执行环境)")
|
||||||
|
else:
|
||||||
|
self.log("Worker启动(按需模式,等待任务时不占用资源)")
|
||||||
|
|
||||||
|
if self.pre_warm and not self.browser_instance:
|
||||||
|
self._create_browser()
|
||||||
|
self.pre_warm = False
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
|
# 允许运行中触发预热(例如池在初始化后调用 warmup)
|
||||||
|
if self.pre_warm and not self.browser_instance:
|
||||||
|
self._create_browser()
|
||||||
|
self.pre_warm = False
|
||||||
|
|
||||||
# 从队列获取任务(带超时,以便能响应停止信号和空闲检查)
|
# 从队列获取任务(带超时,以便能响应停止信号和空闲检查)
|
||||||
self.idle = True
|
self.idle = True
|
||||||
|
|
||||||
|
# 使用自适应队列超时
|
||||||
|
queue_timeout = (
|
||||||
|
self._adaptive_mgr.get_optimal_queue_timeout() if self._adaptive_mgr else TASK_QUEUE_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task = self.task_queue.get(timeout=TASK_QUEUE_TIMEOUT)
|
task = self.task_queue.get(timeout=queue_timeout)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
# 检查是否需要关闭空闲的浏览器
|
# 检查是否需要释放空闲的执行环境
|
||||||
if self.browser_instance and last_task_time > 0:
|
if self.browser_instance and self.last_activity_ts > 0:
|
||||||
idle_time = time.time() - last_task_time
|
idle_time = time.time() - self.last_activity_ts
|
||||||
if idle_time > BROWSER_IDLE_TIMEOUT:
|
|
||||||
self.log(f"空闲{int(idle_time)}秒,关闭浏览器释放资源")
|
# 使用自适应空闲超时
|
||||||
|
optimal_timeout = (
|
||||||
|
self._adaptive_mgr.calculate_optimal_idle_timeout()
|
||||||
|
if self._adaptive_mgr
|
||||||
|
else BROWSER_IDLE_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if idle_time > optimal_timeout:
|
||||||
|
self.log(f"空闲{int(idle_time)}秒(优化超时:{optimal_timeout}秒),释放执行环境")
|
||||||
self._close_browser()
|
self._close_browser()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -130,46 +210,92 @@ class BrowserWorker(threading.Thread):
|
|||||||
self.log("收到停止信号")
|
self.log("收到停止信号")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 按需创建或确保浏览器可用
|
# 按需创建或确保执行环境可用
|
||||||
if not self._ensure_browser():
|
browser_ready = False
|
||||||
self.log("浏览器不可用,任务失败")
|
for attempt in range(2):
|
||||||
task['callback'](None, "浏览器不可用")
|
if self._ensure_browser():
|
||||||
|
browser_ready = True
|
||||||
|
break
|
||||||
|
if attempt < 1:
|
||||||
|
self.log("执行环境创建失败,重试...")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if not browser_ready:
|
||||||
|
retry_count = int(task.get("retry_count", 0) or 0) if isinstance(task, dict) else 0
|
||||||
|
if retry_count < 1 and isinstance(task, dict):
|
||||||
|
task["retry_count"] = retry_count + 1
|
||||||
|
try:
|
||||||
|
self.task_queue.put(task, timeout=1)
|
||||||
|
self.log("执行环境不可用,任务重新入队")
|
||||||
|
except queue.Full:
|
||||||
|
self.log("任务队列已满,无法重新入队,任务失败")
|
||||||
|
callback = task.get("callback")
|
||||||
|
if callable(callback):
|
||||||
|
callback(None, "执行环境不可用")
|
||||||
|
self.total_tasks += 1
|
||||||
|
self.failed_tasks += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.log("执行环境不可用,任务失败")
|
||||||
|
callback = task.get("callback") if isinstance(task, dict) else None
|
||||||
|
if callable(callback):
|
||||||
|
callback(None, "执行环境不可用")
|
||||||
|
self.total_tasks += 1
|
||||||
self.failed_tasks += 1
|
self.failed_tasks += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 执行任务
|
# 执行任务
|
||||||
task_func = task.get('func')
|
task_func = task.get("func")
|
||||||
task_args = task.get('args', ())
|
task_args = task.get("args", ())
|
||||||
task_kwargs = task.get('kwargs', {})
|
task_kwargs = task.get("kwargs", {})
|
||||||
callback = task.get('callback')
|
callback = task.get("callback")
|
||||||
|
|
||||||
self.total_tasks += 1
|
self.total_tasks += 1
|
||||||
self.browser_instance['use_count'] += 1
|
|
||||||
|
|
||||||
self.log(f"开始执行任务(第{self.browser_instance['use_count']}次使用浏览器)")
|
# 确保browser_instance存在后再访问
|
||||||
|
if self.browser_instance is None:
|
||||||
|
self.log("执行环境不可用,任务失败")
|
||||||
|
if callable(callback):
|
||||||
|
callback(None, "执行环境不可用")
|
||||||
|
self.failed_tasks += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.browser_instance["use_count"] += 1
|
||||||
|
|
||||||
|
self.log(f"开始执行任务(第{self.browser_instance['use_count']}次执行)")
|
||||||
|
|
||||||
|
# 记录任务开始时间
|
||||||
|
task_start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 将浏览器实例传递给任务函数
|
# 将执行环境实例传递给任务函数
|
||||||
result = task_func(self.browser_instance, *task_args, **task_kwargs)
|
result = task_func(self.browser_instance, *task_args, **task_kwargs)
|
||||||
callback(result, None)
|
callback(result, None)
|
||||||
self.log(f"任务执行成功")
|
self.log(f"任务执行成功")
|
||||||
last_task_time = time.time()
|
|
||||||
|
# 记录任务完成并更新负载历史
|
||||||
|
task_end_time = time.time()
|
||||||
|
task_interval = task_end_time - task_start_time
|
||||||
|
if self._adaptive_mgr:
|
||||||
|
self._adaptive_mgr.record_task_interval(task_interval)
|
||||||
|
|
||||||
|
self.last_activity_ts = time.time()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"任务执行失败: {e}")
|
self.log(f"任务执行失败: {e}")
|
||||||
callback(None, str(e))
|
callback(None, str(e))
|
||||||
self.failed_tasks += 1
|
self.failed_tasks += 1
|
||||||
last_task_time = time.time()
|
self.last_activity_ts = time.time()
|
||||||
|
|
||||||
# 任务失败后,检查浏览器健康
|
# 任务失败后,检查执行环境健康
|
||||||
if not self._check_browser_health():
|
if not self._check_browser_health():
|
||||||
self.log("任务失败导致浏览器异常,将在下次任务前重建")
|
self.log("任务失败导致执行环境异常,将在下次任务前重建")
|
||||||
self._close_browser()
|
self._close_browser()
|
||||||
|
|
||||||
# 定期重启浏览器,释放Chromium可能累积的内存
|
# 定期重启执行环境,释放可能累积的资源
|
||||||
if self.browser_instance and BROWSER_MAX_USE_COUNT > 0:
|
if self.browser_instance and BROWSER_MAX_USE_COUNT > 0:
|
||||||
if self.browser_instance.get('use_count', 0) >= BROWSER_MAX_USE_COUNT:
|
if self.browser_instance.get("use_count", 0) >= BROWSER_MAX_USE_COUNT:
|
||||||
self.log(f"浏览器已复用{self.browser_instance['use_count']}次,重启释放资源")
|
self.log(f"执行环境已复用{self.browser_instance['use_count']}次,重启释放资源")
|
||||||
self._close_browser()
|
self._close_browser()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -186,7 +312,7 @@ class BrowserWorker(threading.Thread):
|
|||||||
|
|
||||||
|
|
||||||
class BrowserWorkerPool:
|
class BrowserWorkerPool:
|
||||||
"""浏览器工作线程池"""
|
"""截图工作线程池"""
|
||||||
|
|
||||||
def __init__(self, pool_size: int = 3, log_callback: Optional[Callable] = None):
|
def __init__(self, pool_size: int = 3, log_callback: Optional[Callable] = None):
|
||||||
self.pool_size = pool_size
|
self.pool_size = pool_size
|
||||||
@@ -202,27 +328,61 @@ class BrowserWorkerPool:
|
|||||||
if self.log_callback:
|
if self.log_callback:
|
||||||
self.log_callback(message)
|
self.log_callback(message)
|
||||||
else:
|
else:
|
||||||
print(f"[浏览器池] {message}")
|
print(f"[截图池] {message}")
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""初始化工作线程池(按需模式,启动时不创建浏览器)"""
|
"""初始化工作线程池(按需模式,默认预热1个执行环境)"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.initialized:
|
if self.initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log(f"正在初始化工作线程池({self.pool_size}个worker,按需启动浏览器)...")
|
self.log(f"正在初始化截图线程池({self.pool_size}个worker,按需启动执行环境)...")
|
||||||
|
|
||||||
for i in range(self.pool_size):
|
for i in range(self.pool_size):
|
||||||
worker = BrowserWorker(
|
worker = BrowserWorker(
|
||||||
worker_id=i + 1,
|
worker_id=i + 1,
|
||||||
task_queue=self.task_queue,
|
task_queue=self.task_queue,
|
||||||
log_callback=self.log_callback
|
log_callback=self.log_callback,
|
||||||
|
pre_warm=(i < 1),
|
||||||
)
|
)
|
||||||
worker.start()
|
worker.start()
|
||||||
self.workers.append(worker)
|
self.workers.append(worker)
|
||||||
|
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
self.log(f"✓ 工作线程池初始化完成({self.pool_size}个worker就绪,浏览器将在有任务时按需启动)")
|
self.log(f"[OK] 截图线程池初始化完成({self.pool_size}个worker就绪,执行环境将在有任务时按需启动)")
|
||||||
|
|
||||||
|
# 初始化完成后,默认预热1个执行环境,降低容器重启后前几批任务的冷启动开销
|
||||||
|
self.warmup(1)
|
||||||
|
|
||||||
|
def warmup(self, count: int = 1) -> int:
|
||||||
|
"""预热截图线程池 - 预创建指定数量的执行环境"""
|
||||||
|
if count <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not self.initialized:
|
||||||
|
self.log("警告:线程池未初始化,无法预热")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
target_workers = list(self.workers[: min(count, len(self.workers))])
|
||||||
|
|
||||||
|
self.log(f"预热截图线程池(预创建{len(target_workers)}个执行环境)...")
|
||||||
|
|
||||||
|
for worker in target_workers:
|
||||||
|
if not worker.browser_instance:
|
||||||
|
worker.pre_warm = True
|
||||||
|
|
||||||
|
# 等待预热完成(最多等待20秒,避免阻塞过久)
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while time.time() < deadline:
|
||||||
|
warmed = sum(1 for w in target_workers if w.browser_instance)
|
||||||
|
if warmed >= len(target_workers):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
warmed = sum(1 for w in target_workers if w.browser_instance)
|
||||||
|
self.log(f"[OK] 截图线程池预热完成({warmed}个执行环境就绪)")
|
||||||
|
return warmed
|
||||||
|
|
||||||
def submit_task(self, task_func: Callable, callback: Callable, *args, **kwargs) -> bool:
|
def submit_task(self, task_func: Callable, callback: Callable, *args, **kwargs) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -241,10 +401,11 @@ class BrowserWorkerPool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
task = {
|
task = {
|
||||||
'func': task_func,
|
"func": task_func,
|
||||||
'args': args,
|
"args": args,
|
||||||
'kwargs': kwargs,
|
"kwargs": kwargs,
|
||||||
'callback': callback
|
"callback": callback,
|
||||||
|
"retry_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -256,18 +417,44 @@ class BrowserWorkerPool:
|
|||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""获取线程池统计信息"""
|
"""获取线程池统计信息"""
|
||||||
idle_count = sum(1 for w in self.workers if w.idle)
|
workers = list(self.workers or [])
|
||||||
total_tasks = sum(w.total_tasks for w in self.workers)
|
idle_count = sum(1 for w in workers if getattr(w, "idle", False))
|
||||||
failed_tasks = sum(w.failed_tasks for w in self.workers)
|
total_tasks = sum(int(getattr(w, "total_tasks", 0) or 0) for w in workers)
|
||||||
|
failed_tasks = sum(int(getattr(w, "failed_tasks", 0) or 0) for w in workers)
|
||||||
|
|
||||||
|
worker_details = []
|
||||||
|
for w in workers:
|
||||||
|
browser_instance = getattr(w, "browser_instance", None)
|
||||||
|
browser_use_count = 0
|
||||||
|
browser_created_at = None
|
||||||
|
if isinstance(browser_instance, dict):
|
||||||
|
browser_use_count = int(browser_instance.get("use_count", 0) or 0)
|
||||||
|
browser_created_at = browser_instance.get("created_at")
|
||||||
|
|
||||||
|
worker_details.append(
|
||||||
|
{
|
||||||
|
"worker_id": getattr(w, "worker_id", None),
|
||||||
|
"idle": bool(getattr(w, "idle", False)),
|
||||||
|
"has_browser": bool(browser_instance),
|
||||||
|
"total_tasks": int(getattr(w, "total_tasks", 0) or 0),
|
||||||
|
"failed_tasks": int(getattr(w, "failed_tasks", 0) or 0),
|
||||||
|
"browser_use_count": browser_use_count,
|
||||||
|
"browser_created_at": browser_created_at,
|
||||||
|
"last_active_ts": float(getattr(w, "last_activity_ts", 0) or 0),
|
||||||
|
"thread_alive": bool(getattr(w, "is_alive", lambda: False)()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'pool_size': self.pool_size,
|
"pool_size": self.pool_size,
|
||||||
'idle_workers': idle_count,
|
"idle_workers": idle_count,
|
||||||
'busy_workers': self.pool_size - idle_count,
|
"busy_workers": max(0, len(workers) - idle_count),
|
||||||
'queue_size': self.task_queue.qsize(),
|
"queue_size": self.task_queue.qsize(),
|
||||||
'total_tasks': total_tasks,
|
"total_tasks": total_tasks,
|
||||||
'failed_tasks': failed_tasks,
|
"failed_tasks": failed_tasks,
|
||||||
'success_rate': f"{(total_tasks - failed_tasks) / total_tasks * 100:.1f}%" if total_tasks > 0 else "N/A"
|
"success_rate": f"{(total_tasks - failed_tasks) / total_tasks * 100:.1f}%" if total_tasks > 0 else "N/A",
|
||||||
|
"workers": worker_details,
|
||||||
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def wait_for_completion(self, timeout: Optional[float] = None):
|
def wait_for_completion(self, timeout: Optional[float] = None):
|
||||||
@@ -297,7 +484,7 @@ class BrowserWorkerPool:
|
|||||||
|
|
||||||
self.workers.clear()
|
self.workers.clear()
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
self.log("✓ 工作线程池已关闭")
|
self.log("[OK] 工作线程池已关闭")
|
||||||
|
|
||||||
|
|
||||||
# 全局实例
|
# 全局实例
|
||||||
@@ -306,7 +493,7 @@ _pool_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def get_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None) -> BrowserWorkerPool:
|
def get_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None) -> BrowserWorkerPool:
|
||||||
"""获取全局浏览器工作线程池(单例)"""
|
"""获取全局截图工作线程池(单例)"""
|
||||||
global _global_pool
|
global _global_pool
|
||||||
|
|
||||||
with _pool_lock:
|
with _pool_lock:
|
||||||
@@ -318,12 +505,46 @@ def get_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable]
|
|||||||
|
|
||||||
|
|
||||||
def init_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None):
|
def init_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None):
|
||||||
"""初始化全局浏览器工作线程池"""
|
"""初始化全局截图工作线程池"""
|
||||||
get_browser_worker_pool(pool_size=pool_size, log_callback=log_callback)
|
get_browser_worker_pool(pool_size=pool_size, log_callback=log_callback)
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_pool_when_idle(pool: BrowserWorkerPool) -> None:
|
||||||
|
try:
|
||||||
|
pool.wait_for_completion(timeout=60)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pool.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resize_browser_worker_pool(pool_size: int, log_callback: Optional[Callable] = None) -> bool:
|
||||||
|
"""调整截图线程池并发(新任务走新池,旧池空闲后自动关闭)"""
|
||||||
|
global _global_pool
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_size = max(1, int(pool_size))
|
||||||
|
except Exception:
|
||||||
|
target_size = 1
|
||||||
|
|
||||||
|
with _pool_lock:
|
||||||
|
old_pool = _global_pool
|
||||||
|
if old_pool and int(getattr(old_pool, "pool_size", 0) or 0) == target_size:
|
||||||
|
return False
|
||||||
|
effective_log_callback = log_callback or (getattr(old_pool, "log_callback", None) if old_pool else None)
|
||||||
|
_global_pool = BrowserWorkerPool(pool_size=target_size, log_callback=effective_log_callback)
|
||||||
|
_global_pool.initialize()
|
||||||
|
|
||||||
|
if old_pool:
|
||||||
|
threading.Thread(target=_shutdown_pool_when_idle, args=(old_pool,), daemon=True).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def shutdown_browser_worker_pool():
|
def shutdown_browser_worker_pool():
|
||||||
"""关闭全局浏览器工作线程池"""
|
"""关闭全局截图工作线程池"""
|
||||||
global _global_pool
|
global _global_pool
|
||||||
|
|
||||||
with _pool_lock:
|
with _pool_lock:
|
||||||
@@ -332,15 +553,15 @@ def shutdown_browser_worker_pool():
|
|||||||
_global_pool = None
|
_global_pool = None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# 测试代码
|
# 测试代码
|
||||||
print("测试浏览器工作线程池...")
|
print("测试截图工作线程池...")
|
||||||
|
|
||||||
def test_task(browser_instance, url: str, task_id: int):
|
def test_task(browser_instance, url: str, task_id: int):
|
||||||
"""测试任务:访问URL"""
|
"""测试任务:访问URL"""
|
||||||
print(f"[Task-{task_id}] 开始访问: {url}")
|
print(f"[Task-{task_id}] 开始访问: {url}")
|
||||||
time.sleep(2) # 模拟截图耗时
|
time.sleep(2) # 模拟截图耗时
|
||||||
return {'task_id': task_id, 'url': url, 'status': 'success'}
|
return {"task_id": task_id, "url": url, "status": "success"}
|
||||||
|
|
||||||
def test_callback(result, error):
|
def test_callback(result, error):
|
||||||
"""测试回调"""
|
"""测试回调"""
|
||||||
|
|||||||
149
crypto_utils.py
149
crypto_utils.py
@@ -4,14 +4,23 @@
|
|||||||
加密工具模块
|
加密工具模块
|
||||||
用于加密存储敏感信息(如第三方账号密码)
|
用于加密存储敏感信息(如第三方账号密码)
|
||||||
使用Fernet对称加密
|
使用Fernet对称加密
|
||||||
|
|
||||||
|
安全增强版本 - 2026-01-21
|
||||||
|
- 支持 ENCRYPTION_KEY_RAW 直接使用 Fernet 密钥
|
||||||
|
- 增加密钥丢失保护机制
|
||||||
|
- 增加启动时密钥验证
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import base64
|
import base64
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from app_logger import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# 安全修复: 支持通过环境变量配置密钥文件路径
|
# 安全修复: 支持通过环境变量配置密钥文件路径
|
||||||
@@ -19,18 +28,37 @@ ENCRYPTION_KEY_FILE = os.environ.get('ENCRYPTION_KEY_FILE', 'data/encryption_key
|
|||||||
ENCRYPTION_SALT_FILE = os.environ.get('ENCRYPTION_SALT_FILE', 'data/encryption_salt.bin')
|
ENCRYPTION_SALT_FILE = os.environ.get('ENCRYPTION_SALT_FILE', 'data/encryption_salt.bin')
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_private_dir(path: Path) -> None:
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
os.makedirs(path, mode=0o700, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_private_file(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_salt():
|
def _get_or_create_salt():
|
||||||
"""获取或创建盐值"""
|
"""获取或创建盐值"""
|
||||||
salt_path = Path(ENCRYPTION_SALT_FILE)
|
salt_path = Path(ENCRYPTION_SALT_FILE)
|
||||||
if salt_path.exists():
|
if salt_path.exists():
|
||||||
|
_ensure_private_file(salt_path)
|
||||||
with open(salt_path, 'rb') as f:
|
with open(salt_path, 'rb') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
# 生成新的盐值
|
# 生成新的盐值
|
||||||
salt = os.urandom(16)
|
salt = os.urandom(16)
|
||||||
os.makedirs(salt_path.parent, exist_ok=True)
|
_ensure_private_dir(salt_path.parent)
|
||||||
with open(salt_path, 'wb') as f:
|
with open(salt_path, 'wb') as f:
|
||||||
f.write(salt)
|
f.write(salt)
|
||||||
|
_ensure_private_file(salt_path)
|
||||||
return salt
|
return salt
|
||||||
|
|
||||||
|
|
||||||
@@ -45,37 +73,100 @@ def _derive_key(password: bytes, salt: bytes) -> bytes:
|
|||||||
return base64.urlsafe_b64encode(kdf.derive(password))
|
return base64.urlsafe_b64encode(kdf.derive(password))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_existing_encrypted_data() -> bool:
|
||||||
|
"""
|
||||||
|
检查是否存在已加密的数据
|
||||||
|
用于防止在有加密数据的情况下意外生成新密钥
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
db_path = os.environ.get('DB_FILE', 'data/app_data.db')
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM accounts WHERE password LIKE 'gAAAAA%'")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return count > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"检查加密数据时出错: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_encryption_key():
|
def get_encryption_key():
|
||||||
"""获取加密密钥(优先环境变量,否则从文件读取或生成)"""
|
"""
|
||||||
# 优先从环境变量读取
|
获取加密密钥
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
1. ENCRYPTION_KEY_RAW - 直接使用 Fernet 密钥(推荐用于 Docker 部署)
|
||||||
|
2. ENCRYPTION_KEY - 通过 PBKDF2 派生密钥
|
||||||
|
3. 从文件读取
|
||||||
|
4. 生成新密钥(仅在无现有加密数据时)
|
||||||
|
"""
|
||||||
|
# 优先级 1: 直接使用 Fernet 密钥(推荐)
|
||||||
|
raw_key = os.environ.get('ENCRYPTION_KEY_RAW')
|
||||||
|
if raw_key:
|
||||||
|
logger.info("使用环境变量 ENCRYPTION_KEY_RAW 作为加密密钥")
|
||||||
|
return raw_key.encode() if isinstance(raw_key, str) else raw_key
|
||||||
|
|
||||||
|
# 优先级 2: 从环境变量派生密钥
|
||||||
env_key = os.environ.get('ENCRYPTION_KEY')
|
env_key = os.environ.get('ENCRYPTION_KEY')
|
||||||
if env_key:
|
if env_key:
|
||||||
# 使用环境变量中的密钥派生Fernet密钥
|
logger.info("使用环境变量 ENCRYPTION_KEY 派生加密密钥")
|
||||||
salt = _get_or_create_salt()
|
salt = _get_or_create_salt()
|
||||||
return _derive_key(env_key.encode(), salt)
|
return _derive_key(env_key.encode(), salt)
|
||||||
|
|
||||||
# 从文件读取
|
# 优先级 3: 从文件读取
|
||||||
key_path = Path(ENCRYPTION_KEY_FILE)
|
key_path = Path(ENCRYPTION_KEY_FILE)
|
||||||
if key_path.exists():
|
if key_path.exists():
|
||||||
|
logger.info(f"从文件 {ENCRYPTION_KEY_FILE} 读取加密密钥")
|
||||||
|
_ensure_private_file(key_path)
|
||||||
with open(key_path, 'rb') as f:
|
with open(key_path, 'rb') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
# 优先级 4: 生成新密钥(带保护检查)
|
||||||
|
# 安全检查:如果已有加密数据,禁止生成新密钥
|
||||||
|
if _check_existing_encrypted_data():
|
||||||
|
error_msg = (
|
||||||
|
"\n" + "=" * 60 + "\n"
|
||||||
|
"[严重错误] 检测到数据库中存在已加密的密码数据,但加密密钥文件丢失!\n"
|
||||||
|
"\n"
|
||||||
|
"这将导致所有已加密的密码无法解密!\n"
|
||||||
|
"\n"
|
||||||
|
"解决方案:\n"
|
||||||
|
"1. 恢复 data/encryption_key.bin 文件(如有备份)\n"
|
||||||
|
"2. 或在 docker-compose.yml 中设置 ENCRYPTION_KEY_RAW 环境变量\n"
|
||||||
|
"3. 如果密钥确实丢失,需要重新录入所有账号密码\n"
|
||||||
|
"\n"
|
||||||
|
+ "=" * 60
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
print(error_msg, file=sys.stderr)
|
||||||
|
raise RuntimeError("加密密钥丢失且存在已加密数据,请恢复密钥后再启动")
|
||||||
|
|
||||||
# 生成新的密钥
|
# 生成新的密钥
|
||||||
key = Fernet.generate_key()
|
key = Fernet.generate_key()
|
||||||
os.makedirs(key_path.parent, exist_ok=True)
|
_ensure_private_dir(key_path.parent)
|
||||||
with open(key_path, 'wb') as f:
|
with open(key_path, 'wb') as f:
|
||||||
f.write(key)
|
f.write(key)
|
||||||
print(f"[安全] 已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}")
|
_ensure_private_file(key_path)
|
||||||
|
logger.info(f"已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}")
|
||||||
|
logger.warning("请立即备份此密钥文件,并建议设置 ENCRYPTION_KEY_RAW 环境变量!")
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
# 全局Fernet实例
|
# 全局Fernet实例
|
||||||
_fernet = None
|
_fernet = None
|
||||||
|
_fernet_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet():
|
def _get_fernet():
|
||||||
"""获取Fernet加密器(懒加载)"""
|
"""获取Fernet加密器(懒加载)"""
|
||||||
global _fernet
|
global _fernet
|
||||||
|
if _fernet is None:
|
||||||
|
with _fernet_lock:
|
||||||
if _fernet is None:
|
if _fernet is None:
|
||||||
key = get_encryption_key()
|
key = get_encryption_key()
|
||||||
_fernet = Fernet(key)
|
_fernet = Fernet(key)
|
||||||
@@ -118,8 +209,11 @@ def decrypt_password(encrypted_password: str) -> str:
|
|||||||
decrypted = fernet.decrypt(encrypted_password.encode('utf-8'))
|
decrypted = fernet.decrypt(encrypted_password.encode('utf-8'))
|
||||||
return decrypted.decode('utf-8')
|
return decrypted.decode('utf-8')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 解密失败,可能是旧的明文密码
|
# 解密失败,可能是旧的明文密码或密钥不匹配
|
||||||
print(f"[警告] 密码解密失败,可能是未加密的旧数据: {e}")
|
if is_encrypted(encrypted_password):
|
||||||
|
logger.error(f"密码解密失败(密钥可能不匹配): {e}")
|
||||||
|
return ''
|
||||||
|
logger.warning(f"密码解密失败,可能是未加密的旧数据: {e}")
|
||||||
return encrypted_password
|
return encrypted_password
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +230,6 @@ def is_encrypted(password: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
if not password:
|
if not password:
|
||||||
return False
|
return False
|
||||||
# Fernet加密的数据是base64编码,以'gAAAAA'开头
|
|
||||||
return password.startswith('gAAAAA')
|
return password.startswith('gAAAAA')
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +248,39 @@ def migrate_password(password: str) -> str:
|
|||||||
return encrypt_password(password)
|
return encrypt_password(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_encryption_key() -> bool:
|
||||||
|
"""
|
||||||
|
验证当前密钥是否能解密现有数据
|
||||||
|
用于启动时检查
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 密钥是否有效
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
db_path = os.environ.get('DB_FILE', 'data/app_data.db')
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT password FROM accounts WHERE password LIKE 'gAAAAA%' LIMIT 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 尝试解密
|
||||||
|
fernet = _get_fernet()
|
||||||
|
fernet.decrypt(row[0].encode('utf-8'))
|
||||||
|
logger.info("加密密钥验证成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"加密密钥验证失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# 测试加密解密
|
# 测试加密解密
|
||||||
test_password = "test_password_123"
|
test_password = "test_password_123"
|
||||||
@@ -167,3 +293,6 @@ if __name__ == '__main__':
|
|||||||
print(f"加密解密成功: {test_password == decrypted}")
|
print(f"加密解密成功: {test_password == decrypted}")
|
||||||
print(f"是否已加密: {is_encrypted(encrypted)}")
|
print(f"是否已加密: {is_encrypted(encrypted)}")
|
||||||
print(f"明文是否加密: {is_encrypted(test_password)}")
|
print(f"明文是否加密: {is_encrypted(test_password)}")
|
||||||
|
|
||||||
|
# 验证密钥
|
||||||
|
print(f"\n密钥验证: {verify_encryption_key()}")
|
||||||
|
|||||||
2369
database.py
Executable file → Normal file
2369
database.py
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
10
db/__init__.py
Normal file
10
db/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
DB 包:按领域拆分的 DAO + schema/migrations。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 外部仍通过 `import database` 访问稳定 API
|
||||||
|
- 本包仅提供内部实现与组织结构(P2 / O-07)
|
||||||
|
"""
|
||||||
|
|
||||||
185
db/accounts.py
Normal file
185
db/accounts.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from crypto_utils import decrypt_password, encrypt_password
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
_ACCOUNT_STATUS_QUERY_SQL = """
|
||||||
|
SELECT status, login_fail_count, last_login_error
|
||||||
|
FROM accounts
|
||||||
|
WHERE id = ?
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_account_password(account_dict: dict) -> dict:
|
||||||
|
account_dict["password"] = decrypt_password(account_dict.get("password", ""))
|
||||||
|
return account_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_account_ids(account_ids) -> list[str]:
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for account_id in account_ids or []:
|
||||||
|
if not account_id:
|
||||||
|
continue
|
||||||
|
account_key = str(account_id)
|
||||||
|
if account_key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(account_key)
|
||||||
|
normalized.append(account_key)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def create_account(user_id, account_id, username, password, remember=True, remark=""):
|
||||||
|
"""创建账号(密码加密存储)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
encrypted_password = encrypt_password(password)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts (id, user_id, username, password, remember, remark, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
account_id,
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
encrypted_password,
|
||||||
|
1 if remember else 0,
|
||||||
|
remark,
|
||||||
|
get_cst_now_str(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_accounts(user_id):
|
||||||
|
"""获取用户的所有账号(自动解密密码)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC", (user_id,))
|
||||||
|
return [_decode_account_password(dict(row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_account(account_id):
|
||||||
|
"""获取单个账号(自动解密密码)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _decode_account_password(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_remark(account_id, remark):
|
||||||
|
"""更新账号备注"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE accounts SET remark = ? WHERE id = ?", (remark, account_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_account(account_id):
|
||||||
|
"""删除账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def increment_account_login_fail(account_id, error_message):
|
||||||
|
"""增加账号登录失败次数,如果达到3次则暂停账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT login_fail_count FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
fail_count = int(row["login_fail_count"] or 0) + 1
|
||||||
|
is_suspended = fail_count >= 3
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET login_fail_count = ?,
|
||||||
|
last_login_error = ?,
|
||||||
|
status = CASE WHEN ? = 1 THEN 'suspended' ELSE status END
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(fail_count, error_message, 1 if is_suspended else 0, account_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return is_suspended
|
||||||
|
|
||||||
|
|
||||||
|
def reset_account_login_status(account_id):
|
||||||
|
"""重置账号登录状态(修改密码后调用)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET login_fail_count = 0,
|
||||||
|
last_login_error = NULL,
|
||||||
|
status = 'active'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_status(account_id):
|
||||||
|
"""获取账号状态信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(_ACCOUNT_STATUS_QUERY_SQL, (account_id,))
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_status_batch(account_ids):
|
||||||
|
"""批量获取账号状态信息"""
|
||||||
|
normalized_ids = _normalize_account_ids(account_ids)
|
||||||
|
if not normalized_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
chunk_size = 900 # 避免触发 SQLite 绑定参数上限
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
for idx in range(0, len(normalized_ids), chunk_size):
|
||||||
|
chunk = normalized_ids[idx : idx + chunk_size]
|
||||||
|
placeholders = ",".join("?" for _ in chunk)
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, status, login_fail_count, last_login_error
|
||||||
|
FROM accounts
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
row_dict = dict(row)
|
||||||
|
account_id = str(row_dict.pop("id", ""))
|
||||||
|
if account_id:
|
||||||
|
results[account_id] = row_dict
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_accounts(user_id):
|
||||||
|
"""删除用户的所有账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM accounts WHERE user_id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
427
db/admin.py
Normal file
427
db/admin.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
from password_utils import (
|
||||||
|
hash_password_bcrypt,
|
||||||
|
is_sha256_hash,
|
||||||
|
verify_password_bcrypt,
|
||||||
|
verify_password_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULT_SYSTEM_CONFIG = {
|
||||||
|
"max_concurrent_global": 2,
|
||||||
|
"max_concurrent_per_account": 1,
|
||||||
|
"max_screenshot_concurrent": 3,
|
||||||
|
"db_slow_query_ms": 120,
|
||||||
|
"schedule_enabled": 0,
|
||||||
|
"schedule_time": "02:00",
|
||||||
|
"schedule_browse_type": "应读",
|
||||||
|
"schedule_weekdays": "1,2,3,4,5,6,7",
|
||||||
|
"proxy_enabled": 0,
|
||||||
|
"proxy_api_url": "",
|
||||||
|
"proxy_expire_minutes": 3,
|
||||||
|
"enable_screenshot": 1,
|
||||||
|
"auto_approve_enabled": 0,
|
||||||
|
"auto_approve_hourly_limit": 10,
|
||||||
|
"auto_approve_vip_days": 7,
|
||||||
|
"kdocs_enabled": 0,
|
||||||
|
"kdocs_doc_url": "",
|
||||||
|
"kdocs_default_unit": "",
|
||||||
|
"kdocs_sheet_name": "",
|
||||||
|
"kdocs_sheet_index": 0,
|
||||||
|
"kdocs_unit_column": "A",
|
||||||
|
"kdocs_image_column": "D",
|
||||||
|
"kdocs_admin_notify_enabled": 0,
|
||||||
|
"kdocs_admin_notify_email": "",
|
||||||
|
"kdocs_row_start": 0,
|
||||||
|
"kdocs_row_end": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SYSTEM_CONFIG_UPDATERS = (
|
||||||
|
("max_concurrent_global", "max_concurrent"),
|
||||||
|
("schedule_enabled", "schedule_enabled"),
|
||||||
|
("schedule_time", "schedule_time"),
|
||||||
|
("schedule_browse_type", "schedule_browse_type"),
|
||||||
|
("schedule_weekdays", "schedule_weekdays"),
|
||||||
|
("max_concurrent_per_account", "max_concurrent_per_account"),
|
||||||
|
("max_screenshot_concurrent", "max_screenshot_concurrent"),
|
||||||
|
("db_slow_query_ms", "db_slow_query_ms"),
|
||||||
|
("enable_screenshot", "enable_screenshot"),
|
||||||
|
("proxy_enabled", "proxy_enabled"),
|
||||||
|
("proxy_api_url", "proxy_api_url"),
|
||||||
|
("proxy_expire_minutes", "proxy_expire_minutes"),
|
||||||
|
("auto_approve_enabled", "auto_approve_enabled"),
|
||||||
|
("auto_approve_hourly_limit", "auto_approve_hourly_limit"),
|
||||||
|
("auto_approve_vip_days", "auto_approve_vip_days"),
|
||||||
|
("kdocs_enabled", "kdocs_enabled"),
|
||||||
|
("kdocs_doc_url", "kdocs_doc_url"),
|
||||||
|
("kdocs_default_unit", "kdocs_default_unit"),
|
||||||
|
("kdocs_sheet_name", "kdocs_sheet_name"),
|
||||||
|
("kdocs_sheet_index", "kdocs_sheet_index"),
|
||||||
|
("kdocs_unit_column", "kdocs_unit_column"),
|
||||||
|
("kdocs_image_column", "kdocs_image_column"),
|
||||||
|
("kdocs_admin_notify_enabled", "kdocs_admin_notify_enabled"),
|
||||||
|
("kdocs_admin_notify_email", "kdocs_admin_notify_email"),
|
||||||
|
("kdocs_row_start", "kdocs_row_start"),
|
||||||
|
("kdocs_row_end", "kdocs_row_end"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_scalar(cursor, sql: str, params=()) -> int:
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if "count" in row.keys():
|
||||||
|
return int(row["count"] or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return int(row[0] or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(cursor, table_name: str) -> bool:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name=?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
return bool(cursor.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_days(days, default: int = 30) -> int:
|
||||||
|
try:
|
||||||
|
value = int(days)
|
||||||
|
except Exception:
|
||||||
|
value = default
|
||||||
|
if value < 0:
|
||||||
|
return 0
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _store_default_admin_credentials(username: str, password: str) -> str | None:
|
||||||
|
"""将首次管理员账号密码写入受限权限文件,避免打印到日志。"""
|
||||||
|
raw_path = str(
|
||||||
|
os.environ.get("DEFAULT_ADMIN_CREDENTIALS_FILE", "data/default_admin_credentials.txt") or ""
|
||||||
|
).strip()
|
||||||
|
if not raw_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cred_path = Path(raw_path)
|
||||||
|
try:
|
||||||
|
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cred_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("安全提醒:首次管理员账号已创建\n")
|
||||||
|
f.write(f"用户名: {username}\n")
|
||||||
|
f.write(f"密码: {password}\n")
|
||||||
|
f.write("请登录后立即修改密码,并删除该文件。\n")
|
||||||
|
os.chmod(cred_path, 0o600)
|
||||||
|
return str(cred_path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_admin() -> bool:
|
||||||
|
"""确保存在默认管理员账号(行为保持不变)。"""
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
count = _count_scalar(cursor, "SELECT COUNT(*) as count FROM admins")
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
bootstrap_password = str(os.environ.get("DEFAULT_ADMIN_PASSWORD", "") or "").strip()
|
||||||
|
random_password = bootstrap_password or "".join(secrets.choice(alphabet) for _ in range(12))
|
||||||
|
|
||||||
|
default_password_hash = hash_password_bcrypt(random_password)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO admins (username, password_hash, created_at) VALUES (?, ?, ?)",
|
||||||
|
("admin", default_password_hash, get_cst_now_str()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
credential_file = _store_default_admin_credentials("admin", random_password)
|
||||||
|
print("=" * 60)
|
||||||
|
print("安全提醒:已创建默认管理员账号")
|
||||||
|
print("用户名: admin")
|
||||||
|
if credential_file:
|
||||||
|
print(f"初始密码已写入: {credential_file}(权限600)")
|
||||||
|
print("请立即登录后修改密码,并删除该文件。")
|
||||||
|
else:
|
||||||
|
print("未能写入初始密码文件。")
|
||||||
|
print("建议设置 DEFAULT_ADMIN_PASSWORD 后重建管理员账号。")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(username: str, password: str):
|
||||||
|
"""验证管理员登录 - 自动从SHA256升级到bcrypt(行为保持不变)。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM admins WHERE username = ?", (username,))
|
||||||
|
admin = cursor.fetchone()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
admin_dict = dict(admin)
|
||||||
|
password_hash = admin_dict["password_hash"]
|
||||||
|
|
||||||
|
if is_sha256_hash(password_hash):
|
||||||
|
if verify_password_sha256(password, password_hash):
|
||||||
|
new_hash = hash_password_bcrypt(password)
|
||||||
|
cursor.execute("UPDATE admins SET password_hash = ? WHERE username = ?", (new_hash, username))
|
||||||
|
conn.commit()
|
||||||
|
print(f"管理员 {username} 密码已自动升级到bcrypt")
|
||||||
|
return admin_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
if verify_password_bcrypt(password, password_hash):
|
||||||
|
return admin_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_by_username(username: str):
|
||||||
|
"""根据用户名获取管理员记录"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM admins WHERE username = ?", (username,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_by_id(admin_id: int):
|
||||||
|
"""根据ID获取管理员记录"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM admins WHERE id = ?", (int(admin_id),))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_admin_password(username: str, new_password: str) -> bool:
|
||||||
|
"""更新管理员密码"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(new_password)
|
||||||
|
cursor.execute("UPDATE admins SET password_hash = ? WHERE username = ?", (password_hash, username))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_admin_username(old_username: str, new_username: str) -> bool:
|
||||||
|
"""更新管理员用户名"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE admins SET username = ? WHERE username = ?", (new_username, old_username))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_stats() -> dict:
|
||||||
|
"""获取系统统计信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_users,
|
||||||
|
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved_users,
|
||||||
|
SUM(CASE WHEN date(created_at) = date('now', 'localtime') THEN 1 ELSE 0 END) AS new_users_today,
|
||||||
|
SUM(CASE WHEN datetime(created_at) >= datetime('now', 'localtime', '-7 days') THEN 1 ELSE 0 END) AS new_users_7d,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN vip_expire_time IS NOT NULL
|
||||||
|
AND datetime(vip_expire_time) > datetime('now', 'localtime')
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
) AS vip_users
|
||||||
|
FROM users
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
user_stats = cursor.fetchone() or {}
|
||||||
|
|
||||||
|
def _to_int(key: str) -> int:
|
||||||
|
try:
|
||||||
|
return int(user_stats[key] or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_accounts = _count_scalar(cursor, "SELECT COUNT(*) as count FROM accounts")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": _to_int("total_users"),
|
||||||
|
"approved_users": _to_int("approved_users"),
|
||||||
|
"new_users_today": _to_int("new_users_today"),
|
||||||
|
"new_users_7d": _to_int("new_users_7d"),
|
||||||
|
"total_accounts": total_accounts,
|
||||||
|
"vip_users": _to_int("vip_users"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_config_raw() -> dict:
|
||||||
|
"""获取系统配置(无缓存,供 facade 做缓存/失效)。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM system_config WHERE id = 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return dict(_DEFAULT_SYSTEM_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def update_system_config(
|
||||||
|
*,
|
||||||
|
max_concurrent=None,
|
||||||
|
schedule_enabled=None,
|
||||||
|
schedule_time=None,
|
||||||
|
schedule_browse_type=None,
|
||||||
|
schedule_weekdays=None,
|
||||||
|
max_concurrent_per_account=None,
|
||||||
|
max_screenshot_concurrent=None,
|
||||||
|
enable_screenshot=None,
|
||||||
|
proxy_enabled=None,
|
||||||
|
proxy_api_url=None,
|
||||||
|
proxy_expire_minutes=None,
|
||||||
|
auto_approve_enabled=None,
|
||||||
|
auto_approve_hourly_limit=None,
|
||||||
|
auto_approve_vip_days=None,
|
||||||
|
kdocs_enabled=None,
|
||||||
|
kdocs_doc_url=None,
|
||||||
|
kdocs_default_unit=None,
|
||||||
|
kdocs_sheet_name=None,
|
||||||
|
kdocs_sheet_index=None,
|
||||||
|
kdocs_unit_column=None,
|
||||||
|
kdocs_image_column=None,
|
||||||
|
kdocs_admin_notify_enabled=None,
|
||||||
|
kdocs_admin_notify_email=None,
|
||||||
|
kdocs_row_start=None,
|
||||||
|
kdocs_row_end=None,
|
||||||
|
db_slow_query_ms=None,
|
||||||
|
) -> bool:
|
||||||
|
"""更新系统配置(仅更新DB,不做缓存处理)。"""
|
||||||
|
arg_values = {
|
||||||
|
"max_concurrent": max_concurrent,
|
||||||
|
"schedule_enabled": schedule_enabled,
|
||||||
|
"schedule_time": schedule_time,
|
||||||
|
"schedule_browse_type": schedule_browse_type,
|
||||||
|
"schedule_weekdays": schedule_weekdays,
|
||||||
|
"max_concurrent_per_account": max_concurrent_per_account,
|
||||||
|
"max_screenshot_concurrent": max_screenshot_concurrent,
|
||||||
|
"enable_screenshot": enable_screenshot,
|
||||||
|
"proxy_enabled": proxy_enabled,
|
||||||
|
"proxy_api_url": proxy_api_url,
|
||||||
|
"proxy_expire_minutes": proxy_expire_minutes,
|
||||||
|
"auto_approve_enabled": auto_approve_enabled,
|
||||||
|
"auto_approve_hourly_limit": auto_approve_hourly_limit,
|
||||||
|
"auto_approve_vip_days": auto_approve_vip_days,
|
||||||
|
"kdocs_enabled": kdocs_enabled,
|
||||||
|
"kdocs_doc_url": kdocs_doc_url,
|
||||||
|
"kdocs_default_unit": kdocs_default_unit,
|
||||||
|
"kdocs_sheet_name": kdocs_sheet_name,
|
||||||
|
"kdocs_sheet_index": kdocs_sheet_index,
|
||||||
|
"kdocs_unit_column": kdocs_unit_column,
|
||||||
|
"kdocs_image_column": kdocs_image_column,
|
||||||
|
"kdocs_admin_notify_enabled": kdocs_admin_notify_enabled,
|
||||||
|
"kdocs_admin_notify_email": kdocs_admin_notify_email,
|
||||||
|
"kdocs_row_start": kdocs_row_start,
|
||||||
|
"kdocs_row_end": kdocs_row_end,
|
||||||
|
"db_slow_query_ms": db_slow_query_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
for db_field, arg_name in _SYSTEM_CONFIG_UPDATERS:
|
||||||
|
value = arg_values.get(arg_name)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
updates.append(f"{db_field} = ?")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
params.append(get_cst_now_str())
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
sql = f"UPDATE system_config SET {', '.join(updates)} WHERE id = 1"
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_hourly_registration_count() -> int:
|
||||||
|
"""获取最近一小时内的注册用户数"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
return _count_scalar(
|
||||||
|
cursor,
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) as count FROM users
|
||||||
|
WHERE created_at >= datetime('now', 'localtime', '-1 hour')
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 密码重置(管理员) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def admin_reset_user_password(user_id: int, new_password: str) -> bool:
|
||||||
|
"""管理员直接重置用户密码"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(new_password)
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"管理员重置密码失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_old_operation_logs(days: int = 30) -> int:
|
||||||
|
"""清理指定天数前的操作日志(如果存在operation_logs表)"""
|
||||||
|
safe_days = _normalize_days(days, default=30)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if not _table_exists(cursor, "operation_logs"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM operation_logs
|
||||||
|
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
|
||||||
|
""",
|
||||||
|
(safe_days,),
|
||||||
|
)
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
print(f"已清理 {deleted_count} 条旧操作日志 (>{safe_days}天)")
|
||||||
|
return deleted_count
|
||||||
|
except Exception as e:
|
||||||
|
print(f"清理旧操作日志失败: {e}")
|
||||||
|
return 0
|
||||||
161
db/announcements.py
Normal file
161
db/announcements.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
parsed = max(minimum, parsed)
|
||||||
|
parsed = min(maximum, parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_offset(value, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
return max(0, parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_announcement_payload(title, content, image_url):
|
||||||
|
normalized_title = str(title or "").strip()
|
||||||
|
normalized_content = str(content or "").strip()
|
||||||
|
normalized_image = str(image_url or "").strip() or None
|
||||||
|
return normalized_title, normalized_content, normalized_image
|
||||||
|
|
||||||
|
|
||||||
|
def _deactivate_all_active_announcements(cursor, cst_time: str) -> None:
|
||||||
|
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
|
||||||
|
|
||||||
|
|
||||||
|
def create_announcement(title, content, image_url=None, is_active=True):
|
||||||
|
"""创建公告(默认启用;启用时会自动停用其他公告)"""
|
||||||
|
title, content, image_url = _normalize_announcement_payload(title, content, image_url)
|
||||||
|
if not title or not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
_deactivate_all_active_announcements(cursor, cst_time)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO announcements (title, content, image_url, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(title, content, image_url, 1 if is_active else 0, cst_time, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcement_by_id(announcement_id):
|
||||||
|
"""根据ID获取公告"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM announcements WHERE id = ?", (announcement_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcements(limit=50, offset=0):
|
||||||
|
"""获取公告列表(管理员用)"""
|
||||||
|
safe_limit = _normalize_limit(limit, 50)
|
||||||
|
safe_offset = _normalize_offset(offset, 0)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM announcements
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(safe_limit, safe_offset),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def set_announcement_active(announcement_id, is_active):
|
||||||
|
"""启用/停用公告;启用时会自动停用其他公告"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
_deactivate_all_active_announcements(cursor, cst_time)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE announcements
|
||||||
|
SET is_active = 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, announcement_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE announcements
|
||||||
|
SET is_active = 0, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, announcement_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_announcement(announcement_id):
|
||||||
|
"""删除公告(同时清理用户关闭记录)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM announcement_dismissals WHERE announcement_id = ?", (announcement_id,))
|
||||||
|
cursor.execute("DELETE FROM announcements WHERE id = ?", (announcement_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_announcement_for_user(user_id):
|
||||||
|
"""获取当前用户应展示的启用公告(已永久关闭的不再返回)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.*
|
||||||
|
FROM announcements a
|
||||||
|
LEFT JOIN announcement_dismissals d
|
||||||
|
ON d.announcement_id = a.id AND d.user_id = ?
|
||||||
|
WHERE a.is_active = 1 AND d.announcement_id IS NULL
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def dismiss_announcement_for_user(user_id, announcement_id):
|
||||||
|
"""用户永久关闭某条公告(幂等)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO announcement_dismissals (user_id, announcement_id, dismissed_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, announcement_id, get_cst_now_str()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount >= 0
|
||||||
83
db/email.py
Normal file
83
db/email.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool_with_default(value, default: bool = True) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return bool(int(value))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return bool(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_notify_enabled(enabled) -> int:
|
||||||
|
if isinstance(enabled, bool):
|
||||||
|
return 1 if enabled else 0
|
||||||
|
try:
|
||||||
|
return 1 if int(enabled) else 0
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email):
|
||||||
|
"""根据邮箱获取用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_email(user_id, email, verified=False):
|
||||||
|
"""更新用户邮箱"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET email = ?, email_verified = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(email, 1 if verified else 0, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_email_notify(user_id, enabled):
|
||||||
|
"""更新用户邮件通知偏好"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET email_notify_enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(_normalize_notify_enabled(enabled), user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_email_notify(user_id):
|
||||||
|
"""获取用户邮件通知偏好(默认开启)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT email_notify_enabled FROM users WHERE id = ?", (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
return _to_bool_with_default(row[0], default=True)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
178
db/feedbacks.py
Normal file
178
db/feedbacks.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import escape_html, get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
parsed = max(minimum, parsed)
|
||||||
|
parsed = min(maximum, parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_offset(value, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
return max(0, parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_text(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = str(value)
|
||||||
|
return escape_html(text) if text else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_feedback_filter_sql(status_filter=None) -> tuple[str, list]:
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
where_clauses.append("status = ?")
|
||||||
|
params.append(status_filter)
|
||||||
|
|
||||||
|
return " AND ".join(where_clauses), params
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_feedback_stats_row(row) -> dict:
|
||||||
|
row_dict = dict(row) if row else {}
|
||||||
|
return {
|
||||||
|
"total": int(row_dict.get("total") or 0),
|
||||||
|
"pending": int(row_dict.get("pending") or 0),
|
||||||
|
"replied": int(row_dict.get("replied") or 0),
|
||||||
|
"closed": int(row_dict.get("closed") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_bug_feedback(user_id, username, title, description, contact=""):
|
||||||
|
"""创建Bug反馈(带XSS防护)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
_safe_text(username),
|
||||||
|
_safe_text(title),
|
||||||
|
_safe_text(description),
|
||||||
|
_safe_text(contact),
|
||||||
|
get_cst_now_str(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_bug_feedbacks(limit=100, offset=0, status_filter=None):
|
||||||
|
"""获取Bug反馈列表(管理员用)"""
|
||||||
|
safe_limit = _normalize_limit(limit, 100, minimum=1, maximum=1000)
|
||||||
|
safe_offset = _normalize_offset(offset, 0)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
where_sql, params = _build_feedback_filter_sql(status_filter=status_filter)
|
||||||
|
sql = f"""
|
||||||
|
SELECT * FROM bug_feedbacks
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
cursor.execute(sql, params + [safe_limit, safe_offset])
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_feedbacks(user_id, limit=50):
|
||||||
|
"""获取用户自己的反馈列表"""
|
||||||
|
safe_limit = _normalize_limit(limit, 50, minimum=1, maximum=1000)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM bug_feedbacks
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, safe_limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_by_id(feedback_id):
|
||||||
|
"""根据ID获取反馈详情"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM bug_feedbacks WHERE id = ?", (feedback_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def reply_feedback(feedback_id, admin_reply):
|
||||||
|
"""管理员回复反馈(带XSS防护)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bug_feedbacks
|
||||||
|
SET admin_reply = ?, status = 'replied', replied_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(_safe_text(admin_reply), get_cst_now_str(), feedback_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def close_feedback(feedback_id):
|
||||||
|
"""关闭反馈"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bug_feedbacks
|
||||||
|
SET status = 'closed'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(feedback_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_feedback(feedback_id):
|
||||||
|
"""删除反馈"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM bug_feedbacks WHERE id = ?", (feedback_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_stats():
|
||||||
|
"""获取反馈统计"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||||
|
SUM(CASE WHEN status = 'replied' THEN 1 ELSE 0 END) as replied,
|
||||||
|
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||||
|
FROM bug_feedbacks
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return _normalize_feedback_stats_row(cursor.fetchone())
|
||||||
935
db/migrations.py
Normal file
935
db/migrations.py
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version(conn) -> int:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT version FROM db_version WHERE id = 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row["version"])
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return int(row[0])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_version(conn, version: int) -> None:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE db_version SET version = ?, updated_at = ? WHERE id = 1", (int(version), get_cst_now_str()))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(cursor, table_name: str) -> bool:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (str(table_name),))
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_table_columns(cursor, table_name: str) -> set[str]:
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
return {col[1] for col in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(cursor, table_name: str, columns: set[str], column_name: str, column_ddl: str, *, ok_message: str) -> bool:
|
||||||
|
if column_name in columns:
|
||||||
|
return False
|
||||||
|
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}")
|
||||||
|
columns.add(column_name)
|
||||||
|
print(ok_message)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _read_row_value(row, key: str, index: int):
|
||||||
|
if isinstance(row, sqlite3.Row):
|
||||||
|
return row[key]
|
||||||
|
return row[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_migration_steps():
|
||||||
|
return [
|
||||||
|
(1, _migrate_to_v1),
|
||||||
|
(2, _migrate_to_v2),
|
||||||
|
(3, _migrate_to_v3),
|
||||||
|
(4, _migrate_to_v4),
|
||||||
|
(5, _migrate_to_v5),
|
||||||
|
(6, _migrate_to_v6),
|
||||||
|
(7, _migrate_to_v7),
|
||||||
|
(8, _migrate_to_v8),
|
||||||
|
(9, _migrate_to_v9),
|
||||||
|
(10, _migrate_to_v10),
|
||||||
|
(11, _migrate_to_v11),
|
||||||
|
(12, _migrate_to_v12),
|
||||||
|
(13, _migrate_to_v13),
|
||||||
|
(14, _migrate_to_v14),
|
||||||
|
(15, _migrate_to_v15),
|
||||||
|
(16, _migrate_to_v16),
|
||||||
|
(17, _migrate_to_v17),
|
||||||
|
(18, _migrate_to_v18),
|
||||||
|
(19, _migrate_to_v19),
|
||||||
|
(20, _migrate_to_v20),
|
||||||
|
(21, _migrate_to_v21),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database(conn, target_version: int) -> None:
|
||||||
|
"""数据库迁移:按版本增量升级(向前兼容)。"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
target_version = int(target_version)
|
||||||
|
current_version = get_current_version(conn)
|
||||||
|
|
||||||
|
for version, migrate_fn in _get_migration_steps():
|
||||||
|
if version > target_version or current_version >= version:
|
||||||
|
continue
|
||||||
|
migrate_fn(conn)
|
||||||
|
current_version = version
|
||||||
|
|
||||||
|
stored_version = get_current_version(conn)
|
||||||
|
if stored_version != current_version:
|
||||||
|
set_current_version(conn, current_version)
|
||||||
|
|
||||||
|
if current_version != target_version:
|
||||||
|
print(f" [WARN] 目标版本 {target_version} 未完全可达,当前停留在 {current_version}")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v1(conn):
|
||||||
|
"""迁移到版本1 - 添加缺失字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
system_columns = _get_table_columns(cursor, "system_config")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"schedule_weekdays",
|
||||||
|
'TEXT DEFAULT "1,2,3,4,5,6,7"',
|
||||||
|
ok_message=" [OK] 添加 schedule_weekdays 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"max_screenshot_concurrent",
|
||||||
|
"INTEGER DEFAULT 3",
|
||||||
|
ok_message=" [OK] 添加 max_screenshot_concurrent 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"max_concurrent_per_account",
|
||||||
|
"INTEGER DEFAULT 1",
|
||||||
|
ok_message=" [OK] 添加 max_concurrent_per_account 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"auto_approve_enabled",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 auto_approve_enabled 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"auto_approve_hourly_limit",
|
||||||
|
"INTEGER DEFAULT 10",
|
||||||
|
ok_message=" [OK] 添加 auto_approve_hourly_limit 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
"auto_approve_vip_days",
|
||||||
|
"INTEGER DEFAULT 7",
|
||||||
|
ok_message=" [OK] 添加 auto_approve_vip_days 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
task_log_columns = _get_table_columns(cursor, "task_logs")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"task_logs",
|
||||||
|
task_log_columns,
|
||||||
|
"duration",
|
||||||
|
"INTEGER",
|
||||||
|
ok_message=" [OK] 添加 duration 字段到 task_logs",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v2(conn):
|
||||||
|
"""迁移到版本2 - 添加代理配置字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "system_config")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"proxy_enabled",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 proxy_enabled 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"proxy_api_url",
|
||||||
|
'TEXT DEFAULT ""',
|
||||||
|
ok_message=" [OK] 添加 proxy_api_url 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"proxy_expire_minutes",
|
||||||
|
"INTEGER DEFAULT 3",
|
||||||
|
ok_message=" [OK] 添加 proxy_expire_minutes 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"enable_screenshot",
|
||||||
|
"INTEGER DEFAULT 1",
|
||||||
|
ok_message=" [OK] 添加 enable_screenshot 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v3(conn):
|
||||||
|
"""迁移到版本3 - 添加账号状态和登录失败计数字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "accounts")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"accounts",
|
||||||
|
columns,
|
||||||
|
"status",
|
||||||
|
'TEXT DEFAULT "active"',
|
||||||
|
ok_message=" [OK] 添加 accounts.status 字段 (账号状态)",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"accounts",
|
||||||
|
columns,
|
||||||
|
"login_fail_count",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 accounts.login_fail_count 字段 (登录失败计数)",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"accounts",
|
||||||
|
columns,
|
||||||
|
"last_login_error",
|
||||||
|
"TEXT",
|
||||||
|
ok_message=" [OK] 添加 accounts.last_login_error 字段 (最后登录错误)",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v4(conn):
|
||||||
|
"""迁移到版本4 - 添加任务来源字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "task_logs")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"task_logs",
|
||||||
|
columns,
|
||||||
|
"source",
|
||||||
|
'TEXT DEFAULT "manual"',
|
||||||
|
ok_message=" [OK] 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v5(conn):
|
||||||
|
"""迁移到版本5 - 添加用户定时任务表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_schedules'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT DEFAULT '我的定时任务',
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
schedule_time TEXT NOT NULL DEFAULT '08:00',
|
||||||
|
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
|
||||||
|
browse_type TEXT NOT NULL DEFAULT '应读',
|
||||||
|
enable_screenshot INTEGER DEFAULT 1,
|
||||||
|
account_ids TEXT,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
next_run_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" [OK] 创建 user_schedules 表 (用户定时任务)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schedule_execution_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
schedule_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
schedule_name TEXT,
|
||||||
|
execute_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
total_accounts INTEGER DEFAULT 0,
|
||||||
|
success_accounts INTEGER DEFAULT 0,
|
||||||
|
failed_accounts INTEGER DEFAULT 0,
|
||||||
|
total_items INTEGER DEFAULT 0,
|
||||||
|
total_attachments INTEGER DEFAULT 0,
|
||||||
|
total_screenshots INTEGER DEFAULT 0,
|
||||||
|
duration_seconds INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'running',
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (schedule_id) REFERENCES user_schedules (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" [OK] 创建 schedule_execution_logs 表 (定时任务执行日志)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
|
||||||
|
print(" [OK] 创建 user_schedules 表索引")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v6(conn):
|
||||||
|
"""迁移到版本6 - 添加公告功能相关表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcements'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" [OK] 创建 announcements 表 (公告)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
|
||||||
|
print(" [OK] 创建 announcements 表索引")
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement_dismissals'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement_dismissals (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
announcement_id INTEGER NOT NULL,
|
||||||
|
dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, announcement_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" [OK] 创建 announcement_dismissals 表 (公告永久关闭记录)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
|
||||||
|
print(" [OK] 创建 announcement_dismissals 表索引")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v7(conn):
|
||||||
|
"""迁移到版本7 - 统一存储北京时间(将历史UTC时间字段整体+8小时)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
columns_cache: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
def shift_utc_to_cst(table_name: str, column_name: str) -> None:
|
||||||
|
if not _table_exists(cursor, table_name):
|
||||||
|
return
|
||||||
|
|
||||||
|
if table_name not in columns_cache:
|
||||||
|
columns_cache[table_name] = _get_table_columns(cursor, table_name)
|
||||||
|
if column_name not in columns_cache[table_name]:
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE {table_name}
|
||||||
|
SET {column_name} = datetime({column_name}, '+8 hours')
|
||||||
|
WHERE {column_name} IS NOT NULL AND {column_name} != ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for table, col in [
|
||||||
|
("users", "created_at"),
|
||||||
|
("users", "approved_at"),
|
||||||
|
("admins", "created_at"),
|
||||||
|
("accounts", "created_at"),
|
||||||
|
("password_reset_requests", "created_at"),
|
||||||
|
("password_reset_requests", "processed_at"),
|
||||||
|
("smtp_configs", "created_at"),
|
||||||
|
("smtp_configs", "updated_at"),
|
||||||
|
("smtp_configs", "last_success_at"),
|
||||||
|
("email_settings", "updated_at"),
|
||||||
|
("email_tokens", "created_at"),
|
||||||
|
("email_logs", "created_at"),
|
||||||
|
("email_stats", "last_updated"),
|
||||||
|
("task_checkpoints", "created_at"),
|
||||||
|
("task_checkpoints", "updated_at"),
|
||||||
|
("task_checkpoints", "completed_at"),
|
||||||
|
]:
|
||||||
|
shift_utc_to_cst(table, col)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(" [OK] 时区迁移:历史UTC时间已转换为北京时间")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v8(conn):
|
||||||
|
"""迁移到版本8 - 用户定时 next_run_at 随机延迟落库(O-08)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 1) 增量字段:random_delay(旧库可能不存在)
|
||||||
|
columns = _get_table_columns(cursor, "user_schedules")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"user_schedules",
|
||||||
|
columns,
|
||||||
|
"random_delay",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 user_schedules.random_delay 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"user_schedules",
|
||||||
|
columns,
|
||||||
|
"next_run_at",
|
||||||
|
"TIMESTAMP",
|
||||||
|
ok_message=" [OK] 添加 user_schedules.next_run_at 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 2) 为历史 enabled schedule 补算 next_run_at(保证索引驱动可用)
|
||||||
|
try:
|
||||||
|
from services.schedule_utils import compute_next_run_at, format_cst
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, schedule_time, weekdays, random_delay, last_run_at, next_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE enabled = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall() or []
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
schedule_id = _read_row_value(row, "id", 0)
|
||||||
|
schedule_time = _read_row_value(row, "schedule_time", 1)
|
||||||
|
weekdays = _read_row_value(row, "weekdays", 2)
|
||||||
|
random_delay = _read_row_value(row, "random_delay", 3)
|
||||||
|
last_run_at = _read_row_value(row, "last_run_at", 4)
|
||||||
|
next_run_at = _read_row_value(row, "next_run_at", 5)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_run_text = str(next_run_at or "").strip()
|
||||||
|
# 若 next_run_at 为空/非法/已过期,则重算
|
||||||
|
if (not next_run_text) or (next_run_text <= now_str):
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or "08:00"),
|
||||||
|
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(random_delay or 0),
|
||||||
|
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||||
|
)
|
||||||
|
next_run_text = format_cst(next_dt)
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_schedules SET next_run_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(next_run_text, get_cst_now_str(), int(schedule_id)),
|
||||||
|
)
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
if fixed:
|
||||||
|
print(f" [OK] 已为 {fixed} 条启用定时任务补算 next_run_at")
|
||||||
|
except Exception as e:
|
||||||
|
# 迁移过程中不阻断主流程;上线后由 worker 兜底补算
|
||||||
|
print(f" ⚠ v8 迁移补算 next_run_at 失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v9(conn):
|
||||||
|
"""迁移到版本9 - 邮件设置字段迁移(清理 email_service scattered ALTER TABLE)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if not _table_exists(cursor, "email_settings"):
|
||||||
|
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "email_settings")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"email_settings",
|
||||||
|
columns,
|
||||||
|
"register_verify_enabled",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 email_settings.register_verify_enabled 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"email_settings",
|
||||||
|
columns,
|
||||||
|
"base_url",
|
||||||
|
"TEXT DEFAULT ''",
|
||||||
|
ok_message=" [OK] 添加 email_settings.base_url 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"email_settings",
|
||||||
|
columns,
|
||||||
|
"task_notify_enabled",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 email_settings.task_notify_enabled 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v10(conn):
|
||||||
|
"""迁移到版本10 - users 邮箱字段迁移(避免运行时 ALTER TABLE)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
columns = _get_table_columns(cursor, "users")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"users",
|
||||||
|
columns,
|
||||||
|
"email_verified",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 users.email_verified 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"users",
|
||||||
|
columns,
|
||||||
|
"email_notify_enabled",
|
||||||
|
"INTEGER DEFAULT 1",
|
||||||
|
ok_message=" [OK] 添加 users.email_notify_enabled 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v11(conn):
|
||||||
|
"""迁移到版本11 - 取消注册待审核:历史 pending 用户直接置为 approved"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_str = get_cst_now_str()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'approved',
|
||||||
|
approved_at = COALESCE(NULLIF(approved_at, ''), ?)
|
||||||
|
WHERE status = 'pending'
|
||||||
|
""",
|
||||||
|
(now_str,),
|
||||||
|
)
|
||||||
|
updated = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
print(f" [OK] 已将 {updated} 个 pending 用户迁移为 approved")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f" ⚠️ v11 迁移跳过: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v12(conn):
|
||||||
|
"""迁移到版本12 - 登录设备/IP记录表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS login_fingerprints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_ip TEXT DEFAULT '',
|
||||||
|
UNIQUE (user_id, user_agent),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS login_ips (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, ip),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v13(conn):
|
||||||
|
"""迁移到版本13 - 安全防护:威胁检测相关表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS threat_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
threat_type TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rule TEXT,
|
||||||
|
field_name TEXT,
|
||||||
|
matched TEXT,
|
||||||
|
value_preview TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_id INTEGER,
|
||||||
|
request_method TEXT,
|
||||||
|
request_path TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_risk_scores (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
reason TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS threat_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
threat_type TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
pattern_type TEXT DEFAULT 'regex',
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v14(conn):
|
||||||
|
"""迁移到版本14 - 安全防护:用户黑名单表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
reason TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v15(conn):
|
||||||
|
"""迁移到版本15 - 邮件设置:新设备登录提醒全局开关"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if not _table_exists(cursor, "email_settings"):
|
||||||
|
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "email_settings")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
changed = (
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"email_settings",
|
||||||
|
columns,
|
||||||
|
"login_alert_enabled",
|
||||||
|
"INTEGER DEFAULT 1",
|
||||||
|
ok_message=" [OK] 添加 email_settings.login_alert_enabled 字段",
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE email_settings SET login_alert_enabled = 1 WHERE login_alert_enabled IS NULL")
|
||||||
|
if cursor.rowcount:
|
||||||
|
changed = True
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# 列不存在等情况由上方迁移兜底;不阻断主流程
|
||||||
|
pass
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v16(conn):
|
||||||
|
"""迁移到版本16 - 公告支持图片字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
columns = _get_table_columns(cursor, "announcements")
|
||||||
|
|
||||||
|
if _add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"announcements",
|
||||||
|
columns,
|
||||||
|
"image_url",
|
||||||
|
"TEXT",
|
||||||
|
ok_message=" [OK] 添加 announcements.image_url 字段",
|
||||||
|
):
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v17(conn):
|
||||||
|
"""迁移到版本17 - 金山文档上传配置与用户开关"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
system_columns = _get_table_columns(cursor, "system_config")
|
||||||
|
system_fields = [
|
||||||
|
("kdocs_enabled", "INTEGER DEFAULT 0"),
|
||||||
|
("kdocs_doc_url", "TEXT DEFAULT ''"),
|
||||||
|
("kdocs_default_unit", "TEXT DEFAULT ''"),
|
||||||
|
("kdocs_sheet_name", "TEXT DEFAULT ''"),
|
||||||
|
("kdocs_sheet_index", "INTEGER DEFAULT 0"),
|
||||||
|
("kdocs_unit_column", "TEXT DEFAULT 'A'"),
|
||||||
|
("kdocs_image_column", "TEXT DEFAULT 'D'"),
|
||||||
|
("kdocs_admin_notify_enabled", "INTEGER DEFAULT 0"),
|
||||||
|
("kdocs_admin_notify_email", "TEXT DEFAULT ''"),
|
||||||
|
]
|
||||||
|
for field, ddl in system_fields:
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
system_columns,
|
||||||
|
field,
|
||||||
|
ddl,
|
||||||
|
ok_message=f" [OK] 添加 system_config.{field} 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_columns = _get_table_columns(cursor, "users")
|
||||||
|
user_fields = [
|
||||||
|
("kdocs_unit", "TEXT DEFAULT ''"),
|
||||||
|
("kdocs_auto_upload", "INTEGER DEFAULT 0"),
|
||||||
|
]
|
||||||
|
for field, ddl in user_fields:
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"users",
|
||||||
|
user_columns,
|
||||||
|
field,
|
||||||
|
ddl,
|
||||||
|
ok_message=f" [OK] 添加 users.{field} 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v18(conn):
|
||||||
|
"""迁移到版本18 - 金山文档上传:有效行范围配置"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "system_config")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"kdocs_row_start",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 system_config.kdocs_row_start 字段",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"kdocs_row_end",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
ok_message=" [OK] 添加 system_config.kdocs_row_end 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v19(conn):
|
||||||
|
"""迁移到版本19 - 报表与调度查询复合索引优化"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
index_statements = [
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_task_logs_status_created_at ON task_logs(status, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled_next_run ON user_schedules(enabled, next_run_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status_created_at ON bug_feedbacks(status, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_created_at ON bug_feedbacks(user_id, created_at)",
|
||||||
|
]
|
||||||
|
|
||||||
|
for statement in index_statements:
|
||||||
|
cursor.execute(statement)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v20(conn):
|
||||||
|
"""迁移到版本20 - 慢SQL阈值系统配置"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = _get_table_columns(cursor, "system_config")
|
||||||
|
_add_column_if_missing(
|
||||||
|
cursor,
|
||||||
|
"system_config",
|
||||||
|
columns,
|
||||||
|
"db_slow_query_ms",
|
||||||
|
"INTEGER DEFAULT 120",
|
||||||
|
ok_message=" [OK] 添加 system_config.db_slow_query_ms 字段",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v21(conn):
|
||||||
|
"""迁移到版本21 - Passkey 认证设备表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS passkeys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_type TEXT NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
credential_id TEXT UNIQUE NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
sign_count INTEGER DEFAULT 0,
|
||||||
|
transports TEXT DEFAULT '',
|
||||||
|
aaguid TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
173
db/passkeys.py
Normal file
173
db/passkeys.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
_OWNER_TYPES = {"user", "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_owner_type(owner_type: str) -> str:
|
||||||
|
normalized = str(owner_type or "").strip().lower()
|
||||||
|
if normalized not in _OWNER_TYPES:
|
||||||
|
raise ValueError(f"invalid owner_type: {owner_type}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def list_passkeys(owner_type: str, owner_id: int) -> list[dict]:
|
||||||
|
owner = _normalize_owner_type(owner_type)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, owner_type, owner_id, device_name, credential_id, transports,
|
||||||
|
sign_count, aaguid, created_at, last_used_at
|
||||||
|
FROM passkeys
|
||||||
|
WHERE owner_type = ? AND owner_id = ?
|
||||||
|
ORDER BY datetime(created_at) DESC, id DESC
|
||||||
|
""",
|
||||||
|
(owner, int(owner_id)),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def count_passkeys(owner_type: str, owner_id: int) -> int:
|
||||||
|
owner = _normalize_owner_type(owner_type)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) AS count FROM passkeys WHERE owner_type = ? AND owner_id = ?",
|
||||||
|
(owner, int(owner_id)),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row["count"] or 0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return int(row[0] or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_passkey_by_credential_id(credential_id: str) -> dict | None:
|
||||||
|
credential = str(credential_id or "").strip()
|
||||||
|
if not credential:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
|
||||||
|
sign_count, transports, aaguid, created_at, last_used_at
|
||||||
|
FROM passkeys
|
||||||
|
WHERE credential_id = ?
|
||||||
|
""",
|
||||||
|
(credential,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_passkey_by_id(owner_type: str, owner_id: int, passkey_id: int) -> dict | None:
|
||||||
|
owner = _normalize_owner_type(owner_type)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
|
||||||
|
sign_count, transports, aaguid, created_at, last_used_at
|
||||||
|
FROM passkeys
|
||||||
|
WHERE id = ? AND owner_type = ? AND owner_id = ?
|
||||||
|
""",
|
||||||
|
(int(passkey_id), owner, int(owner_id)),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_passkey(
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: int,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
public_key: str,
|
||||||
|
sign_count: int,
|
||||||
|
device_name: str,
|
||||||
|
transports: str = "",
|
||||||
|
aaguid: str = "",
|
||||||
|
) -> int | None:
|
||||||
|
owner = _normalize_owner_type(owner_type)
|
||||||
|
now = get_cst_now_str()
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO passkeys (
|
||||||
|
owner_type,
|
||||||
|
owner_id,
|
||||||
|
device_name,
|
||||||
|
credential_id,
|
||||||
|
public_key,
|
||||||
|
sign_count,
|
||||||
|
transports,
|
||||||
|
aaguid,
|
||||||
|
created_at,
|
||||||
|
last_used_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
owner,
|
||||||
|
int(owner_id),
|
||||||
|
str(device_name or "").strip(),
|
||||||
|
str(credential_id or "").strip(),
|
||||||
|
str(public_key or "").strip(),
|
||||||
|
int(sign_count or 0),
|
||||||
|
str(transports or "").strip(),
|
||||||
|
str(aaguid or "").strip(),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_passkey_usage(passkey_id: int, new_sign_count: int) -> bool:
|
||||||
|
now = get_cst_now_str()
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE passkeys
|
||||||
|
SET sign_count = ?,
|
||||||
|
last_used_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(new_sign_count or 0), now, int(passkey_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_passkey(owner_type: str, owner_id: int, passkey_id: int) -> bool:
|
||||||
|
owner = _normalize_owner_type(owner_type)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM passkeys WHERE id = ? AND owner_type = ? AND owner_id = ?",
|
||||||
|
(int(passkey_id), owner, int(owner_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
554
db/schedules.py
Normal file
554
db/schedules.py
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from services.schedule_utils import compute_next_run_at, format_cst
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
_SCHEDULE_DEFAULT_TIME = "08:00"
|
||||||
|
_SCHEDULE_DEFAULT_WEEKDAYS = "1,2,3,4,5"
|
||||||
|
|
||||||
|
_ALLOWED_SCHEDULE_UPDATE_FIELDS = (
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"schedule_time",
|
||||||
|
"weekdays",
|
||||||
|
"browse_type",
|
||||||
|
"enable_screenshot",
|
||||||
|
"random_delay",
|
||||||
|
"account_ids",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ALLOWED_EXEC_LOG_UPDATE_FIELDS = (
|
||||||
|
"total_accounts",
|
||||||
|
"success_accounts",
|
||||||
|
"failed_accounts",
|
||||||
|
"total_items",
|
||||||
|
"total_attachments",
|
||||||
|
"total_screenshots",
|
||||||
|
"duration_seconds",
|
||||||
|
"status",
|
||||||
|
"error_message",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_limit(limit, default: int, *, minimum: int = 1) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(limit)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
if parsed < minimum:
|
||||||
|
return minimum
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _format_optional_datetime(dt: datetime | None) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return format_cst(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_account_ids(account_ids) -> str:
|
||||||
|
return json.dumps(account_ids) if account_ids else "[]"
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_schedule_next_run_str(
|
||||||
|
*,
|
||||||
|
now_dt,
|
||||||
|
schedule_time,
|
||||||
|
weekdays,
|
||||||
|
random_delay,
|
||||||
|
last_run_at,
|
||||||
|
) -> str:
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or _SCHEDULE_DEFAULT_TIME),
|
||||||
|
weekdays=str(weekdays or _SCHEDULE_DEFAULT_WEEKDAYS),
|
||||||
|
random_delay=_to_int(random_delay, 0),
|
||||||
|
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||||
|
)
|
||||||
|
return format_cst(next_dt)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_schedule_log_row(row) -> dict:
|
||||||
|
log = dict(row)
|
||||||
|
log["created_at"] = log.get("execute_time")
|
||||||
|
log["success_count"] = log.get("success_accounts", 0)
|
||||||
|
log["failed_count"] = log.get("failed_accounts", 0)
|
||||||
|
log["duration"] = log.get("duration_seconds", 0)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_schedules(user_id):
|
||||||
|
"""获取用户的所有定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_schedules
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule_by_id(schedule_id):
|
||||||
|
"""根据ID获取定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM user_schedules WHERE id = ?", (schedule_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_schedule(
|
||||||
|
user_id,
|
||||||
|
name="我的定时任务",
|
||||||
|
schedule_time="08:00",
|
||||||
|
weekdays="1,2,3,4,5",
|
||||||
|
browse_type="应读",
|
||||||
|
enable_screenshot=1,
|
||||||
|
random_delay=0,
|
||||||
|
account_ids=None,
|
||||||
|
):
|
||||||
|
"""创建用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = format_cst(get_beijing_now())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_schedules (
|
||||||
|
user_id, name, enabled, schedule_time, weekdays,
|
||||||
|
browse_type, enable_screenshot, random_delay, account_ids, created_at, updated_at
|
||||||
|
) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
schedule_time,
|
||||||
|
weekdays,
|
||||||
|
browse_type,
|
||||||
|
enable_screenshot,
|
||||||
|
_to_int(random_delay, 0),
|
||||||
|
_serialize_account_ids(account_ids),
|
||||||
|
cst_time,
|
||||||
|
cst_time,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_schedule(schedule_id, **kwargs):
|
||||||
|
"""更新用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT enabled, schedule_time, weekdays, random_delay, last_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
current = cursor.fetchone()
|
||||||
|
if not current:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_enabled = _to_int(current[0], 0)
|
||||||
|
current_time = current[1]
|
||||||
|
current_weekdays = current[2]
|
||||||
|
current_random_delay = _to_int(current[3], 0)
|
||||||
|
current_last_run_at = current[4]
|
||||||
|
|
||||||
|
will_enabled = current_enabled
|
||||||
|
next_time = current_time
|
||||||
|
next_weekdays = current_weekdays
|
||||||
|
next_random_delay = current_random_delay
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
for field in _ALLOWED_SCHEDULE_UPDATE_FIELDS:
|
||||||
|
if field not in kwargs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = kwargs[field]
|
||||||
|
if field == "account_ids" and isinstance(value, list):
|
||||||
|
value = json.dumps(value)
|
||||||
|
|
||||||
|
if field == "enabled":
|
||||||
|
will_enabled = 1 if value else 0
|
||||||
|
if field == "schedule_time":
|
||||||
|
next_time = value
|
||||||
|
if field == "weekdays":
|
||||||
|
next_weekdays = value
|
||||||
|
if field == "random_delay":
|
||||||
|
next_random_delay = int(value or 0)
|
||||||
|
|
||||||
|
updates.append(f"{field} = ?")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
params.append(now_str)
|
||||||
|
|
||||||
|
config_changed = any(key in kwargs for key in ("schedule_time", "weekdays", "random_delay"))
|
||||||
|
enabled_toggled = "enabled" in kwargs
|
||||||
|
should_recompute_next = config_changed or (enabled_toggled and will_enabled == 1)
|
||||||
|
|
||||||
|
if should_recompute_next:
|
||||||
|
next_run_at = _compute_schedule_next_run_str(
|
||||||
|
now_dt=now_dt,
|
||||||
|
schedule_time=next_time,
|
||||||
|
weekdays=next_weekdays,
|
||||||
|
random_delay=next_random_delay,
|
||||||
|
last_run_at=None if config_changed else current_last_run_at,
|
||||||
|
)
|
||||||
|
updates.append("next_run_at = ?")
|
||||||
|
params.append(next_run_at)
|
||||||
|
|
||||||
|
if enabled_toggled and will_enabled == 0:
|
||||||
|
updates.append("next_run_at = ?")
|
||||||
|
params.append(None)
|
||||||
|
|
||||||
|
params.append(schedule_id)
|
||||||
|
sql = f"UPDATE user_schedules SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_schedule(schedule_id):
|
||||||
|
"""删除用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM user_schedules WHERE id = ?", (schedule_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_user_schedule(schedule_id, enabled):
|
||||||
|
"""启用/禁用用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
next_run_at = None
|
||||||
|
if enabled:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay, last_run_at, next_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
schedule_time, weekdays, random_delay, last_run_at, existing_next_run_at = row
|
||||||
|
existing_next_run_at = str(existing_next_run_at or "").strip() or None
|
||||||
|
|
||||||
|
if existing_next_run_at and existing_next_run_at > now_str:
|
||||||
|
next_run_at = existing_next_run_at
|
||||||
|
else:
|
||||||
|
next_run_at = _compute_schedule_next_run_str(
|
||||||
|
now_dt=now_dt,
|
||||||
|
schedule_time=schedule_time,
|
||||||
|
weekdays=weekdays,
|
||||||
|
random_delay=random_delay,
|
||||||
|
last_run_at=last_run_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET enabled = ?, next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0, next_run_at, now_str, schedule_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_enabled_user_schedules():
|
||||||
|
"""获取所有启用的用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT us.*, u.username as user_username
|
||||||
|
FROM user_schedules us
|
||||||
|
JOIN users u ON us.user_id = u.id
|
||||||
|
WHERE us.enabled = 1
|
||||||
|
ORDER BY us.schedule_time
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_last_run(schedule_id):
|
||||||
|
"""更新定时任务最后运行时间,并推进 next_run_at(O-08)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
schedule_time, weekdays, random_delay = row
|
||||||
|
next_run_at = _compute_schedule_next_run_str(
|
||||||
|
now_dt=now_dt,
|
||||||
|
schedule_time=schedule_time,
|
||||||
|
weekdays=weekdays,
|
||||||
|
random_delay=random_delay,
|
||||||
|
last_run_at=now_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET last_run_at = ?, next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now_str, next_run_at, now_str, schedule_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_next_run(schedule_id: int, next_run_at: str) -> bool:
|
||||||
|
"""仅更新 next_run_at(不改变 last_run_at),用于跳过执行时推进。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(next_run_at or "").strip() or None,
|
||||||
|
format_cst(get_beijing_now()),
|
||||||
|
int(schedule_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_schedule_next_run(schedule_id: int, *, now_dt=None) -> bool:
|
||||||
|
"""按当前配置重算 next_run_at(不改变 last_run_at)。"""
|
||||||
|
now_dt = now_dt or get_beijing_now()
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay, last_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(schedule_id),),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
schedule_time, weekdays, random_delay, last_run_at = row
|
||||||
|
next_run_at = _compute_schedule_next_run_str(
|
||||||
|
now_dt=now_dt,
|
||||||
|
schedule_time=schedule_time,
|
||||||
|
weekdays=weekdays,
|
||||||
|
random_delay=random_delay,
|
||||||
|
last_run_at=last_run_at,
|
||||||
|
)
|
||||||
|
return update_schedule_next_run(int(schedule_id), next_run_at)
|
||||||
|
|
||||||
|
|
||||||
|
def get_due_user_schedules(now_cst: str, limit: int = 50):
|
||||||
|
"""获取到期需要执行的用户定时任务(索引驱动)。"""
|
||||||
|
now_cst = str(now_cst or "").strip()
|
||||||
|
if not now_cst:
|
||||||
|
now_cst = format_cst(get_beijing_now())
|
||||||
|
|
||||||
|
safe_limit = _normalize_limit(limit, 50, minimum=1)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT us.*, u.username as user_username
|
||||||
|
FROM user_schedules us
|
||||||
|
JOIN users u ON us.user_id = u.id
|
||||||
|
WHERE us.enabled = 1
|
||||||
|
AND us.next_run_at IS NOT NULL
|
||||||
|
AND us.next_run_at <= ?
|
||||||
|
ORDER BY us.next_run_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(now_cst, safe_limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 定时任务执行日志 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create_schedule_execution_log(schedule_id, user_id, schedule_name):
|
||||||
|
"""创建定时任务执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO schedule_execution_logs (
|
||||||
|
schedule_id, user_id, schedule_name, execute_time, status
|
||||||
|
) VALUES (?, ?, ?, ?, 'running')
|
||||||
|
""",
|
||||||
|
(schedule_id, user_id, schedule_name, format_cst(get_beijing_now())),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_execution_log(log_id, **kwargs):
|
||||||
|
"""更新定时任务执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
for field in _ALLOWED_EXEC_LOG_UPDATE_FIELDS:
|
||||||
|
if field not in kwargs:
|
||||||
|
continue
|
||||||
|
updates.append(f"{field} = ?")
|
||||||
|
params.append(kwargs[field])
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(log_id)
|
||||||
|
sql = f"UPDATE schedule_execution_logs SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule_execution_logs(schedule_id, limit=10):
|
||||||
|
"""获取定时任务执行日志"""
|
||||||
|
try:
|
||||||
|
safe_limit = _normalize_limit(limit, 10, minimum=1)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM schedule_execution_logs
|
||||||
|
WHERE schedule_id = ?
|
||||||
|
ORDER BY execute_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(schedule_id, safe_limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
try:
|
||||||
|
logs.append(_map_schedule_log_row(row))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[数据库] 处理日志行时出错: {e}")
|
||||||
|
continue
|
||||||
|
return logs
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[数据库] 查询定时任务日志时出错: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_all_schedule_logs(user_id, limit=50):
|
||||||
|
"""获取用户所有定时任务的执行日志"""
|
||||||
|
safe_limit = _normalize_limit(limit, 50, minimum=1)
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM schedule_execution_logs
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY execute_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, safe_limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedule_logs(schedule_id, user_id):
|
||||||
|
"""删除指定定时任务的所有执行日志(需验证用户权限)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedule_execution_logs
|
||||||
|
WHERE schedule_id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def clean_old_schedule_logs(days=30):
|
||||||
|
"""清理指定天数前的定时任务执行日志"""
|
||||||
|
safe_days = _to_int(days, 30)
|
||||||
|
if safe_days < 0:
|
||||||
|
safe_days = 0
|
||||||
|
|
||||||
|
cutoff_dt = get_beijing_now() - timedelta(days=safe_days)
|
||||||
|
cutoff_str = format_cst(cutoff_dt)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedule_execution_logs
|
||||||
|
WHERE execute_time < ?
|
||||||
|
""",
|
||||||
|
(cutoff_str,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user