Compare commits
61
Commits
master
..
87b04e84d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87b04e84d3 | ||
|
|
c1256f6425 | ||
|
|
5c80fa0860 | ||
|
|
bda780ed5c | ||
|
|
20efb7ec2a | ||
|
|
7298cffe5e | ||
|
|
f020a9c6d9 | ||
|
|
0c0a5a7770 | ||
|
|
648cf0adf0 | ||
|
|
2f762db337 | ||
|
|
364566df99 | ||
|
|
9bc4cc66fe | ||
|
|
5c9696e65d | ||
|
|
acb22cf96b | ||
|
|
126d4cbb52 | ||
|
|
a25c9fbba0 | ||
|
|
70cd95c366 | ||
|
|
a4b7074634 | ||
|
|
716111e7e6 | ||
|
|
c1a65debbc | ||
|
|
481f4dfbac | ||
|
|
706e80e286 | ||
|
|
184e46cc1e | ||
|
|
900c5a8a9f | ||
|
|
1836b26f64 | ||
|
|
891b0d5729 | ||
|
|
9701d8b14f | ||
|
|
2af52b8f6c | ||
|
|
696fcfa85d | ||
|
|
4a72c76c11 | ||
|
|
e4024e4706 | ||
|
|
74079e3f08 | ||
|
|
a58ace97c5 | ||
|
|
349e329373 | ||
|
|
83eff15a65 | ||
|
|
8cb2a684e3 | ||
|
|
a8e8bbe8a2 | ||
|
|
f46c662fe5 | ||
|
|
7b0ecb03bd | ||
|
|
f366a108a0 | ||
|
|
2e7dc95d55 | ||
|
|
fc7ef70259 | ||
|
|
2d648bac58 | ||
|
|
551e354aa3 | ||
|
|
8b0a9a6fcf | ||
|
|
3e0bd4cf91 | ||
|
|
226d6724bd | ||
|
|
8e5a5cb679 | ||
|
|
dab97c25e1 | ||
|
|
398cd5c9cf | ||
|
|
dc5f9e939b | ||
|
|
653cbbc42c | ||
|
|
fb7a75e76e | ||
|
|
cc4b69e631 | ||
|
|
b289d197a0 | ||
|
|
3e54789ee4 | ||
|
|
c22e2f6a19 | ||
|
|
97d1406e11 | ||
|
|
f9aa511806 | ||
|
|
b5344cd55e | ||
|
|
0fd7137cea |
+1
-26
@@ -13,36 +13,11 @@ FLASK_DEBUG=false
|
|||||||
|
|
||||||
# Session配置
|
# Session配置
|
||||||
SESSION_LIFETIME_HOURS=24
|
SESSION_LIFETIME_HOURS=24
|
||||||
SESSION_COOKIE_SECURE=true # 生产环境HTTPS必须为true,本地HTTP调试可临时设为false
|
SESSION_COOKIE_SECURE=false # 使用HTTPS时设为true
|
||||||
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
|
||||||
|
|||||||
+27
-132
@@ -1,152 +1,47 @@
|
|||||||
# Python
|
# 浏览器二进制文件
|
||||||
|
playwright/
|
||||||
|
ms-playwright/
|
||||||
|
|
||||||
|
# 数据库文件(敏感数据)
|
||||||
|
data/*.db
|
||||||
|
data/*.db-shm
|
||||||
|
data/*.db-wal
|
||||||
|
data/secret_key.txt
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 截图文件
|
||||||
|
截图/
|
||||||
|
|
||||||
|
# Python缓存
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*.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
|
# 环境变量文件(包含敏感信息)
|
||||||
.spyderproject
|
.env
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
# Docker volumes
|
||||||
.ropeproject
|
volumes/
|
||||||
|
|
||||||
# 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
|
||||||
*.temp
|
*.bak
|
||||||
|
*.backup
|
||||||
# Allow committed test cases
|
|
||||||
!tests/
|
|
||||||
!tests/**/*.py
|
|
||||||
|
|||||||
+8
-14
@@ -1,18 +1,14 @@
|
|||||||
# 使用国内镜像源加速
|
# 使用国内镜像源加速
|
||||||
FROM python:3.10-slim-bullseye
|
FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
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
|
||||||
|
|
||||||
@@ -22,15 +18,16 @@ 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 .
|
||||||
@@ -40,11 +37,8 @@ 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 routes/ ./routes/
|
COPY app_state.py .
|
||||||
COPY services/ ./services/
|
COPY app_utils.py .
|
||||||
COPY realtime/ ./realtime/
|
|
||||||
COPY db/ ./db/
|
|
||||||
COPY security/ ./security/
|
|
||||||
|
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
|
|||||||
@@ -1,107 +1,59 @@
|
|||||||
# 知识管理平台自动化工具 - Docker部署版
|
# 知识管理平台自动化工具 - Docker部署版
|
||||||
|
|
||||||
这是一个基于 Docker 的知识管理平台自动化工具,支持多用户、定时任务、代理IP、VIP管理、金山文档集成等功能。
|
这是一个基于 Docker 的知识管理平台自动化工具,支持多用户、定时任务、代理IP、VIP管理等功能。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 近期更新(2026-02)
|
|
||||||
|
|
||||||
- Socket.IO 运行模式已切换为 `eventlet`(生产优先)。
|
|
||||||
- 管理端前端增加请求缓存/去重,降低报表页重复请求压力。
|
|
||||||
- 默认 Docker 端口映射更新为 `51232 -> 51233`。
|
|
||||||
- 已清理仓库中的历史清理报告与明显冗余文件。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
本项目是一个 **Docker 容器化应用**,使用 Flask + Vue 3 + Requests + wkhtmltoimage + SQLite 构建,提供:
|
本项目是一个 **Docker 容器化应用**,使用 Flask + Playwright + SQLite 构建,提供:
|
||||||
|
|
||||||
### 核心功能
|
- 多用户注册登录系统
|
||||||
- 多用户注册登录系统(支持邮箱绑定与验证)
|
- 浏览器自动化任务
|
||||||
- 自动化浏览任务(纯 HTTP API 模拟,速度快)
|
- 定时任务调度
|
||||||
- 智能截图系统(wkhtmltoimage,支持线程池)
|
- 截图管理
|
||||||
- 用户自定义定时任务(支持随机延迟)
|
- VIP用户管理
|
||||||
- VIP 用户管理(账号数量限制、优先队列)
|
- 代理IP支持
|
||||||
|
- 后台管理系统
|
||||||
### 集成功能
|
|
||||||
- **金山文档集成** - 自动上传截图到在线表格,支持姓名搜索匹配
|
|
||||||
- **邮件通知** - 任务完成通知、密码重置、邮箱验证
|
|
||||||
- **代理IP支持** - 动态代理API集成
|
|
||||||
|
|
||||||
### 安全功能
|
|
||||||
- 威胁检测引擎(JNDI/SQL注入/XSS/命令注入检测)
|
|
||||||
- IP/用户风险评分系统
|
|
||||||
- 自动黑名单机制
|
|
||||||
- 登录设备指纹追踪
|
|
||||||
|
|
||||||
### 管理功能
|
|
||||||
- 现代化 Vue 3 SPA 后台管理界面
|
|
||||||
- 公告系统(支持图片)
|
|
||||||
- Bug 反馈系统
|
|
||||||
- 任务日志与统计
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**: Python 3.10+, Flask, Flask-SocketIO
|
- **后端**: Python 3.8+, Flask
|
||||||
- **前端**: Vue 3 + Vite + Element Plus (SPA)
|
- **数据库**: SQLite
|
||||||
- **数据库**: SQLite + 连接池
|
- **自动化**: Playwright (Chromium)
|
||||||
- **自动化**: Requests + BeautifulSoup (浏览)
|
|
||||||
- **截图**: wkhtmltoimage
|
|
||||||
- **金山文档**: Playwright (表格操作/上传)
|
|
||||||
- **容器化**: Docker + Docker Compose
|
- **容器化**: Docker + Docker Compose
|
||||||
- **实时通信**: Socket.IO (WebSocket)
|
- **前端**: HTML + JavaScript + Socket.IO
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
zsglpt/
|
zsgpt2/
|
||||||
├── app.py # 启动/装配入口
|
├── app.py # 主应用程序
|
||||||
├── routes/ # 路由层(Blueprint)
|
├── database.py # 数据库模块
|
||||||
│ ├── api_*.py # API 路由
|
├── playwright_automation.py # 浏览器自动化
|
||||||
│ ├── admin_api/ # 管理后台 API
|
├── browser_installer.py # 浏览器安装检查
|
||||||
│ └── 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 # 安全模块
|
||||||
├── password_utils.py # 密码哈希工具
|
├── app_state.py # 状态管理
|
||||||
├── crypto_utils.py # 加解密工具
|
├── app_utils.py # 工具函数
|
||||||
├── email_service.py # 邮件服务(SMTP)
|
├── db_pool.py # 数据库连接池
|
||||||
|
├── 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模板(SPA 入口)
|
├── templates/ # HTML模板
|
||||||
│ ├── app.html # 用户端 SPA 入口
|
│ ├── index.html # 主页面
|
||||||
│ ├── admin.html # 管理端 SPA 入口
|
│ ├── login.html # 登录页
|
||||||
│ └── email/ # 邮件模板
|
│ ├── register.html # 注册页
|
||||||
├── app-frontend/ # 用户端 Vue 源码
|
│ ├── admin.html # 后台管理
|
||||||
├── admin-frontend/ # 管理端 Vue 源码
|
│ └── ...
|
||||||
├── static/ # 前端构建产物
|
└── static/ # 静态资源
|
||||||
│ ├── app/ # 用户端 SPA 资源
|
└── js/ # JavaScript文件
|
||||||
│ └── admin/ # 管理端 SPA 资源
|
|
||||||
└── scripts/ # 维护脚本(例如健康监控)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -134,56 +86,20 @@ 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: 上传项目文件
|
||||||
|
|
||||||
将整个 `zsglpt` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
|
将整个 `zsgpt2` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在本地执行(Windows PowerShell 或 Git Bash)
|
# 在本地执行(Windows PowerShell 或 Git Bash)
|
||||||
scp -r C:\Users\Administrator\Desktop\zsglpt root@your-server-ip:/www/wwwroot/
|
scp -r C:\Users\Administrator\Desktop\zsgpt2 root@your-server-ip:/www/wwwroot/
|
||||||
|
|
||||||
# 或者使用 FileZilla、WinSCP 等工具上传
|
# 或者使用 FileZilla、WinSCP 等工具上传
|
||||||
```
|
```
|
||||||
|
|
||||||
上传后,服务器上的路径应该是:`/www/wwwroot/zsglpt/`
|
上传后,服务器上的路径应该是:`/www/wwwroot/zsgpt2/`
|
||||||
|
|
||||||
### 步骤2: SSH登录服务器
|
### 步骤2: SSH登录服务器
|
||||||
|
|
||||||
@@ -194,19 +110,16 @@ ssh root@your-server-ip
|
|||||||
### 步骤3: 进入项目目录
|
### 步骤3: 进入项目目录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/zsglpt
|
cd /www/wwwroot/zsgpt2
|
||||||
```
|
```
|
||||||
|
|
||||||
### 步骤4: 创建必要的目录
|
### 步骤4: 创建必要的目录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data logs 截图
|
mkdir -p data logs 截图 playwright
|
||||||
chown -R 1000:1000 data logs 截图
|
chmod 777 data logs 截图 playwright
|
||||||
chmod 750 data logs 截图
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 说明:避免使用 `chmod 777`。如容器内运行用户不是 `1000:1000`,请改为实际 UID/GID。
|
|
||||||
|
|
||||||
### 步骤5: 构建并启动Docker容器
|
### 步骤5: 构建并启动Docker容器
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -214,7 +127,7 @@ chmod 750 data logs 截图
|
|||||||
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
|
||||||
@@ -229,8 +142,8 @@ docker logs -f knowledge-automation-multiuser
|
|||||||
如果看到以下信息,说明启动成功:
|
如果看到以下信息,说明启动成功:
|
||||||
```
|
```
|
||||||
服务器启动中...
|
服务器启动中...
|
||||||
用户访问地址: http://0.0.0.0:51233
|
用户访问地址: http://0.0.0.0:5000
|
||||||
后台管理地址: http://0.0.0.0:51233/yuyx
|
后台管理地址: http://0.0.0.0:5000/yuyx
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,7 +177,7 @@ server {
|
|||||||
|
|
||||||
# 反向代理
|
# 反向代理
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:51232;
|
proxy_pass http://127.0.0.1:5001;
|
||||||
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;
|
||||||
@@ -306,15 +219,15 @@ certbot renew --dry-run
|
|||||||
|
|
||||||
### 用户端
|
### 用户端
|
||||||
|
|
||||||
- **HTTP**: `http://your-server-ip:51232`
|
- **HTTP**: `http://your-server-ip:5001`
|
||||||
- **域名**: `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`
|
||||||
|
|
||||||
**首次登录后请立即修改密码!**
|
**首次登录后请立即修改密码!**
|
||||||
|
|
||||||
@@ -331,7 +244,7 @@ certbot renew --dry-run
|
|||||||
### 2. 定时任务
|
### 2. 定时任务
|
||||||
- **启用定时浏览**: 是/否
|
- **启用定时浏览**: 是/否
|
||||||
- **执行时间**: 02:00 (CST时间)
|
- **执行时间**: 02:00 (CST时间)
|
||||||
- **浏览类型**: 应读/注册前未读
|
- **浏览类型**: 应读/注册前未读/未读
|
||||||
- **执行日期**: 周一到周日
|
- **执行日期**: 周一到周日
|
||||||
|
|
||||||
### 3. 代理配置
|
### 3. 代理配置
|
||||||
@@ -372,7 +285,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/zsglpt/logs/app.log
|
tail -f /www/wwwroot/zsgpt2/logs/app.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### 进入容器
|
### 进入容器
|
||||||
@@ -390,14 +303,14 @@ docker exec knowledge-automation-multiuser python -c "print('Hello')"
|
|||||||
如果修改了代码,需要重新构建:
|
如果修改了代码,需要重新构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/zsglpt
|
cd /www/wwwroot/zsgpt2
|
||||||
|
|
||||||
# 停止并删除旧容器
|
# 停止并删除旧容器
|
||||||
docker compose down
|
docker-compose down
|
||||||
|
|
||||||
# 重新构建并启动
|
# 重新构建并启动
|
||||||
docker compose build
|
docker-compose build
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -410,13 +323,13 @@ docker compose up -d
|
|||||||
cd /www/wwwroot
|
cd /www/wwwroot
|
||||||
|
|
||||||
# 备份整个项目
|
# 备份整个项目
|
||||||
tar -czf zsglpt_backup_$(date +%Y%m%d).tar.gz zsglpt/
|
tar -czf zsgpt2_backup_$(date +%Y%m%d).tar.gz zsgpt2/
|
||||||
|
|
||||||
# 仅备份数据库
|
# 仅备份数据库
|
||||||
cp /www/wwwroot/zsglpt/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
|
cp /www/wwwroot/zsgpt2/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
|
||||||
|
|
||||||
# 备份截图
|
# 备份截图
|
||||||
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsglpt/截图/
|
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsgpt2/截图/
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 恢复数据
|
### 2. 恢复数据
|
||||||
@@ -427,10 +340,10 @@ docker stop knowledge-automation-multiuser
|
|||||||
|
|
||||||
# 恢复整个项目
|
# 恢复整个项目
|
||||||
cd /www/wwwroot
|
cd /www/wwwroot
|
||||||
tar -xzf zsglpt_backup_20251027.tar.gz
|
tar -xzf zsgpt2_backup_20251027.tar.gz
|
||||||
|
|
||||||
# 恢复数据库
|
# 恢复数据库
|
||||||
cp /backup/app_data_20251027.db /www/wwwroot/zsglpt/data/app_data.db
|
cp /backup/app_data_20251027.db /www/wwwroot/zsgpt2/data/app_data.db
|
||||||
|
|
||||||
# 重启容器
|
# 重启容器
|
||||||
docker start knowledge-automation-multiuser
|
docker start knowledge-automation-multiuser
|
||||||
@@ -448,7 +361,7 @@ crontab -e
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 每天凌晨3点备份
|
# 每天凌晨3点备份
|
||||||
0 3 * * * tar -czf /backup/zsglpt_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsglpt/data
|
0 3 * * * tar -czf /backup/zsgpt2_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsgpt2/data
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -457,19 +370,19 @@ crontab -e
|
|||||||
|
|
||||||
### 1. 容器启动失败
|
### 1. 容器启动失败
|
||||||
|
|
||||||
**问题**: `docker compose up -d` 失败
|
**问题**: `docker-compose up -d` 失败
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**:
|
||||||
```bash
|
```bash
|
||||||
# 查看详细错误
|
# 查看详细错误
|
||||||
docker compose logs
|
docker-compose logs
|
||||||
|
|
||||||
# 检查端口占用
|
# 检查端口占用
|
||||||
netstat -tlnp | grep 51232
|
netstat -tlnp | grep 5001
|
||||||
|
|
||||||
# 重新构建
|
# 重新构建
|
||||||
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
|
||||||
@@ -482,10 +395,10 @@ docker compose up -d
|
|||||||
docker ps | grep knowledge-automation
|
docker ps | grep knowledge-automation
|
||||||
|
|
||||||
# 检查端口是否监听
|
# 检查端口是否监听
|
||||||
netstat -tlnp | grep 51232
|
netstat -tlnp | grep 5001
|
||||||
|
|
||||||
# 测试直接访问
|
# 测试直接访问
|
||||||
curl http://127.0.0.1:51232
|
curl http://127.0.0.1:5001
|
||||||
|
|
||||||
# 检查Nginx配置
|
# 检查Nginx配置
|
||||||
nginx -t
|
nginx -t
|
||||||
@@ -501,7 +414,7 @@ nginx -t
|
|||||||
docker restart knowledge-automation-multiuser
|
docker restart knowledge-automation-multiuser
|
||||||
|
|
||||||
# 如果问题持续,优化数据库
|
# 如果问题持续,优化数据库
|
||||||
cd /www/wwwroot/zsglpt
|
cd /www/wwwroot/zsgpt2
|
||||||
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;"
|
||||||
```
|
```
|
||||||
@@ -524,23 +437,23 @@ services:
|
|||||||
然后重启:
|
然后重启:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker-compose down
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 截图工具未安装
|
### 5. 浏览器下载失败
|
||||||
|
|
||||||
**问题**: wkhtmltoimage 命令不存在
|
**问题**: Playwright浏览器下载失败
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**:
|
||||||
```bash
|
```bash
|
||||||
# 进入容器手动安装
|
# 进入容器手动安装
|
||||||
docker exec -it knowledge-automation-multiuser bash
|
docker exec -it knowledge-automation-multiuser bash
|
||||||
apt-get update
|
playwright install chromium
|
||||||
apt-get install -y wkhtmltopdf
|
|
||||||
|
|
||||||
# 验证安装
|
# 或使用国内镜像
|
||||||
wkhtmltoimage --version
|
export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/
|
||||||
|
playwright install chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -567,13 +480,13 @@ wkhtmltoimage --version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 清理7天前的截图
|
# 清理7天前的截图
|
||||||
find /www/wwwroot/zsglpt/截图 -name "*.jpg" -mtime +7 -delete
|
find /www/wwwroot/zsgpt2/截图 -name "*.jpg" -mtime +7 -delete
|
||||||
|
|
||||||
# 清理旧日志
|
# 清理旧日志
|
||||||
find /www/wwwroot/zsglpt/logs -name "*.log" -mtime +30 -delete
|
find /www/wwwroot/zsgpt2/logs -name "*.log" -mtime +30 -delete
|
||||||
|
|
||||||
# 优化数据库
|
# 优化数据库
|
||||||
sqlite3 /www/wwwroot/zsglpt/data/app_data.db "VACUUM;"
|
sqlite3 /www/wwwroot/zsgpt2/data/app_data.db "VACUUM;"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -594,9 +507,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
|
||||||
|
|
||||||
# 禁止直接访问51232端口(仅Nginx可访问)
|
# 禁止直接访问5001端口(仅Nginx可访问)
|
||||||
iptables -A INPUT -p tcp --dport 51232 -s 127.0.0.1 -j ACCEPT
|
iptables -A INPUT -p tcp --dport 5001 -s 127.0.0.1 -j ACCEPT
|
||||||
iptables -A INPUT -p tcp --dport 51232 -j DROP
|
iptables -A INPUT -p tcp --dport 5001 -j DROP
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 启用HTTPS
|
### 3. 启用HTTPS
|
||||||
@@ -637,13 +550,13 @@ systemctl restart sshd
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 统计今日任务数
|
# 统计今日任务数
|
||||||
grep "浏览完成" /www/wwwroot/zsglpt/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
|
grep "浏览完成" /www/wwwroot/zsgpt2/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
|
||||||
|
|
||||||
# 查看错误日志
|
# 查看错误日志
|
||||||
grep "ERROR" /www/wwwroot/zsglpt/logs/app.log | tail -20
|
grep "ERROR" /www/wwwroot/zsgpt2/logs/app.log | tail -20
|
||||||
|
|
||||||
# 查看最近的登录
|
# 查看最近的登录
|
||||||
grep "登录成功" /www/wwwroot/zsglpt/logs/app.log | tail -10
|
grep "登录成功" /www/wwwroot/zsgpt2/logs/app.log | tail -10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 数据库维护
|
### 3. 数据库维护
|
||||||
@@ -667,7 +580,7 @@ EOF
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 停止容器
|
# 停止容器
|
||||||
docker compose down
|
docker-compose down
|
||||||
|
|
||||||
# 备份数据
|
# 备份数据
|
||||||
cp -r data data.backup
|
cp -r data data.backup
|
||||||
@@ -677,8 +590,8 @@ cp -r 截图 截图.backup
|
|||||||
# 使用 scp 或 FTP 工具上传
|
# 使用 scp 或 FTP 工具上传
|
||||||
|
|
||||||
# 重新构建并启动
|
# 重新构建并启动
|
||||||
docker compose build
|
docker-compose build
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 数据库迁移
|
### 2. 数据库迁移
|
||||||
@@ -697,8 +610,8 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
| 端口 | 说明 | 映射 |
|
| 端口 | 说明 | 映射 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 51233 | 容器内应用端口 | - |
|
| 5000 | 容器内应用端口 | - |
|
||||||
| 51232 | 主机映射端口 | 容器51233 → 主机51232 |
|
| 5001 | 主机映射端口 | 容器5000 → 主机5001 |
|
||||||
| 80 | HTTP端口 | Nginx |
|
| 80 | HTTP端口 | Nginx |
|
||||||
| 443 | HTTPS端口 | Nginx |
|
| 443 | HTTPS端口 | Nginx |
|
||||||
|
|
||||||
@@ -710,23 +623,9 @@ 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 |
|
||||||
| WKHTMLTOIMAGE_PATH | wkhtmltoimage 可执行文件路径 | 自动探测 |
|
| PLAYWRIGHT_BROWSERS_PATH | 浏览器路径 | /ms-playwright |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -736,20 +635,20 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
- **项目名称**: 知识管理平台自动化工具
|
- **项目名称**: 知识管理平台自动化工具
|
||||||
- **版本**: Docker 多用户版
|
- **版本**: Docker 多用户版
|
||||||
- **技术栈**: Python + Flask + Requests + wkhtmltopdf + SQLite + Docker
|
- **技术栈**: Python + Flask + Playwright + SQLite + Docker
|
||||||
|
|
||||||
### 常用文档链接
|
### 常用文档链接
|
||||||
|
|
||||||
- [Docker 官方文档](https://docs.docker.com/)
|
- [Docker 官方文档](https://docs.docker.com/)
|
||||||
- [Flask 官方文档](https://flask.palletsprojects.com/)
|
- [Flask 官方文档](https://flask.palletsprojects.com/)
|
||||||
- [wkhtmltopdf 官方文档](https://wkhtmltopdf.org/)
|
- [Playwright 官方文档](https://playwright.dev/python/)
|
||||||
|
|
||||||
### 故障排查
|
### 故障排查
|
||||||
|
|
||||||
遇到问题时,请按以下顺序检查:
|
遇到问题时,请按以下顺序检查:
|
||||||
|
|
||||||
1. **容器日志**: `docker logs knowledge-automation-multiuser`
|
1. **容器日志**: `docker logs knowledge-automation-multiuser`
|
||||||
2. **应用日志**: `cat /www/wwwroot/zsglpt/logs/app.log`
|
2. **应用日志**: `cat /www/wwwroot/zsgpt2/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`
|
||||||
|
|
||||||
@@ -761,9 +660,9 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本**: v2.1
|
**文档版本**: v1.0
|
||||||
**更新日期**: 2026-02-07
|
**更新日期**: 2025-10-29
|
||||||
**适用版本**: Docker多用户版 + Vue SPA
|
**适用版本**: Docker多用户版
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -771,73 +670,26 @@ docker logs knowledge-automation-multiuser | grep "数据库"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 上传文件
|
# 1. 上传文件
|
||||||
scp -r zsglpt root@your-ip:/www/wwwroot/
|
scp -r zsgpt2 root@your-ip:/www/wwwroot/
|
||||||
|
|
||||||
# 2. SSH登录
|
# 2. SSH登录
|
||||||
ssh root@your-ip
|
ssh root@your-ip
|
||||||
|
|
||||||
# 3. 进入目录并创建必要目录
|
# 3. 进入目录并创建必要目录
|
||||||
cd /www/wwwroot/zsglpt
|
cd /www/wwwroot/zsgpt2
|
||||||
mkdir -p data logs 截图
|
mkdir -p data logs 截图 playwright
|
||||||
chmod 777 data logs 截图
|
chmod 777 data logs 截图 playwright
|
||||||
|
|
||||||
# 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:51232
|
# 浏览器打开: http://your-ip:5001
|
||||||
# 后台管理: http://your-ip:51232/yuyx
|
# 后台管理: http://your-ip:5001/yuyx
|
||||||
# 首次管理员密码会写入 data/default_admin_credentials.txt(权限600)
|
# 默认账号: admin / admin
|
||||||
# 登录后请立即修改密码并删除该文件
|
|
||||||
```
|
```
|
||||||
|
|
||||||
完成!🎉
|
完成!🎉
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 更新日志
|
|
||||||
|
|
||||||
### v2.0 (2026-01-08)
|
|
||||||
|
|
||||||
#### 新功能
|
|
||||||
- **金山文档集成**: 自动上传截图到金山文档表格
|
|
||||||
- 支持姓名搜索匹配单元格
|
|
||||||
- 支持配置有效行范围
|
|
||||||
- 支持覆盖已有图片
|
|
||||||
- 离线状态监控与邮件通知
|
|
||||||
- **Vue 3 SPA 前端**: 用户端和管理端全面升级为现代化单页应用
|
|
||||||
- Element Plus UI 组件库
|
|
||||||
- 实时任务状态更新
|
|
||||||
- 响应式设计
|
|
||||||
- **用户自定义定时任务**: 用户可创建自己的定时任务
|
|
||||||
- 支持多时间段配置
|
|
||||||
- 支持随机延迟
|
|
||||||
- 支持选择指定账号
|
|
||||||
- **安全防护系统**:
|
|
||||||
- 威胁检测引擎(JNDI/SQL注入/XSS/命令注入)
|
|
||||||
- IP/用户风险评分
|
|
||||||
- 自动黑名单机制
|
|
||||||
- **邮件通知系统**:
|
|
||||||
- 任务完成通知
|
|
||||||
- 密码重置邮件
|
|
||||||
- 邮箱验证
|
|
||||||
- **公告系统**: 支持图片的系统公告
|
|
||||||
- **Bug反馈系统**: 用户可提交问题反馈
|
|
||||||
|
|
||||||
#### 优化
|
|
||||||
- **截图线程池**: wkhtmltoimage 截图支持多线程并发
|
|
||||||
- 线程池管理,按需启动
|
|
||||||
- 空闲自动释放资源
|
|
||||||
- **二次登录机制**: 刷新"上次登录时间"显示
|
|
||||||
- **API 预热**: 启动时预热连接,减少首次请求延迟
|
|
||||||
- **数据库连接池**: 提高并发性能
|
|
||||||
|
|
||||||
### v1.0 (2025-10-29)
|
|
||||||
- 初始版本
|
|
||||||
- 多用户系统
|
|
||||||
- 基础自动化任务
|
|
||||||
- 定时任务调度
|
|
||||||
- 代理IP支持
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Generated
-1845
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"qrcode.vue": "^3.6.0",
|
|
||||||
"vue": "^3.5.24",
|
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"vite": "^7.2.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 fetchAdminSocialBindings() {
|
|
||||||
const { data } = await api.get('/admin/social-bindings')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAdminSocialLoginUrl(payload = {}) {
|
|
||||||
const { data } = await api.post('/admin/social-login-url', payload)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pollAdminSocialLogin(payload = {}) {
|
|
||||||
const { data } = await api.post('/admin/social-poll', payload)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bindAdminSocialCallback(provider, payload = {}) {
|
|
||||||
const { data } = await api.post(`/admin/social-bindings/${encodeURIComponent(provider)}/callback`, payload)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unbindAdminSocial(provider) {
|
|
||||||
const { data } = await api.delete(`/admin/social-bindings/${encodeURIComponent(provider)}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSocialLoginConfig() {
|
|
||||||
const { data } = await api.get('/social-login/config')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSocialLoginConfig(payload) {
|
|
||||||
const { data } = await api.post('/social-login/config', payload || {})
|
|
||||||
systemConfigGetter.clear()
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testSocialLoginConfig(payload) {
|
|
||||||
const { data } = await api.post('/social-login/test', payload || {})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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')
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import { bindAdminSocialCallback } from '../api/admin'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const statusText = ref('正在完成绑定')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const hashQuery = String(window.location.hash || '').split('?')[1] || ''
|
|
||||||
const params = new URLSearchParams(window.location.search || hashQuery)
|
|
||||||
const provider = String(params.get('provider') || params.get('type') || '').trim().toLowerCase()
|
|
||||||
const code = String(params.get('code') || '').trim()
|
|
||||||
const routeProvider = String(route.query?.provider || route.query?.type || '').trim().toLowerCase()
|
|
||||||
const routeCode = String(route.query?.code || '').trim()
|
|
||||||
const finalProvider = provider || routeProvider
|
|
||||||
const finalCode = code || routeCode
|
|
||||||
|
|
||||||
if (!finalProvider || !finalCode) {
|
|
||||||
ElMessage.error('快捷登录回调参数不完整')
|
|
||||||
window.location.replace('/yuyx/admin#/settings')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await bindAdminSocialCallback(finalProvider, { provider: finalProvider, code: finalCode })
|
|
||||||
ElMessage.success('管理员快捷登录已绑定')
|
|
||||||
window.location.replace('/yuyx/admin#/settings')
|
|
||||||
} catch (error) {
|
|
||||||
const payload = error?.response?.data
|
|
||||||
statusText.value = payload?.error || '快捷登录绑定失败'
|
|
||||||
ElMessage.error(statusText.value)
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.replace('/yuyx/admin#/settings')
|
|
||||||
}, 1200)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="callback-wrap">
|
|
||||||
<el-card shadow="never" class="callback-card">
|
|
||||||
<el-skeleton :rows="3" animated />
|
|
||||||
<div class="callback-text">{{ statusText }}</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.callback-wrap {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f6f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callback-card {
|
|
||||||
width: min(420px, 94vw);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--app-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.callback-text {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: var(--app-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,924 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
<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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,836 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import QrcodeVue from 'qrcode.vue'
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAdminSocialLoginUrl,
|
|
||||||
fetchAdminSocialBindings,
|
|
||||||
logout,
|
|
||||||
pollAdminSocialLogin,
|
|
||||||
unbindAdminSocial,
|
|
||||||
updateAdminPassword,
|
|
||||||
updateAdminUsername,
|
|
||||||
} from '../api/admin'
|
|
||||||
|
|
||||||
const username = ref('')
|
|
||||||
const currentPassword = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const submitting = ref(false)
|
|
||||||
const socialBindingsLoading = ref(false)
|
|
||||||
const socialBindLoadingProvider = ref('')
|
|
||||||
const socialBindings = ref([])
|
|
||||||
const socialEnabled = ref(false)
|
|
||||||
const qrOpen = ref(false)
|
|
||||||
const qrValue = ref('')
|
|
||||||
const qrProvider = ref('wx')
|
|
||||||
let socialPollTimer = null
|
|
||||||
let socialPollStartedAt = 0
|
|
||||||
|
|
||||||
const providerLabels = {
|
|
||||||
qq: 'QQ',
|
|
||||||
wx: '微信',
|
|
||||||
alipay: '支付宝',
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleSocialBindings = computed(() => socialBindings.value.filter((item) => providerLabels[item.provider]))
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function socialBindRedirectUri() {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.pathname = '/yuyx/admin-social-bind-callback'
|
|
||||||
url.search = ''
|
|
||||||
url.hash = ''
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function socialProviderIcon(provider) {
|
|
||||||
if (provider === 'wx') return '微'
|
|
||||||
if (provider === 'qq') return 'Q'
|
|
||||||
return '支'
|
|
||||||
}
|
|
||||||
|
|
||||||
function socialQrPrompt(provider) {
|
|
||||||
if (provider === 'wx') return '请使用微信扫描二维码完成绑定'
|
|
||||||
if (provider === 'qq') return '请使用 QQ 扫描二维码完成绑定'
|
|
||||||
return '请使用支付宝扫描二维码完成绑定'
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSocialPolling() {
|
|
||||||
if (socialPollTimer) {
|
|
||||||
window.clearTimeout(socialPollTimer)
|
|
||||||
socialPollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSocialQr() {
|
|
||||||
stopSocialPolling()
|
|
||||||
qrOpen.value = false
|
|
||||||
qrValue.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleSocialPoll(provider, state, intervalSeconds) {
|
|
||||||
stopSocialPolling()
|
|
||||||
socialPollStartedAt = Date.now()
|
|
||||||
const tick = async () => {
|
|
||||||
if (Date.now() - socialPollStartedAt > 5 * 60 * 1000) {
|
|
||||||
closeSocialQr()
|
|
||||||
ElMessage.warning('二维码已过期,请重新获取')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await pollAdminSocialLogin({ provider, state })
|
|
||||||
if (result?.status === 'authorized' && result?.url) {
|
|
||||||
closeSocialQr()
|
|
||||||
window.location.assign(result.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
|
||||||
} catch (error) {
|
|
||||||
closeSocialQr()
|
|
||||||
const data = error?.response?.data
|
|
||||||
ElMessage.error(data?.error || '扫码状态获取失败,请重新尝试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSocialBindings() {
|
|
||||||
socialBindingsLoading.value = true
|
|
||||||
try {
|
|
||||||
const data = await fetchAdminSocialBindings()
|
|
||||||
socialEnabled.value = Boolean(data?.enabled)
|
|
||||||
socialBindings.value = Array.isArray(data?.items) ? data.items : []
|
|
||||||
} catch {
|
|
||||||
socialEnabled.value = false
|
|
||||||
socialBindings.value = []
|
|
||||||
} finally {
|
|
||||||
socialBindingsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bindSocial(item) {
|
|
||||||
const provider = String(item?.provider || '').trim()
|
|
||||||
if (!provider || socialBindLoadingProvider.value) return
|
|
||||||
socialBindLoadingProvider.value = provider
|
|
||||||
try {
|
|
||||||
const data = await createAdminSocialLoginUrl({
|
|
||||||
provider,
|
|
||||||
redirect_uri: socialBindRedirectUri(),
|
|
||||||
})
|
|
||||||
if (provider !== 'wx') {
|
|
||||||
window.location.assign(data.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const value = data.scan_url || data.qrcode || data.url
|
|
||||||
if (!value || !data.scan_state) {
|
|
||||||
ElMessage.error('微信二维码获取失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qrProvider.value = provider
|
|
||||||
qrValue.value = value
|
|
||||||
qrOpen.value = true
|
|
||||||
scheduleSocialPoll(provider, data.scan_state, data.scan_poll_interval || 2)
|
|
||||||
} catch (error) {
|
|
||||||
const data = error?.response?.data
|
|
||||||
ElMessage.error(data?.error || '获取聚合登录地址失败')
|
|
||||||
} finally {
|
|
||||||
socialBindLoadingProvider.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unbindSocial(item) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定解绑${item?.provider_label || '快捷登录'}吗?`, '解绑快捷登录', {
|
|
||||||
confirmButtonText: '解绑',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await unbindAdminSocial(item.provider)
|
|
||||||
ElMessage.success('已解绑')
|
|
||||||
await loadSocialBindings()
|
|
||||||
} catch (error) {
|
|
||||||
const data = error?.response?.data
|
|
||||||
ElMessage.error(data?.error || '解绑失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadSocialBindings()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopSocialPolling()
|
|
||||||
})
|
|
||||||
</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">
|
|
||||||
<div class="section-head">
|
|
||||||
<h3 class="section-title">快捷登录绑定</h3>
|
|
||||||
<el-tag v-if="socialEnabled" size="small" type="success">已启用</el-tag>
|
|
||||||
<el-tag v-else size="small" type="info">未启用</el-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="!socialEnabled"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
title="聚合登录未启用,请先在系统配置中填写并启用 Space 聚合登录。"
|
|
||||||
show-icon
|
|
||||||
class="help-alert"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-loading="socialBindingsLoading">
|
|
||||||
<el-empty v-if="visibleSocialBindings.length === 0" description="暂无可绑定的快捷登录方式" />
|
|
||||||
<div v-else class="social-list">
|
|
||||||
<div v-for="item in visibleSocialBindings" :key="item.provider" class="social-row">
|
|
||||||
<div class="social-provider">
|
|
||||||
<span class="social-icon" :class="`provider-${item.provider}`">{{ socialProviderIcon(item.provider) }}</span>
|
|
||||||
<div class="social-info">
|
|
||||||
<strong>{{ item.provider_label }}</strong>
|
|
||||||
<span v-if="item.bound">{{ item.nickname || '已绑定' }}</span>
|
|
||||||
<span v-else>未绑定</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="social-actions">
|
|
||||||
<el-button
|
|
||||||
v-if="item.bound"
|
|
||||||
type="danger"
|
|
||||||
text
|
|
||||||
@click="unbindSocial(item)"
|
|
||||||
>
|
|
||||||
解绑
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-else
|
|
||||||
type="primary"
|
|
||||||
plain
|
|
||||||
:disabled="!socialEnabled"
|
|
||||||
:loading="socialBindLoadingProvider === item.provider"
|
|
||||||
@click="bindSocial(item)"
|
|
||||||
>
|
|
||||||
绑定
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-dialog v-model="qrOpen" :title="`${providerLabels[qrProvider]}绑定`" width="min(340px, 92vw)" @close="closeSocialQr">
|
|
||||||
<div class="social-qr-box">
|
|
||||||
<QrcodeVue v-if="qrValue" :value="qrValue" :size="220" level="M" />
|
|
||||||
<div class="social-qr-prompt">{{ socialQrPrompt(qrProvider) }}</div>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head .section-title {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--app-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-alert {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-row {
|
|
||||||
min-height: 58px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--app-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(248, 250, 252, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-provider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-wx {
|
|
||||||
background: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-qq {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-alipay {
|
|
||||||
background: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-info {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-info strong {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-info span {
|
|
||||||
max-width: min(52vw, 360px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--app-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-actions {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-qr-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-qr-prompt {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #374151;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.social-row {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-actions {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,900 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
|
|
||||||
import { fetchSocialLoginConfig, fetchSystemConfig, testSocialLoginConfig, updateSocialLoginConfig, 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 socialLoginEnabled = ref(false)
|
|
||||||
const socialLoginEndpoint = ref('https://www.spacezs.cn/connect.php')
|
|
||||||
const socialLoginAppid = ref('')
|
|
||||||
const socialLoginAppkey = ref('')
|
|
||||||
const socialLoginAppkeyMasked = ref('')
|
|
||||||
const socialLoginAppkeyConfigured = ref(false)
|
|
||||||
const socialLoginProviders = ref(['wx'])
|
|
||||||
const socialLoginSaving = ref(false)
|
|
||||||
const socialLoginTesting = ref(false)
|
|
||||||
const socialProviderOptions = [
|
|
||||||
{ label: 'QQ', value: 'qq' },
|
|
||||||
{ label: '微信', value: 'wx' },
|
|
||||||
{ label: '支付宝', value: 'alipay' },
|
|
||||||
]
|
|
||||||
|
|
||||||
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, social] = await Promise.all([
|
|
||||||
fetchSystemConfig(),
|
|
||||||
fetchProxyConfig(),
|
|
||||||
fetchSocialLoginConfig(),
|
|
||||||
])
|
|
||||||
|
|
||||||
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 || ''
|
|
||||||
|
|
||||||
socialLoginEnabled.value = (social.social_login_enabled ?? 0) === 1
|
|
||||||
socialLoginEndpoint.value = social.social_login_endpoint || 'https://www.spacezs.cn/connect.php'
|
|
||||||
socialLoginAppid.value = social.social_login_appid || ''
|
|
||||||
socialLoginAppkey.value = ''
|
|
||||||
socialLoginAppkeyMasked.value = social.social_login_appkey_masked || ''
|
|
||||||
socialLoginAppkeyConfigured.value = Boolean(social.social_login_appkey_configured)
|
|
||||||
socialLoginProviders.value = Array.isArray(social.social_login_providers) && social.social_login_providers.length
|
|
||||||
? social.social_login_providers
|
|
||||||
: ['wx']
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function socialLoginPayload() {
|
|
||||||
return {
|
|
||||||
social_login_enabled: socialLoginEnabled.value ? 1 : 0,
|
|
||||||
social_login_endpoint: socialLoginEndpoint.value.trim() || 'https://www.spacezs.cn/connect.php',
|
|
||||||
social_login_appid: socialLoginAppid.value.trim(),
|
|
||||||
social_login_appkey: socialLoginAppkey.value.trim(),
|
|
||||||
social_login_providers: socialLoginProviders.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSocialLoginForm() {
|
|
||||||
if (!socialLoginEnabled.value) return true
|
|
||||||
if (!socialLoginProviders.value.length) {
|
|
||||||
ElMessage.error('请选择至少一种登录方式')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!socialLoginEndpoint.value.trim()) {
|
|
||||||
ElMessage.error('请输入聚合接口地址')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!socialLoginAppid.value.trim()) {
|
|
||||||
ElMessage.error('请输入 APPID')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!socialLoginAppkey.value.trim() && !socialLoginAppkeyConfigured.value) {
|
|
||||||
ElMessage.error('请输入 APPKEY')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSocialLogin() {
|
|
||||||
if (!validateSocialLoginForm()) return
|
|
||||||
socialLoginSaving.value = true
|
|
||||||
try {
|
|
||||||
const res = await updateSocialLoginConfig(socialLoginPayload())
|
|
||||||
const cfg = res?.config || {}
|
|
||||||
socialLoginAppkey.value = ''
|
|
||||||
socialLoginAppkeyMasked.value = cfg.social_login_appkey_masked || socialLoginAppkeyMasked.value
|
|
||||||
socialLoginAppkeyConfigured.value = Boolean(cfg.social_login_appkey_configured)
|
|
||||||
ElMessage.success(res?.message || '聚合登录配置已保存')
|
|
||||||
} catch {
|
|
||||||
// handled by interceptor
|
|
||||||
} finally {
|
|
||||||
socialLoginSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onTestSocialLogin() {
|
|
||||||
if (!validateSocialLoginForm()) return
|
|
||||||
socialLoginTesting.value = true
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.pathname = '/login'
|
|
||||||
url.search = ''
|
|
||||||
url.hash = ''
|
|
||||||
const provider = socialLoginProviders.value[0] || 'wx'
|
|
||||||
const res = await testSocialLoginConfig({
|
|
||||||
...socialLoginPayload(),
|
|
||||||
provider,
|
|
||||||
redirect_uri: url.toString(),
|
|
||||||
})
|
|
||||||
await ElMessageBox.alert(res?.success ? '连接正常' : '测试完成', '聚合登录测试', {
|
|
||||||
confirmButtonText: '知道了',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// handled by interceptor
|
|
||||||
} finally {
|
|
||||||
socialLoginTesting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card social-card">
|
|
||||||
<h3 class="section-title">聚合登录</h3>
|
|
||||||
<div class="section-sub app-muted">QQ、微信、支付宝快捷登录</div>
|
|
||||||
|
|
||||||
<el-form label-width="122px">
|
|
||||||
<el-form-item label="启用">
|
|
||||||
<el-switch v-model="socialLoginEnabled" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="登录方式">
|
|
||||||
<el-checkbox-group v-model="socialLoginProviders">
|
|
||||||
<el-checkbox v-for="item in socialProviderOptions" :key="item.value" :label="item.value">
|
|
||||||
{{ item.label }}
|
|
||||||
</el-checkbox>
|
|
||||||
</el-checkbox-group>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="接口地址">
|
|
||||||
<el-input v-model="socialLoginEndpoint" placeholder="https://www.spacezs.cn/connect.php" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="APPID">
|
|
||||||
<el-input v-model="socialLoginAppid" placeholder="Space APPID" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="APPKEY">
|
|
||||||
<el-input v-model="socialLoginAppkey" type="password" show-password placeholder="留空则保持当前密钥" />
|
|
||||||
<div v-if="socialLoginAppkeyConfigured" class="help">当前:{{ socialLoginAppkeyMasked }}</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<div class="row-actions">
|
|
||||||
<el-button type="primary" :loading="socialLoginSaving" @click="saveSocialLogin">保存聚合登录</el-button>
|
|
||||||
<el-button :loading="socialLoginTesting" @click="onTestSocialLogin">测试连接</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 (min-width: 1201px) {
|
|
||||||
.social-card {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.config-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kdocs-inline {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kdocs-range {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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 AdminSocialBindCallbackPage = () => import('../pages/AdminSocialBindCallbackPage.vue')
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{ path: '/social-bind-callback', name: 'admin_social_bind_callback', component: AdminSocialBindCallbackPage },
|
|
||||||
{
|
|
||||||
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
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
+152
-518
@@ -2,160 +2,24 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
API 浏览器 - 用纯 HTTP 请求实现浏览功能
|
API 浏览器 - 用纯 HTTP 请求实现浏览功能
|
||||||
比传统浏览器自动化快 30-60 倍
|
比 Playwright 快 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
|
|
||||||
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 # 预热期间的超时时间
|
|
||||||
|
|
||||||
|
|
||||||
# HTML解析缓存类
|
BASE_URL = "https://postoa.aidunsoft.com"
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_api_browser_instances():
|
|
||||||
"""进程退出时清理残留的API浏览器实例(弱引用,不阻止GC)"""
|
|
||||||
for inst in list(_api_browser_instances):
|
|
||||||
try:
|
|
||||||
inst.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -167,107 +31,66 @@ 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',
|
||||||
"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',
|
||||||
"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',
|
||||||
"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 = {"http": proxy_server, "https": proxy_server}
|
self.session.proxies = {
|
||||||
|
"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)
|
# 注册退出清理函数
|
||||||
|
atexit.register(self._cleanup_on_exit)
|
||||||
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:
|
||||||
lines = [
|
# 获取requests session的cookies
|
||||||
"# Netscape HTTP Cookie File",
|
cookies_list = []
|
||||||
"# This file was generated by zsglpt",
|
|
||||||
]
|
|
||||||
for cookie in self.session.cookies:
|
for cookie in self.session.cookies:
|
||||||
domain = cookie.domain or _cookie_domain_fallback
|
cookies_list.append({
|
||||||
include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
|
'name': cookie.name,
|
||||||
path = cookie.path or "/"
|
'value': cookie.value,
|
||||||
secure = "TRUE" if getattr(cookie, "secure", False) else "FALSE"
|
'domain': cookie.domain or 'postoa.aidunsoft.com',
|
||||||
expires = int(getattr(cookie, "expires", 0) or 0)
|
'path': cookie.path or '/',
|
||||||
lines.append(
|
})
|
||||||
"\t".join(
|
|
||||||
[
|
|
||||||
domain,
|
|
||||||
include_subdomains,
|
|
||||||
path,
|
|
||||||
secure,
|
|
||||||
str(expires),
|
|
||||||
cookie.name,
|
|
||||||
cookie.value,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(cookies_path, "w", encoding="utf-8") as f:
|
# Playwright storage_state 格式
|
||||||
f.write("\n".join(lines) + "\n")
|
storage_state = {
|
||||||
try:
|
'cookies': cookies_list,
|
||||||
os.chmod(cookies_path, 0o600)
|
'origins': []
|
||||||
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
|
||||||
@@ -275,43 +98,25 @@ 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):
|
||||||
"""带重试机制的请求方法"""
|
"""带重试机制的请求方法"""
|
||||||
# 启动后 60 秒内使用更长超时(15秒),之后使用配置的超时
|
kwargs.setdefault('timeout', 10)
|
||||||
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)}")
|
||||||
@@ -321,78 +126,48 @@ 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]:
|
|
||||||
"""
|
|
||||||
获取用户真实姓名
|
|
||||||
从 center.aspx 页面解析姓名信息
|
|
||||||
返回: 姓名字符串,失败返回 None
|
|
||||||
"""
|
|
||||||
if not self.logged_in:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{BASE_URL}/admin/center.aspx"
|
|
||||||
resp = self._request_with_retry("get", url)
|
|
||||||
soup = BeautifulSoup(resp.text, "html.parser")
|
|
||||||
|
|
||||||
# 查找包含"姓名:"的元素
|
|
||||||
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
|
|
||||||
nlist = soup.find("div", {"class": "nlist-5"})
|
|
||||||
if nlist:
|
|
||||||
first_li = nlist.find("li")
|
|
||||||
if first_li:
|
|
||||||
text = first_li.get_text()
|
|
||||||
# 解析姓名:格式为 "姓名:XXX(手机号)"
|
|
||||||
match = re.search(r"姓名[::]\s*([^\((]+)", text)
|
|
||||||
if match:
|
|
||||||
real_name = match.group(1).strip()
|
|
||||||
if real_name:
|
|
||||||
return real_name
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def login(self, username: str, password: str) -> bool:
|
def login(self, username: str, password: str) -> bool:
|
||||||
"""登录"""
|
"""登录"""
|
||||||
self.log(f"[API] 登录: {username}")
|
self.log(f"[API] 登录: {username}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._request_with_retry("get", LOGIN_URL)
|
login_url = f"{BASE_URL}/admin/login.aspx"
|
||||||
|
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_URL_PATTERN in resp.url:
|
if 'index.aspx' 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
|
||||||
|
|
||||||
@@ -405,145 +180,104 @@ 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,
|
||||||
"title": title,
|
'href': href,
|
||||||
"href": href,
|
'article_id': article_id,
|
||||||
"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):
|
||||||
"""获取文章的附件列表和文章信息"""
|
"""获取文章的附件列表"""
|
||||||
if not article_href.startswith("http"):
|
try:
|
||||||
|
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)
|
||||||
# 使用URL作为缓存键(简化版本)
|
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||||
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}
|
|
||||||
|
|
||||||
# 从 saveread 按钮获取 channel_id 和 article_id
|
attach_list = soup.find('div', {'class': 'attach-list2'})
|
||||||
for elem in soup.find_all(["button", "input"]):
|
|
||||||
onclick = elem.get("onclick", "")
|
|
||||||
match = re.search(r"saveread\((\d+),(\d+)\)", onclick)
|
|
||||||
if match:
|
|
||||||
article_info["channel_id"] = match.group(1)
|
|
||||||
article_info["article_id"] = match.group(2)
|
|
||||||
break
|
|
||||||
|
|
||||||
attach_list = soup.find("div", {"class": "attach-list2"})
|
|
||||||
if attach_list:
|
if attach_list:
|
||||||
items = attach_list.find_all("li")
|
items = attach_list.find_all('li')
|
||||||
for item in items:
|
for item in items:
|
||||||
download_links = item.find_all("a", onclick=re.compile(r"download2?\.ashx"))
|
download_links = item.find_all('a', onclick=re.compile(r'download\.ashx'))
|
||||||
for link in download_links:
|
for link in download_links:
|
||||||
onclick = link.get("onclick", "")
|
onclick = link.get('onclick', '')
|
||||||
id_match = re.search(r"id=(\d+)", onclick)
|
id_match = re.search(r'id=(\d+)', onclick)
|
||||||
channel_match = re.search(r"channel_id=(\d+)", onclick)
|
channel_match = re.search(r'channel_id=(\d+)', onclick)
|
||||||
if id_match:
|
if id_match:
|
||||||
attach_id = id_match.group(1)
|
attach_id = id_match.group(1)
|
||||||
channel_id = channel_match.group(1) if channel_match else "1"
|
channel_id = channel_match.group(1) if channel_match else '1'
|
||||||
h3 = item.find("h3")
|
h3 = item.find('h3')
|
||||||
filename = h3.get_text().strip() if h3 else f"附件{attach_id}"
|
filename = h3.get_text().strip() if h3 else f'附件{attach_id}'
|
||||||
attachments.append({"id": attach_id, "channel_id": channel_id, "filename": filename})
|
attachments.append({
|
||||||
|
'id': attach_id,
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'filename': filename
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
result = (attachments, article_info)
|
return attachments
|
||||||
# 存入缓存
|
|
||||||
self._parse_cache.set(cache_key, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def mark_article_read(self, channel_id: str, article_id: str) -> bool:
|
except Exception as e:
|
||||||
"""通过 saveread API 标记文章已读"""
|
return []
|
||||||
if not channel_id or not article_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
import random
|
def mark_read(self, attach_id: str, channel_id: str = '1') -> bool:
|
||||||
|
"""通过访问下载链接标记已读"""
|
||||||
saveread_url = (
|
download_url = f"{BASE_URL}/tools/download.ashx?site=main&id={attach_id}&channel_id={channel_id}"
|
||||||
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)
|
||||||
@@ -552,19 +286,14 @@ class APIBrowser:
|
|||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def browse_content(
|
def browse_content(self, browse_type: str,
|
||||||
self,
|
should_stop_callback: Optional[Callable] = None) -> APIBrowseResult:
|
||||||
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:
|
||||||
浏览结果
|
浏览结果
|
||||||
@@ -576,149 +305,75 @@ class APIBrowser:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# 根据浏览类型确定 bz 参数
|
# 根据浏览类型确定 bz 参数
|
||||||
# 网站更新后参数: 0=应读, 1=已读(注册前未读需通过页面交互切换)
|
# 网页实际选项: 0=注册前未读, 1=已读, 2=应读
|
||||||
# 当前前端选项: 注册前未读、应读(默认应读)
|
# 前端选项: 注册前未读, 应读, 未读, 已读
|
||||||
browse_type_text = str(browse_type or "")
|
if '注册前' in browse_type:
|
||||||
if "注册前" in browse_type_text:
|
bz = 0 # 注册前未读
|
||||||
bz = 0 # 注册前未读(暂与应读相同,网站通过页面状态区分)
|
elif browse_type == '已读':
|
||||||
|
bz = 1 # 已读
|
||||||
else:
|
else:
|
||||||
bz = 0 # 应读
|
bz = 2 # 应读、未读 都映射到 bz=2
|
||||||
|
|
||||||
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
|
||||||
skipped_items = 0
|
page = 1
|
||||||
consecutive_failures = 0
|
base_url = None
|
||||||
max_consecutive_failures = 3
|
|
||||||
|
|
||||||
# 获取第一页,了解总记录数
|
# 获取第一页
|
||||||
try:
|
articles, total_pages, next_url = self.get_article_list_page(bz, page)
|
||||||
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
|
||||||
|
|
||||||
total_records = int(getattr(self, "last_total_records", 0) or 0)
|
self.log(f"[API] 共 {total_pages} 页,开始处理...")
|
||||||
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
|
|
||||||
|
|
||||||
last_report_ts = 0.0
|
if next_url:
|
||||||
|
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
|
||||||
|
|
||||||
article_href = article["href"]
|
title = article['title'][:30]
|
||||||
# 跳过已处理的文章
|
|
||||||
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)
|
# 获取附件
|
||||||
article_marked = False
|
attachments = self.get_article_attachments(article['href'])
|
||||||
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
|
||||||
|
|
||||||
try:
|
articles, _, next_url = self.get_article_list_page(bz, page, base_url)
|
||||||
articles, new_total_pages, _ = self.get_article_list_page(bz, current_page)
|
if not articles:
|
||||||
if new_total_pages > 0:
|
|
||||||
total_pages = new_total_pages
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
report_progress(force=True)
|
if next_url:
|
||||||
if skipped_items:
|
base_url = next_url
|
||||||
self.log(
|
|
||||||
f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件(跳过 {skipped_items} 条内容)"
|
time.sleep(0.2)
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
||||||
|
|
||||||
result.success = True
|
result.success = True
|
||||||
@@ -741,10 +396,14 @@ class APIBrowser:
|
|||||||
self.session.close()
|
self.session.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
|
def _cleanup_on_exit(self):
|
||||||
|
"""进程退出时的清理函数(由atexit调用)"""
|
||||||
|
if not self._closed:
|
||||||
try:
|
try:
|
||||||
_api_browser_instances.discard(self)
|
self.session.close()
|
||||||
except Exception:
|
self._closed = True
|
||||||
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
@@ -755,28 +414,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
.vite
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# app-frontend
|
|
||||||
|
|
||||||
前台(用户端)Vue3 + Vite 工程,构建产物输出到 `static/app/`。
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!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>
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Generated
-2476
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"qrcode.vue": "^3.6.0",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<script setup></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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 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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSocialConfig() {
|
|
||||||
const { data } = await publicApi.get('/auth/social/config')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function socialLoginUrl(payload) {
|
|
||||||
const { data } = await publicApi.post('/auth/social/login-url', payload || {})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function socialPoll(payload) {
|
|
||||||
const { data } = await publicApi.post('/auth/social/poll', payload || {})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function socialCallback(payload) {
|
|
||||||
const { data } = await publicApi.post('/auth/social/callback', payload || {})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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 fetchSocialBindings() {
|
|
||||||
const { data } = await publicApi.get('/user/social-bindings')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bindSocial(payload) {
|
|
||||||
const { data } = await publicApi.post('/user/social-bindings', payload || {})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unbindSocial(provider) {
|
|
||||||
const { data } = await publicApi.delete(`/user/social-bindings/${encodeURIComponent(provider)}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { publicApi } from './http'
|
|
||||||
|
|
||||||
export async function fetchRunStats() {
|
|
||||||
const { data } = await publicApi.get('/run_stats')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import QrcodeVue from 'qrcode.vue'
|
|
||||||
|
|
||||||
import { socialLoginUrl, socialPoll } from '../api/auth'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
providers: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
mode: {
|
|
||||||
type: String,
|
|
||||||
default: 'login',
|
|
||||||
},
|
|
||||||
redirectUri: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
block: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['error'])
|
|
||||||
|
|
||||||
const providerLabels = {
|
|
||||||
qq: 'QQ',
|
|
||||||
wx: '微信',
|
|
||||||
alipay: '支付宝',
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadingProvider = ref('')
|
|
||||||
const qrOpen = ref(false)
|
|
||||||
const qrValue = ref('')
|
|
||||||
const qrProvider = ref('wx')
|
|
||||||
let pollTimer = null
|
|
||||||
let pollStartedAt = 0
|
|
||||||
|
|
||||||
const enabledProviders = computed(() => props.providers.filter((item) => providerLabels[item]))
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer) {
|
|
||||||
window.clearTimeout(pollTimer)
|
|
||||||
pollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeQr() {
|
|
||||||
stopPolling()
|
|
||||||
qrOpen.value = false
|
|
||||||
qrValue.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function qrPrompt(provider) {
|
|
||||||
if (provider === 'wx') return '请使用微信扫描二维码点关注后登录'
|
|
||||||
if (provider === 'qq') return '请使用 QQ 扫描二维码登录'
|
|
||||||
return '请使用支付宝扫描二维码登录'
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerIcon(provider) {
|
|
||||||
if (provider === 'wx') return '微'
|
|
||||||
if (provider === 'qq') return 'Q'
|
|
||||||
return '支'
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitError(error, fallback) {
|
|
||||||
const data = error?.response?.data
|
|
||||||
const message = data?.error || data?.message || fallback
|
|
||||||
emit('error', message)
|
|
||||||
ElMessage.error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePoll(provider, state, intervalSeconds) {
|
|
||||||
stopPolling()
|
|
||||||
pollStartedAt = Date.now()
|
|
||||||
const tick = async () => {
|
|
||||||
if (Date.now() - pollStartedAt > 5 * 60 * 1000) {
|
|
||||||
closeQr()
|
|
||||||
ElMessage.warning('二维码已过期,请重新获取')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await socialPoll({ provider, state })
|
|
||||||
if (result?.status === 'authorized' && result?.url) {
|
|
||||||
closeQr()
|
|
||||||
window.location.assign(result.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
|
||||||
} catch (error) {
|
|
||||||
closeQr()
|
|
||||||
emitError(error, '扫码状态获取失败,请重新尝试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start(provider) {
|
|
||||||
if (loadingProvider.value) return
|
|
||||||
loadingProvider.value = provider
|
|
||||||
try {
|
|
||||||
const data = await socialLoginUrl({
|
|
||||||
provider,
|
|
||||||
mode: props.mode === 'bind' ? 'bind' : 'login',
|
|
||||||
redirect_uri: props.redirectUri,
|
|
||||||
})
|
|
||||||
if (provider !== 'wx') {
|
|
||||||
window.location.assign(data.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const value = data.scan_url || data.qrcode || data.url
|
|
||||||
if (!value || !data.scan_state) {
|
|
||||||
ElMessage.error('微信二维码获取失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qrProvider.value = provider
|
|
||||||
qrValue.value = value
|
|
||||||
qrOpen.value = true
|
|
||||||
schedulePoll(provider, data.scan_state, data.scan_poll_interval || 2)
|
|
||||||
} catch (error) {
|
|
||||||
emitError(error, '获取聚合登录地址失败')
|
|
||||||
} finally {
|
|
||||||
loadingProvider.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopPolling()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="enabledProviders.length" class="social-login-buttons" :class="{ block }">
|
|
||||||
<button
|
|
||||||
v-for="provider in enabledProviders"
|
|
||||||
:key="provider"
|
|
||||||
type="button"
|
|
||||||
class="social-btn"
|
|
||||||
:class="`provider-${provider}`"
|
|
||||||
:disabled="Boolean(loadingProvider)"
|
|
||||||
@click="start(provider)"
|
|
||||||
>
|
|
||||||
<span class="social-icon">{{ providerIcon(provider) }}</span>
|
|
||||||
<span>{{ mode === 'bind' ? `绑定${providerLabels[provider]}` : `${providerLabels[provider]}登录` }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<el-dialog v-model="qrOpen" :title="`${providerLabels[qrProvider]}登录`" width="min(340px, 92vw)" @close="closeQr">
|
|
||||||
<div class="social-qr-box">
|
|
||||||
<QrcodeVue v-if="qrValue" :value="qrValue" :size="220" level="M" />
|
|
||||||
<div class="social-qr-prompt">{{ qrPrompt(qrProvider) }}</div>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.social-login-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-login-buttons.block {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-btn {
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block .social-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-btn:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-btn:hover:not(:disabled) {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-wx .social-icon {
|
|
||||||
background: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-qq .social-icon {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-alipay .social-icon {
|
|
||||||
background: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-qr-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-qr-prompt {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #374151;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
|
|
||||||
import LoginPage from './pages/LoginPage.vue'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
createApp(LoginPage).mount('#app')
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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')
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,939 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
|
|
||||||
import SocialLoginButtons from '../components/SocialLoginButtons.vue'
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
captcha: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const needCaptcha = ref(false)
|
|
||||||
const captchaImage = ref('')
|
|
||||||
const captchaSession = ref('')
|
|
||||||
const loading = 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)
|
|
||||||
const socialConfig = ref({ enabled: false, providers: [] })
|
|
||||||
const socialCallbackLoading = ref(false)
|
|
||||||
|
|
||||||
const socialPendingKeys = {
|
|
||||||
token: 'zsglpt_social_pending_bind_token',
|
|
||||||
provider: 'zsglpt_social_pending_bind_provider',
|
|
||||||
nickname: 'zsglpt_social_pending_bind_nickname',
|
|
||||||
avatar: 'zsglpt_social_pending_bind_avatar_url',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
|
|
||||||
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
|
|
||||||
const fetchSocialConfig = () => apiRequest('/auth/social/config')
|
|
||||||
const socialCallbackRequest = (payload) => apiRequest('/auth/social/callback', { method: 'POST', body: payload || {} })
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentLoginRedirectUri() {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.pathname = '/login'
|
|
||||||
url.search = ''
|
|
||||||
url.hash = ''
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSocialConfig() {
|
|
||||||
try {
|
|
||||||
const data = await fetchSocialConfig()
|
|
||||||
socialConfig.value = {
|
|
||||||
enabled: Boolean(data?.enabled),
|
|
||||||
providers: Array.isArray(data?.providers) ? data.providers : [],
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
socialConfig.value = { enabled: false, providers: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePendingSocialBind(data) {
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(socialPendingKeys.token, data?.bind_token || '')
|
|
||||||
window.sessionStorage.setItem(socialPendingKeys.provider, data?.provider || '')
|
|
||||||
window.sessionStorage.setItem(socialPendingKeys.nickname, data?.nickname || '')
|
|
||||||
window.sessionStorage.setItem(socialPendingKeys.avatar, data?.avatar_url || '')
|
|
||||||
} catch {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSocialCallback() {
|
|
||||||
const params = new URLSearchParams(window.location.search || '')
|
|
||||||
const provider = String(params.get('provider') || params.get('type') || '').trim()
|
|
||||||
const code = String(params.get('code') || '').trim()
|
|
||||||
return Boolean(provider && code)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSocialCallback() {
|
|
||||||
const params = new URLSearchParams(window.location.search || '')
|
|
||||||
const provider = String(params.get('provider') || params.get('type') || '').trim()
|
|
||||||
const code = String(params.get('code') || '').trim()
|
|
||||||
if (!provider || !code) return false
|
|
||||||
|
|
||||||
socialCallbackLoading.value = true
|
|
||||||
setNotice('success', '正在完成快捷登录...')
|
|
||||||
try {
|
|
||||||
const data = await socialCallbackRequest({ provider, code, mode: 'login' })
|
|
||||||
if (data?.requires_register && data?.bind_token) {
|
|
||||||
savePendingSocialBind(data)
|
|
||||||
setNotice('success', '请完成注册后继续')
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.href = '/register'
|
|
||||||
}, 500)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (data?.success || data?.bound) {
|
|
||||||
setNotice('success', '快捷登录成功,正在跳转...')
|
|
||||||
redirectAfterLogin()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
setNotice('error', '快捷登录失败')
|
|
||||||
} catch (e) {
|
|
||||||
const data = e?.response?.data
|
|
||||||
setNotice('error', data?.error || data?.message || '快捷登录失败')
|
|
||||||
} finally {
|
|
||||||
socialCallbackLoading.value = false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 (hasSocialCallback()) {
|
|
||||||
await handleSocialCallback()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await loadSocialConfig()
|
|
||||||
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 || socialCallbackLoading" @click="onSubmit">
|
|
||||||
{{ loading || socialCallbackLoading ? '登录中...' : '登录系统' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="socialConfig.enabled" class="social-login-area">
|
|
||||||
<div class="divider"><span>快捷登录</span></div>
|
|
||||||
<SocialLoginButtons
|
|
||||||
:providers="socialConfig.providers"
|
|
||||||
mode="login"
|
|
||||||
:redirect-uri="currentLoginRedirectUri()"
|
|
||||||
block
|
|
||||||
@error="(message) => setNotice('error', message)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-login-area {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 14px 0 10px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider::before,
|
|
||||||
.divider::after {
|
|
||||||
content: '';
|
|
||||||
height: 1px;
|
|
||||||
flex: 1;
|
|
||||||
background: rgba(17, 24, 39, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
<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 socialPendingKeys = {
|
|
||||||
token: 'zsglpt_social_pending_bind_token',
|
|
||||||
provider: 'zsglpt_social_pending_bind_provider',
|
|
||||||
nickname: 'zsglpt_social_pending_bind_nickname',
|
|
||||||
avatar: 'zsglpt_social_pending_bind_avatar_url',
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerLabels = {
|
|
||||||
qq: 'QQ',
|
|
||||||
wx: '微信',
|
|
||||||
alipay: '支付宝',
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingSocial = reactive({
|
|
||||||
token: '',
|
|
||||||
provider: '',
|
|
||||||
nickname: '',
|
|
||||||
avatar_url: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPendingSocialBind() {
|
|
||||||
try {
|
|
||||||
pendingSocial.token = window.sessionStorage.getItem(socialPendingKeys.token) || ''
|
|
||||||
pendingSocial.provider = window.sessionStorage.getItem(socialPendingKeys.provider) || ''
|
|
||||||
pendingSocial.nickname = window.sessionStorage.getItem(socialPendingKeys.nickname) || ''
|
|
||||||
pendingSocial.avatar_url = window.sessionStorage.getItem(socialPendingKeys.avatar) || ''
|
|
||||||
if (pendingSocial.nickname && !form.username) {
|
|
||||||
form.username = pendingSocial.nickname.replace(/[^\w\u4e00-\u9fa5]/g, '').slice(0, 20)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
pendingSocial.token = ''
|
|
||||||
pendingSocial.provider = ''
|
|
||||||
pendingSocial.nickname = ''
|
|
||||||
pendingSocial.avatar_url = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPendingSocialBind() {
|
|
||||||
try {
|
|
||||||
Object.values(socialPendingKeys).forEach((key) => window.sessionStorage.removeItem(key))
|
|
||||||
} catch {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
pendingSocial.token = ''
|
|
||||||
pendingSocial.provider = ''
|
|
||||||
pendingSocial.nickname = ''
|
|
||||||
pendingSocial.avatar_url = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
social_bind_token: pendingSocial.token || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
successTitle.value = res?.message || '注册成功'
|
|
||||||
successDesc.value = res?.need_verify ? '请检查您的邮箱(包括垃圾邮件文件夹)' : ''
|
|
||||||
ElMessage.success('注册成功')
|
|
||||||
if (pendingSocial.token) {
|
|
||||||
clearPendingSocialBind()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
loadPendingSocialBind()
|
|
||||||
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-alert
|
|
||||||
v-if="pendingSocial.token"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
class="alert"
|
|
||||||
:title="`注册后绑定${providerLabels[pendingSocial.provider] || '快捷登录'}`"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,703 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
import { socialCallback } from '../api/auth'
|
|
||||||
import { bindSocial } from '../api/settings'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const statusText = ref('正在完成绑定')
|
|
||||||
const pendingSettingsBindKey = 'zsglpt_social_settings_bind_token'
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const params = new URLSearchParams(window.location.search || '')
|
|
||||||
const provider = String(params.get('provider') || params.get('type') || '').trim()
|
|
||||||
const code = String(params.get('code') || '').trim()
|
|
||||||
if (!provider || !code) {
|
|
||||||
ElMessage.error('快捷登录回调参数不完整')
|
|
||||||
router.replace('/app/accounts')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await socialCallback({ provider, code, mode: 'bind' })
|
|
||||||
if (data?.success && data?.bound) {
|
|
||||||
ElMessage.success('快捷登录已绑定')
|
|
||||||
router.replace('/app/accounts')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!data?.bind_token) {
|
|
||||||
ElMessage.warning('未获取到绑定凭证')
|
|
||||||
router.replace('/app/accounts')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await bindSocial({ bind_token: data.bind_token })
|
|
||||||
ElMessage.success('快捷登录已绑定')
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.response?.status === 401) {
|
|
||||||
window.sessionStorage.setItem(pendingSettingsBindKey, data.bind_token)
|
|
||||||
ElMessage.info('请先登录后完成绑定')
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
router.replace('/app/accounts')
|
|
||||||
} catch (error) {
|
|
||||||
const payload = error?.response?.data
|
|
||||||
statusText.value = payload?.error || '快捷登录绑定失败'
|
|
||||||
ElMessage.error(statusText.value)
|
|
||||||
router.replace('/app/accounts')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="callback-wrap">
|
|
||||||
<el-card shadow="never" class="callback-card">
|
|
||||||
<el-skeleton :rows="3" animated />
|
|
||||||
<div class="callback-text">{{ statusText }}</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.callback-wrap {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callback-card {
|
|
||||||
width: min(420px, 94vw);
|
|
||||||
border-radius: var(--app-radius);
|
|
||||||
border: 1px solid var(--app-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.callback-text {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: var(--app-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 SocialBindCallbackPage = () => import('../pages/SocialBindCallbackPage.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: '/social-bind-callback', name: 'social_bind_callback', component: SocialBindCallbackPage },
|
|
||||||
{ 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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
: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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
File diff suppressed because it is too large
Load Diff
+53
-206
@@ -8,98 +8,46 @@
|
|||||||
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"[OK] 已加载环境变量文件: {env_path}")
|
print(f"✓ 已加载环境变量文件: {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):
|
||||||
_ensure_private_file(SECRET_KEY_FILE)
|
with open(SECRET_KEY_FILE, 'r') as f:
|
||||||
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()
|
||||||
_ensure_private_dir("data")
|
os.makedirs('data', exist_ok=True)
|
||||||
with open(SECRET_KEY_FILE, "w") as f:
|
with open(SECRET_KEY_FILE, 'w') as f:
|
||||||
f.write(new_key)
|
f.write(new_key)
|
||||||
_ensure_private_file(SECRET_KEY_FILE)
|
print(f"✓ 已生成新的SECRET_KEY并保存到 {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:
|
||||||
"""应用配置基类"""
|
"""应用配置基类"""
|
||||||
|
|
||||||
@@ -109,30 +57,27 @@ 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 (
|
SESSION_COOKIE_SECURE = _force_secure or (_is_production and os.environ.get('HTTPS_ENABLED', 'false').lower() == 'true')
|
||||||
_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 = []
|
warnings = []
|
||||||
env = os.environ.get("FLASK_ENV", "production")
|
env = os.environ.get('FLASK_ENV', 'production')
|
||||||
|
|
||||||
if 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")
|
||||||
|
|
||||||
@@ -143,125 +88,59 @@ 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')
|
||||||
ZSGL_BASE_URL = os.environ.get("ZSGL_BASE_URL") or _derive_base_url_from_full_url(
|
MAX_CONCURRENT_CONTEXTS = int(os.environ.get('MAX_CONCURRENT_CONTEXTS', '100'))
|
||||||
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):
|
||||||
@@ -285,40 +164,11 @@ 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
|
||||||
@@ -342,14 +192,12 @@ 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
|
||||||
@@ -357,27 +205,26 @@ 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()
|
||||||
@@ -387,5 +234,5 @@ if __name__ == "__main__":
|
|||||||
for error in errors:
|
for error in errors:
|
||||||
print(f" ✗ {error}")
|
print(f" ✗ {error}")
|
||||||
else:
|
else:
|
||||||
print("[OK] 配置验证通过")
|
print("✓ 配置验证通过")
|
||||||
config.print_config()
|
config.print_config()
|
||||||
|
|||||||
+1
-41
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -46,31 +45,6 @@ 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):
|
||||||
"""
|
"""
|
||||||
设置日志记录器
|
设置日志记录器
|
||||||
@@ -100,17 +74,6 @@ 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(
|
||||||
@@ -317,10 +280,7 @@ def init_logging(log_level='INFO', log_file='logs/app.log'):
|
|||||||
|
|
||||||
# 创建审计日志器(已在AuditLogger中创建)
|
# 创建审计日志器(已在AuditLogger中创建)
|
||||||
|
|
||||||
try:
|
print("✓ 日志系统初始化完成")
|
||||||
get_logger('app').info("[OK] 日志系统初始化完成")
|
|
||||||
except Exception:
|
|
||||||
print("[OK] 日志系统初始化完成")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+7
-216
@@ -9,14 +9,10 @@ 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
|
||||||
@@ -79,13 +75,7 @@ def sanitize_filename(filename):
|
|||||||
class IPRateLimiter:
|
class IPRateLimiter:
|
||||||
"""IP访问频率限制器"""
|
"""IP访问频率限制器"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, max_attempts=10, window_seconds=3600, lock_duration=3600):
|
||||||
self,
|
|
||||||
max_attempts=10,
|
|
||||||
window_seconds=3600,
|
|
||||||
lock_duration=3600,
|
|
||||||
max_tracked_ips=20000,
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
初始化限流器
|
初始化限流器
|
||||||
|
|
||||||
@@ -97,7 +87,6 @@ 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)
|
||||||
@@ -105,47 +94,6 @@ 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是否被锁定
|
||||||
@@ -178,7 +126,6 @@ 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
|
||||||
@@ -247,43 +194,18 @@ 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 = get_rate_limit_ip()
|
ip_address = request.remote_addr
|
||||||
|
|
||||||
# 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 (
|
return jsonify({
|
||||||
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)
|
||||||
|
|
||||||
@@ -407,19 +329,7 @@ def generate_csrf_token():
|
|||||||
|
|
||||||
def validate_csrf_token(token):
|
def validate_csrf_token(token):
|
||||||
"""验证CSRF令牌"""
|
"""验证CSRF令牌"""
|
||||||
expected = session.get("csrf_token")
|
return token == 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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 内容安全 ====================
|
# ==================== 内容安全 ====================
|
||||||
@@ -508,7 +418,7 @@ def get_client_ip(trust_proxy=False):
|
|||||||
"""
|
"""
|
||||||
# 安全说明:X-Forwarded-For 可被伪造
|
# 安全说明:X-Forwarded-For 可被伪造
|
||||||
# 仅在确认请求来自可信代理时才使用代理头
|
# 仅在确认请求来自可信代理时才使用代理头
|
||||||
if trust_proxy and _TRUST_PROXY_HEADERS:
|
if trust_proxy:
|
||||||
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'):
|
||||||
@@ -518,125 +428,6 @@ 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("文件路径安全测试:")
|
||||||
|
|||||||
Executable
+328
@@ -0,0 +1,328 @@
|
|||||||
|
#!/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("✓ 所有测试通过!")
|
||||||
Executable
+366
@@ -0,0 +1,366 @@
|
|||||||
|
#!/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("✓ 工具模块加载成功!")
|
||||||
Executable
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/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)
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/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
|
||||||
+110
-344
@@ -1,108 +1,24 @@
|
|||||||
#!/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表示无限制)
|
|
||||||
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__(
|
def __init__(self, worker_id: int, task_queue: queue.Queue, log_callback: Optional[Callable] = None):
|
||||||
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
|
||||||
@@ -112,95 +28,97 @@ 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):
|
||||||
"""创建截图执行环境(逻辑占位,无需真实浏览器)"""
|
"""创建浏览器实例"""
|
||||||
created_at = time.time()
|
try:
|
||||||
|
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 = {
|
||||||
"created_at": created_at,
|
'playwright': playwright,
|
||||||
"use_count": 0,
|
'browser': browser,
|
||||||
"worker_id": self.worker_id,
|
'created_at': time.time(),
|
||||||
|
'use_count': 0,
|
||||||
|
'worker_id': self.worker_id
|
||||||
}
|
}
|
||||||
self.last_activity_ts = created_at
|
self.log(f"浏览器创建成功")
|
||||||
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:
|
||||||
self.log(f"执行环境已释放(共处理{self.browser_instance.get('use_count', 0)}个任务)")
|
try:
|
||||||
|
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:
|
||||||
"""检查执行环境是否就绪"""
|
"""检查浏览器是否健康"""
|
||||||
return bool(self.browser_instance)
|
if not 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):
|
||||||
"""工作线程主循环 - 按需启动执行环境模式"""
|
"""工作线程主循环 - 按需启动浏览器模式"""
|
||||||
if self.pre_warm:
|
self.log("Worker启动(按需模式,等待任务时不占用浏览器资源)")
|
||||||
self.log("Worker启动(预热模式,启动即准备执行环境)")
|
last_task_time = 0
|
||||||
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=queue_timeout)
|
task = self.task_queue.get(timeout=TASK_QUEUE_TIMEOUT)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
# 检查是否需要释放空闲的执行环境
|
# 检查是否需要关闭空闲的浏览器
|
||||||
if self.browser_instance and self.last_activity_ts > 0:
|
if self.browser_instance and last_task_time > 0:
|
||||||
idle_time = time.time() - self.last_activity_ts
|
idle_time = time.time() - last_task_time
|
||||||
|
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
|
||||||
|
|
||||||
@@ -210,92 +128,40 @@ class BrowserWorker(threading.Thread):
|
|||||||
self.log("收到停止信号")
|
self.log("收到停止信号")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 按需创建或确保执行环境可用
|
# 按需创建或确保浏览器可用
|
||||||
browser_ready = False
|
if not self._ensure_browser():
|
||||||
for attempt in range(2):
|
self.log("浏览器不可用,任务失败")
|
||||||
if self._ensure_browser():
|
task['callback'](None, "浏览器不可用")
|
||||||
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
|
||||||
|
|
||||||
# 确保browser_instance存在后再访问
|
self.log(f"开始执行任务(第{self.browser_instance['use_count']}次使用浏览器)")
|
||||||
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
|
||||||
self.last_activity_ts = time.time()
|
last_task_time = time.time()
|
||||||
|
|
||||||
# 任务失败后,检查执行环境健康
|
# 任务失败后,检查浏览器健康
|
||||||
if not self._check_browser_health():
|
if not self._check_browser_health():
|
||||||
self.log("任务失败导致执行环境异常,将在下次任务前重建")
|
self.log("任务失败导致浏览器异常,将在下次任务前重建")
|
||||||
self._close_browser()
|
|
||||||
|
|
||||||
# 定期重启执行环境,释放可能累积的资源
|
|
||||||
if self.browser_instance and BROWSER_MAX_USE_COUNT > 0:
|
|
||||||
if self.browser_instance.get("use_count", 0) >= BROWSER_MAX_USE_COUNT:
|
|
||||||
self.log(f"执行环境已复用{self.browser_instance['use_count']}次,重启释放资源")
|
|
||||||
self._close_browser()
|
self._close_browser()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -312,13 +178,12 @@ 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
|
||||||
self.log_callback = log_callback
|
self.log_callback = log_callback
|
||||||
maxsize = TASK_QUEUE_MAXSIZE if TASK_QUEUE_MAXSIZE > 0 else 0
|
self.task_queue = queue.Queue()
|
||||||
self.task_queue = queue.Queue(maxsize=maxsize)
|
|
||||||
self.workers = []
|
self.workers = []
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
@@ -328,61 +193,27 @@ 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"[OK] 截图线程池初始化完成({self.pool_size}个worker就绪,执行环境将在有任务时按需启动)")
|
self.log(f"✓ 工作线程池初始化完成({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:
|
||||||
"""
|
"""
|
||||||
@@ -401,60 +232,29 @@ 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:
|
self.task_queue.put(task)
|
||||||
self.task_queue.put(task, timeout=1)
|
|
||||||
return True
|
return True
|
||||||
except queue.Full:
|
|
||||||
self.log(f"警告:任务队列已满(maxsize={self.task_queue.maxsize}),拒绝提交任务")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""获取线程池统计信息"""
|
"""获取线程池统计信息"""
|
||||||
workers = list(self.workers or [])
|
idle_count = sum(1 for w in self.workers if w.idle)
|
||||||
idle_count = sum(1 for w in workers if getattr(w, "idle", False))
|
total_tasks = sum(w.total_tasks for w in self.workers)
|
||||||
total_tasks = sum(int(getattr(w, "total_tasks", 0) or 0) for w in workers)
|
failed_tasks = sum(w.failed_tasks for w in self.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": max(0, len(workers) - idle_count),
|
'busy_workers': self.pool_size - 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):
|
||||||
@@ -484,7 +284,7 @@ class BrowserWorkerPool:
|
|||||||
|
|
||||||
self.workers.clear()
|
self.workers.clear()
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
self.log("[OK] 工作线程池已关闭")
|
self.log("✓ 工作线程池已关闭")
|
||||||
|
|
||||||
|
|
||||||
# 全局实例
|
# 全局实例
|
||||||
@@ -493,7 +293,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:
|
||||||
@@ -505,46 +305,12 @@ 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:
|
||||||
@@ -553,15 +319,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):
|
||||||
"""测试回调"""
|
"""测试回调"""
|
||||||
|
|||||||
+10
-139
@@ -4,23 +4,14 @@
|
|||||||
加密工具模块
|
加密工具模块
|
||||||
用于加密存储敏感信息(如第三方账号密码)
|
用于加密存储敏感信息(如第三方账号密码)
|
||||||
使用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__)
|
|
||||||
|
|
||||||
|
|
||||||
# 安全修复: 支持通过环境变量配置密钥文件路径
|
# 安全修复: 支持通过环境变量配置密钥文件路径
|
||||||
@@ -28,37 +19,18 @@ 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)
|
||||||
_ensure_private_dir(salt_path.parent)
|
os.makedirs(salt_path.parent, exist_ok=True)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -73,100 +45,37 @@ 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:
|
||||||
logger.info("使用环境变量 ENCRYPTION_KEY 派生加密密钥")
|
# 使用环境变量中的密钥派生Fernet密钥
|
||||||
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()
|
||||||
_ensure_private_dir(key_path.parent)
|
os.makedirs(key_path.parent, exist_ok=True)
|
||||||
with open(key_path, 'wb') as f:
|
with open(key_path, 'wb') as f:
|
||||||
f.write(key)
|
f.write(key)
|
||||||
_ensure_private_file(key_path)
|
print(f"[安全] 已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}")
|
||||||
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)
|
||||||
@@ -209,11 +118,8 @@ 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:
|
||||||
# 解密失败,可能是旧的明文密码或密钥不匹配
|
# 解密失败,可能是旧的明文密码
|
||||||
if is_encrypted(encrypted_password):
|
print(f"[警告] 密码解密失败,可能是未加密的旧数据: {e}")
|
||||||
logger.error(f"密码解密失败(密钥可能不匹配): {e}")
|
|
||||||
return ''
|
|
||||||
logger.warning(f"密码解密失败,可能是未加密的旧数据: {e}")
|
|
||||||
return encrypted_password
|
return encrypted_password
|
||||||
|
|
||||||
|
|
||||||
@@ -230,6 +136,7 @@ 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')
|
||||||
|
|
||||||
|
|
||||||
@@ -248,39 +155,6 @@ 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"
|
||||||
@@ -293,6 +167,3 @@ 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()}")
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "owaevbqacwruvgb2iwy5cuyq", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=auqf0225&Pwd=F4125F6ACB6BBF4610DEED7C6AF71EAD", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840391.77388, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.773932, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "auqf0225", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.773971, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "F4125F6ACB6BBF4610DEED7C6AF71EAD", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.774006, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "qg5lgvlveakicsjxkd2zs0e4", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=13974660129&Pwd=235400CB551897EBA480ECCB8E9F77E2", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840382.754989, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.75507, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "13974660129", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.755092, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "235400CB551897EBA480ECCB8E9F77E2", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.755113, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "nzch5jykpkxnhmlprk3v2rpv", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=ltx0919&Pwd=4FE5873929D3E84AB5D155BA666D1EBF", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796872060.055808, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055894, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "ltx0919", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055917, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "4FE5873929D3E84AB5D155BA666D1EBF", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055938, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "4pk03tbxuqszmvxwu4l25531", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=15211689491&Pwd=CF534F43D6953D0F380673616127C11F", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840384.193098, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193181, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "15211689491", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193205, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "CF534F43D6953D0F380673616127C11F", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193228, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "u1pmp34wmvqfyykld2b1ktad", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=19892585678&Pwd=A0A429F1CD34F7832B90A62CD8D89BAB", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796839883.461513, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461553, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "19892585678", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461574, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "A0A429F1CD34F7832B90A62CD8D89BAB", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461597, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"cookies": [{"name": "ASP.NET_SessionId", "value": "qk1e4ggreykolpxxscx0wwpe", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=\u9ec4\u7d2b\u590f99&Pwd=E1B6AEEA30B8789B1EEB489F27DBE63D", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840391.293757, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293804, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "%e9%bb%84%e7%b4%ab%e5%a4%8f99", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293836, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "E1B6AEEA30B8789B1EEB489F27DBE63D", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293858, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user