Compare commits

..
73 Commits
Author SHA1 Message Date
237899745 cda9c5ffda 删除 verify_deployment.sh 2025-12-12 15:38:36 +08:00
19174616018andClaude Opus 4.5 18da2f8d18 修复bug和清理调试代码
1. email_service.py: 删除未定义的log_email_send调用(send_email内部已记录日志)
2. app.py: 删除[DEBUG run_task]调试打印语句
3. playwright_automation.py: 删除[调试]诊断日志块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 15:36:36 +08:00
Yu Yon 63ca9265ed 修复高优先级安全和稳定性问题
1. app.py:
   - 添加user_semaphores_lock线程锁,修复get_user_semaphore竞态条件
   - 将9处裸except:改为except Exception:或具体异常类型

2. database.py:
   - 将3处裸except:改为except Exception:

3. email_service.py:
   - 添加_smtp_config_lock线程锁
   - 修复daily_sent计数竞态条件:获取配置时预增,失败时回退
   - _get_available_smtp_config和_get_next_available_smtp_config使用锁保护

🤖 Generated with Claude Code
2025-12-12 15:04:58 +08:00
Yu Yon 4a47d4ba6b 添加任务自动重试机制
- 任务执行失败(非登录失败)时自动重试,最多重试2次
- 重试间隔5秒,给前端显示状态"等待重试"
- 添加sys模块导入修复

🤖 Generated with Claude Code
2025-12-12 13:45:34 +08:00
Yu Yon 82f8a8e73d 更新VIP权限对比表,新增随机时间执行、邮件通知、自动备注姓名等功能说明
- 更新VIP会员权限对比表
- 新增基础浏览任务、随机时间执行、邮件通知、自动备注姓名功能对比
- 修正账号管理数量为最多3个

🤖 Generated with Claude Code
2025-12-12 11:55:31 +08:00
19174616018 9f0b5b1819 添加自动备注日志 2025-12-12 10:48:36 +08:00
19174616018 8730a0a387 自动获取真实姓名作为账号备注(静默完成) 2025-12-12 10:36:47 +08:00
Yu Yon 2d0d51da17 添加定时任务随机时间执行功能;修复手动执行任务不发送邮件通知 2025-12-12 10:12:27 +08:00
19174616018andClaude Opus 4.5 d1e47dca7f fix: 修复send_email参数名错误
使用正确的参数名: to_email, body, html_body

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:31:22 +08:00
19174616018andClaude Opus 4.5 4b258c9061 fix: 修复批次邮件在截图完成后发送
之前邮件在run_task线程结束时发送,但截图是异步的,导致截图还没完成就发送了。
现在改为在截图回调中检测,当所有截图完成时才发送打包邮件。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:27:56 +08:00
19174616018andClaude Opus 4.5 578489cb0b feat: 定时任务截图打包发送邮件
1. 添加批次任务跟踪机制,收集同一定时任务的所有账号截图
2. 等所有账号执行完成后,将截图打包成ZIP发送一封邮件
3. 邮件包含任务执行详情表格和统计信息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:21:24 +08:00
19174616018andClaude Opus 4.5 fc9c264f37 fix: 使用容器内可用字体生成验证码
尝试多个字体路径,优先使用Liberation字体

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:52:39 +08:00
19174616018andClaude Opus 4.5 87b04e84d3 fix: 增大验证码显示尺寸
前端验证码图片高度从36/40px增大到50px,配合后端生成的160x60图片

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:49:35 +08:00
19174616018andClaude Opus 4.5 c1256f6425 fix: 修复邮件设置checkbox点击范围过大问题
将label改为div+span结构,使只有checkbox本身可点击

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:48:06 +08:00
19174616018andClaude Opus 4.5 5c80fa0860 fix: 修复邮箱绑定验证错误及多项改进
1. 修复email_verified字段缺失导致的500错误
2. 将邮件主题从"知识管理平台"改为"自动化学习"
3. 增大验证码字体(28->42)和图片尺寸(120x40->160x60)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:45:44 +08:00
19174616018andClaude Opus 4.5 bda780ed5c fix: 修复邮件Token时间解析错误
问题:数据库中的时间戳包含微秒(如 2025-12-11 14:31:20.307257),
但代码使用 '%Y-%m-%d %H:%M:%S' 格式解析会报错。

解决:添加 parse_datetime() 函数,支持带微秒和不带微秒两种格式。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:33:46 +08:00
19174616018andClaude Opus 4.5 20efb7ec2a feat: 添加邮件功能第六阶段 - API浏览任务通知
在API模式浏览任务完成时(不截图的情况下),也发送任务完成邮件通知

修改位置: app.py run_browse_task函数
- 在enable_screenshot=False时,浏览成功后发送邮件通知
- 复用email_service.send_task_complete_email_async()

至此邮件功能六个阶段全部完成:
1. 邮件基础设施(多SMTP配置、故障转移)
2. 注册邮箱验证
3. 密码重置邮件
4. 任务完成通知(带截图)
5. 用户邮箱绑定
6. API浏览任务通知

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:22:07 +08:00
19174616018andClaude Opus 4.5 7298cffe5e feat: 添加邮件功能第五阶段 - 用户邮箱绑定
1. 添加邮箱绑定验证邮件模板 (templates/email/bind_email.html)
2. 在email_service.py中添加:
   - send_bind_email_verification() 发送绑定验证邮件
   - verify_bind_email_token() 验证绑定Token
3. 在database.py中添加:
   - update_user_email() 更新用户邮箱
4. 在app.py中添加API:
   - GET /api/user/email - 获取用户邮箱信息
   - POST /api/user/bind-email - 发送绑定验证邮件
   - GET /api/verify-bind-email/<token> - 验证绑定Token
   - POST /api/user/unbind-email - 解绑邮箱
5. 更新templates/index.html:
   - 将"修改密码"弹窗改为"个人设置"
   - 添加邮箱绑定/解绑功能UI
   - 显示邮箱状态(未绑定/待验证/已验证)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:20:29 +08:00
19174616018andClaude Opus 4.5 f020a9c6d9 feat: 添加邮件功能第四阶段 - 任务完成通知
1. 添加任务完成邮件模板 (templates/email/task_complete.html)
2. 在email_service.py中添加:
   - task_notify_enabled 字段支持
   - send_task_complete_email() 函数,支持附件大小限制和分批发送
   - send_task_complete_email_async() 异步发送函数
   - MAX_ATTACHMENT_SIZE 常量 (10MB)
3. 更新app.py:
   - 邮件设置API支持task_notify_enabled字段
   - 截图回调中集成任务完成邮件发送
4. 更新admin.html:
   - 添加"启用任务完成通知"开关
   - 更新loadEmailSettings/updateEmailSettings函数

附件超过10MB时会自动分两封邮件发送(通知+截图),作为容错机制

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:09:59 +08:00
19174616018andClaude Opus 4.5 0c0a5a7770 feat: 添加邮件功能第三阶段 - 密码重置
实现通过邮件自助重置密码功能:
- 新增发送密码重置邮件API (/api/forgot-password)
- 新增密码重置页面路由 (/reset-password/<token>)
- 新增确认密码重置API (/api/reset-password-confirm)

新增文件:
- templates/email/reset_password.html - 密码重置邮件模板
- templates/reset_password.html - 密码重置页面

修改文件:
- email_service.py - 添加密码重置相关函数
  - send_password_reset_email()
  - verify_password_reset_token()
  - confirm_password_reset()
- app.py - 添加密码重置相关API
- templates/login.html - 忘记密码支持两种方式:
  - 启用邮件功能:通过邮件自助重置
  - 未启用邮件:提交申请等待管理员审核

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:58:49 +08:00
19174616018andClaude Opus 4.5 648cf0adf0 feat: 添加邮件功能第二阶段 - 注册邮箱验证
实现注册时的邮箱验证功能:
- 修改注册API支持邮箱验证流程
- 新增邮箱验证API (/api/verify-email/<token>)
- 新增重发验证邮件API (/api/resend-verify-email)
- 新增邮箱验证状态查询API (/api/email/verify-status)

新增文件:
- templates/email/register.html - 注册验证邮件模板
- templates/verify_success.html - 验证成功页面
- templates/verify_failed.html - 验证失败页面

修改文件:
- email_service.py - 添加发送注册验证邮件函数
- app.py - 添加邮箱验证相关API
- database.py - 添加get_user_by_email函数
- app_config.py - 添加BASE_URL配置
- templates/register.html - 支持邮箱必填切换
- templates/login.html - 添加重发验证邮件功能
- templates/admin.html - 添加注册验证开关和BASE_URL设置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:51:07 +08:00
19174616018andClaude Opus 4.5 2f762db337 feat: 添加邮件功能第一阶段 - 邮件基础设施
新增功能:
- 创建 email_service.py 邮件服务模块
  - 支持多SMTP配置(主备切换、故障转移)
  - 发送纯文本/HTML邮件
  - 发送带附件邮件(支持ZIP压缩)
  - 异步发送队列(多线程工作池)
  - 每日发送限额控制
  - 发送日志记录和统计

- 数据库表结构
  - smtp_configs: 多SMTP配置表
  - email_settings: 全局邮件设置
  - email_tokens: 邮件验证Token
  - email_logs: 邮件发送日志
  - email_stats: 邮件发送统计

- API接口
  - GET/POST /yuyx/api/email/settings: 全局邮件设置
  - CRUD /yuyx/api/smtp/configs: SMTP配置管理
  - POST /yuyx/api/smtp/configs/<id>/test: 测试SMTP连接
  - POST /yuyx/api/smtp/configs/<id>/primary: 设为主配置
  - GET /yuyx/api/email/stats: 邮件统计
  - GET /yuyx/api/email/logs: 邮件日志
  - POST /yuyx/api/email/logs/cleanup: 清理日志

- 后台管理页面
  - 新增"邮件配置"Tab
  - 全局邮件开关、故障转移开关
  - SMTP配置列表管理
  - 添加/编辑SMTP配置弹窗
  - 邮件发送统计展示
  - 邮件日志查询和清理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:38:28 +08:00
19174616018andClaude Opus 4.5 364566df99 修复用户和管理员session冲突问题
问题:在同一浏览器先登录用户再登录管理员时,用户会掉线
原因:管理员登录时使用session.clear()清除了整个session
修复:改为只清除管理员相关的session键,保留用户登录状态

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:41:53 +08:00
19174616018andClaude Opus 4.5 9bc4cc66fe 添加Pillow依赖用于验证码图片生成
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:26:54 +08:00
19174616018andClaude Opus 4.5 5c9696e65d 添加cryptography依赖和crypto_utils.py到Docker构建
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:09:22 +08:00
19174616018andClaude Opus 4.5 acb22cf96b 修复12项安全漏洞和代码质量问题
安全修复:
- 使用secrets替代random生成验证码,提升安全性
- 添加内存清理调度器,防止内存泄漏
- PIL缺失时返回503而非降级服务
- 改进会话安全配置,支持环境自动检测
- 密钥文件路径支持环境变量配置

Bug修复:
- 改进异常处理,不再吞掉SystemExit/KeyboardInterrupt
- 清理死代码(if False占位符)
- 改进浏览器资源释放逻辑,使用try-finally确保关闭
- 重构数据库连接池归还逻辑,修复竞态条件
- 添加安全的JSON解析方法,处理损坏数据
- 日志级别默认值改为INFO
- 提取魔法数字为可配置常量

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:00:19 +08:00
19174616018andClaude Opus 4.5 126d4cbb52 修复37项安全漏洞和Bug
高危修复:
- app.py: 添加ip_rate_limit_lock线程锁保护IP限流字典
- app.py: 添加validate_ip_port()验证代理IP/端口范围
- database.py: SQL字段名白名单验证防止注入
- playwright_automation.py: 改进浏览器进程强制清理逻辑

中危修复:
- database.py: 统一时区处理函数get_cst_now()
- database.py: 消除循环导入,移动app_security导入到顶部
- playwright_automation.py: 所有bare except改为except Exception
- app_config.py: dotenv导入失败警告+安全配置检查
- db_pool.py: 添加详细异常堆栈日志
- app_security.py: 用户名过滤零宽字符
- database.py: delete_old_task_logs分批删除避免锁表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 19:35:29 +08:00
19174616018andClaude Opus 4.5 a25c9fbba0 修复多项安全漏洞和Bug
1. 安全修复:
   - 修复密码重置接口用户枚举漏洞,统一返回消息防止信息泄露
   - 统一密码强度验证为8位以上且包含字母和数字
   - 添加第三方账号密码加密存储(Fernet对称加密)
   - 修复默认管理员弱密码问题,改用随机生成强密码
   - 修复管理员回复XSS漏洞,添加HTML转义
   - 将MD5哈希替换为SHA256

2. 并发Bug修复:
   - 修复日志缓存竞态条件,添加锁保护
   - 修复截图信号量配置变更后不生效问题

3. 其他改进:
   - 添加API参数类型验证和边界检查
   - 新增crypto_utils.py加密工具模块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 19:14:14 +08:00
19174616018andClaude Opus 4.5 70cd95c366 修复多项安全漏洞
安全修复清单:
1. 验证码改为图片方式返回,防止明文泄露
2. CORS配置从环境变量读取,不再使用通配符"*"
3. VIP API添加@admin_required装饰器,统一认证
4. 用户登录统一错误消息,防止用户枚举
5. IP限流不再信任X-Forwarded-For头,防止伪造绕过
6. 密码强度要求提升(8位+字母+数字)
7. 日志不���记录完整session/cookie内容,防止敏感信息泄露
8. XSS防护:日志输出和Bug反馈内容转义HTML
9. SQL注入防护:LIKE查询参数转义
10. 路径遍历防护:截图目录白名单验证
11. 验证码重放防护:验证前删除验证码
12. 数据库连接池健康检查
13. 正则DoS防护:限制数字匹配长度
14. Account类密码私有化,__repr__不暴露密码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 17:53:48 +08:00
19174616018andClaude Opus 4.5 a4b7074634 修复代码质量问题(Bug #20, #1, #2)
修复的Bug:
- Bug #20: 删除重复函数定义 (update_admin_username重复定义)
- Bug #1: 删除类型转换冗余 (APIBrowseResult->BrowseResult无意义转换)
- Bug #2: 统一时区处理

主要改进:
1. 删除重复函数:
   - 删除app.py:1023处重复的update_admin_username函数定义
   - 避免函数覆盖导致的潜在问题

2. 删除冗余类型转换:
   - APIBrowseResult和BrowseResult字段完全相同
   - 删除run_task中的冗余转换代码
   - 提升性能,减少维护成本

3. 统一时区处理:
   - 新增BEIJING_TZ常量和get_beijing_now()辅助函数
   - 替换所有分散的beijing_tz创建为统一函数调用
   - 删除重复的datetime/pytz导入
   - 涉及6处代码统一:
     * log_to_client - 日志时间戳
     * take_screenshot_for_account - 截图文件名
     * get_screenshots - 文件修改时间
     * run_scheduled_task - 定时任务星期检查
     * check_user_schedules - 用户定时任务检查
     * get_server_info_api - 服务器运行时长

影响:
- 提升代码质量和可维护性
- 统一时区处理,避免时间混淆
- 减少内存占用和CPU开销

受影响文件:
- app.py (所有修复)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 14:25:08 +08:00
19174616018andClaude Opus 4.5 716111e7e6 修复关键安全漏洞(Bug #19和Bug #13)
修复的Bug:
- Bug #19: 路径遍历漏洞
- Bug #13: 浏览器上下文竞态条件

主要改进:
1. 路径遍历防护:
   - /screenshots/<filename> 端点添加is_safe_path()验证
   - /static/<path:filename> 端点添加is_safe_path()验证
   - 防止攻击者通过../等序列访问系统文件

2. 浏览器资源并发保护:
   - PlaywrightAutomation类添加_lock线程锁
   - get_iframe_safe()方法使用锁保护main_page访问
   - close()方法使用锁保护资源释放
   - _cleanup_on_exit()使用非阻塞锁避免退出死锁
   - 解决TOCTOU(Time-of-Check-Time-of-Use)竞态条件

影响:
- 防止路径遍历攻击,保护系统文件安全
- 防止多线程环境下的浏览器资源竞争
- 提升系统安全性和稳定性

受影响文件:
- app.py (路径验证)
- playwright_automation.py (线程锁)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 14:12:01 +08:00
19174616018andClaude Opus 4.5 c1a65debbc 修复所有资源泄漏问题(P0级bug)
修复的Bug:
- Bug #21: Playwright浏览器实例泄漏
- Bug #22: 数据库连接泄漏(已由连接池解决)
- Bug #23: 截图文件句柄泄漏
- Bug #24: 线程资源未清理
- Bug #25: requests.Session对象泄漏

主要改进:
1. PlaywrightAutomation类:
   - 添加atexit注册,确保进程退出时关闭浏览器
   - 添加__enter__/__exit__支持context manager
   - 添加_closed标志防止重复关闭
   - 添加_cleanup_on_exit静默清理方法

2. APIBrowser类:
   - 添加atexit注册,确保Session正确关闭
   - 添加__enter__/__exit__支持context manager
   - 添加_closed标志防止重复关闭

3. 截图功能增强:
   - 使用临时文件机制
   - 添加文件大小验证
   - 失败时自动清理临时文件
   - 确保不产生垃圾文件

4. 应用关闭清理:
   - 添加cleanup_on_exit()函数
   - 注册SIGINT/SIGTERM信号处理器
   - 停止所有运行中的任务
   - 等待线程优雅退出
   - 关闭浏览器线程池
   - 关闭数据库连接池

影响:
- 防止长期运行导致的内存泄漏
- 确保进程异常退出时正确清理资源
- 提升系统稳定性和可靠性

受影响文件:
- playwright_automation.py
- api_browser.py
- app.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 13:48:06 +08:00
19174616018andClaude Opus 4.5 481f4dfbac 彻底修复移动端按钮溢出和布局问题
主要改进:
- 添加overflow-x: hidden防止水平滚动
- Header按钮缩小字体和padding,确保不溢出
- 工具栏改为垂直布局,按钮支持flex换行
- 所有按钮添加white-space: nowrap防止内部换行
- 账号卡片操作区域改为垂直布局
- 模态窗口按钮改为100%宽度垂直堆叠
- 表格容器添加横向滚动支持
- 优化所有flex容器的flex-wrap属性
- 减小移动端所有元素的padding和font-size
- 确保所有输入框和选择框width: 100%

受影响文件:
- templates/index.html - 全面优化移动端布局
- templates/admin.html - 全面优化管理后台移动端布局

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 13:21:17 +08:00
19174616018andClaude Opus 4.5 706e80e286 优化移动端UI布局,修复按钮和内容超出屏幕问题
主要改进:
- 优化header布局,支持flex-wrap防止内容溢出
- 改进工具栏和按钮组的响应式布局
- 优化表格在移动端的显示(添加横向滚动)
- 调整模态窗口在小屏幕上的尺寸和间距
- 优化表单输入框字体大小(防止iOS自动缩放)
- 改进统计卡片、导航栏等组件的移动端适配
- 为登录/注册页面添加移动端优化样式

受影响文件:
- templates/index.html - 用户主界面移动端优化
- templates/admin.html - 管理员界面移动端优化
- templates/login.html - 登录页面移动端优化
- templates/register.html - 注册页面移动端优化
- templates/admin_login.html - 管理员登录页面移动端优化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 12:38:18 +08:00
237899745 184e46cc1e 删除 DEPLOYMENT_SUMMARY.md 2025-12-10 22:40:05 +08:00
237899745 900c5a8a9f 删除 DEPLOYMENT_GUIDE.md 2025-12-10 22:39:48 +08:00
237899745 1836b26f64 删除 最终修复总结.md 2025-12-10 22:39:20 +08:00
237899745 891b0d5729 删除 功能验证清单.md 2025-12-10 22:39:15 +08:00
237899745 9701d8b14f 删除 BUG修复报告_20251210.md 2025-12-10 22:39:08 +08:00
19174616018andClaude Opus 4.5 2af52b8f6c 🐛 修复定时任务执行耗时显示为0的问题
- 启动监控线程等待所有任务完成后再更新日志
- 通过参数传递避免闭包问题
- 耗时现在正确反映实际任务执行时间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 22:09:30 +08:00
19174616018andClaude Opus 4.5 696fcfa85d 添加注册自动审核功能
- 系统配置新增:自动审核开关、每小时注册限制、赠送VIP天数
- 数据库:添加 auto_approve_enabled, auto_approve_hourly_limit, auto_approve_vip_days 字段
- 后端API:支持保存和读取自动审核配置
- 管理后台:新增注册自动审核配置区域(绿色背景)
- 注册逻辑:支持自动审核通过并赠送VIP

功能说明:
1. 启用自动审核后,新用户注册自动通过,无需管理员审批
2. 每小时注册限制防止恶意注册
3. 可配置注册赠送VIP天数(设为0则不赠送)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 21:42:28 +08:00
19174616018andClaude Opus 4.5 4a72c76c11 🔧 完善system_config初始化字段
- INSERT 语句补全所有字段,确保与 CREATE TABLE 定义一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 21:03:11 +08:00
19174616018andClaude Opus 4.5 e4024e4706 🐛 修复截图并发数初始化遗漏字段
- 初始化system_config时添加max_screenshot_concurrent字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:59:03 +08:00
19174616018andClaude Opus 4.5 74079e3f08 🎨 减小右上角元素间距为8px
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:52:38 +08:00
19174616018andClaude Opus 4.5 a58ace97c5 🎨 统一右上角所有元素间距
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:48:28 +08:00
19174616018andClaude Opus 4.5 349e329373 🎨 统一右上角按钮间距
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:42:23 +08:00
19174616018andClaude Opus 4.5 83eff15a65 🔧 修改密码按钮移至右上角
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:40:01 +08:00
19174616018andClaude Opus 4.5 8cb2a684e3 添加用户修改密码功能
- 左边栏添加修改密码按钮
- 添加修改密码弹窗
- 添加 /api/user/password API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:37:27 +08:00
19174616018andClaude Opus 4.5 a8e8bbe8a2 优化浏览器池和并发配置
1. 浏览器池改为按需启动模式
   - 启动时不创建浏览器,有截图任务时才启动
   - 空闲5分钟后自动关闭浏览器释放资源

2. 修复截图并发数保存问题
   - 修复database.py中缺少保存max_screenshot_concurrent的代码

3. 去掉并发数上限限制
   - 管理员可自由设置并发数,不再限制1-20/1-5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:31:49 +08:00
19174616018andClaude Opus 4.5 f46c662fe5 添加定时任务日志管理功能
- 添加用户清空日志按钮
- 添加30天自动清理定时任务执行日志
- 简化日志API代码,移除调试日志

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:20:44 +08:00
19174616018andClaude Opus 4.5 7b0ecb03bd 🐛 修复定时任务日志状态显示
- 状态检查同时支持success和completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:15:05 +08:00
19174616018andClaude Opus 4.5 f366a108a0 🧹 清理更多调试日志
- 移除Socket连接/断开/错误日志
- 移除账号加载日志
- 移除定时任务时间标准化日志

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:13:31 +08:00
19174616018andClaude Opus 4.5 2e7dc95d55 🧹 清理调试日志
- 移除日志弹窗调试日志
- 移除openModal调试日志
- 移除账号API调试日志
- 移除账号加载调试日志

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:11:18 +08:00
19174616018andClaude Opus 4.5 fc7ef70259 🐛 修复日志弹窗无法显示问题
- 修复scheduleModal缺少关闭标签导致scheduleLogsModal被嵌套
- 移除多余的</div>标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:08:04 +08:00
19174616018andClaude Opus 4.5 2d648bac58 🐛 修复周日期选择器无法点击问题
- 移除CSS :has()选择器(兼容性问题)
- 改用JavaScript控制.selected类
- 添加点击事件处理器

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:04:59 +08:00
19174616018andClaude Opus 4.5 551e354aa3 🔍 增强账号API诊断日志
添加更详细的日志输出,帮助排查问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:02:13 +08:00
19174616018andClaude Opus 4.5 8b0a9a6fcf 🔍 添加账号加载诊断日志
帮助排查容器重启后账号丢失的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:56 +08:00
19174616018andClaude Opus 4.5 3e0bd4cf91 🔇 减少定时任务调试日志输出
- 移除每5秒的时间检查详细日志
- 移除星期/时间匹配的调试日志
- 移除账号ID类型和user_accounts内容的调试日志
- 保留关键的执行状态和错误日志

日志从45行减少到4行,降低服务器资源占用。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:45:33 +08:00
19174616018andClaude Opus 4.5 226d6724bd 🐛 修复定时任务日志和执行问题
- 修复日志按钮点击无反应(类型匹配和错误处理)
- 修复定时任务到时间不执行(时间格式标准化)
- 增强所有检查点的调试日志输出
- 改进今日执行检查逻辑(支持同一天不同时间执行)

Bug 1: 日志按钮完全没反应
- 将严格相等(===)改为宽松相等(==)避免类型不匹配
- 添加详细的错误日志和用户提示
- 添加容器元素存在性检查

Bug 2: 定时任务到时间不会被执行
- 标准化时间格式(8:00 -> 08:00)
- 增强所有检查点的日志输出(时间、星期、账号)
- 改进今日执行检查(同一天不同时间可再次执行)
- 前端添加时间格式验证和标准化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:12:48 +08:00
237899745 8e5a5cb679 删除 交接文档.md 2025-12-10 18:51:19 +08:00
rootandClaude dab97c25e1 添加openModal调试日志
为了诊断弹窗不显示的问题,在openModal函数中添加:
1. 检查元素是否存在
2. 打印元素信息
3. 打印classList

位置: templates/index.html 第1697-1712行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 17:28:36 +08:00
rootandClaude 398cd5c9cf 🔥 紧急修复JavaScript语法错误
问题:第1340行有多余的引号导致语法错误
修复:删除多余的引号

position: templates/index.html:1340

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 17:20:37 +08:00
rootandClaude dc5f9e939b 修复定时任务日志弹窗显示问题
问题:点击日志按钮无反应

修复:
1. 重新格式化viewScheduleLogs函数(原本全部压缩成一行)
2. 添加详细的console.log调试日志
3. 每个步骤都有日志输出,便于调试

调试日志包括:
- 开始查看日志
- 找到任务
- API响应状态
- 收到的数据
- 打开弹窗

现在可以通过浏览器控制台查看完整的执行流程。

位置: templates/index.html viewScheduleLogs函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 17:14:08 +08:00
rootandClaude 653cbbc42c 彻底修复定时任务日志500错误
采用最保守的错误处理策略:

1. 外层、中层、内层三重try-except保护
2. 每个可能出错的步骤独立包装
3. 任何错误都返回空数组[],绝不抛出500错误
4. 每个步骤都有详细的调试日志

错误处理策略:
- 查询定时任务失败 → 返回[]
- 定时任务不存在 → 返回[]
- 权限检查失败 → 返回[]
- 查询日志失败 → 返回[]
- 任何未知错误 → 返回[]

这样可以保证即使有任何问题,前端也会看到
"暂无执行日志"而不是500错误。

位置: app.py 第3274-3323行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:53:59 +08:00
rootandClaude fb7a75e76e 强化定时任务日志查询的错误处理
添加多层错误保护:

1. 在整个查询函数外层添加try-except
2. 在每行数据处理时也添加try-except
3. 出错时返回空数组而不是抛出异常
4. 打印详细的错误信息和堆栈跟踪

这样即使数据有问题,也不会导致500错误,
而是会正常返回空列表并在日志中显示具体错误。

位置: database.py 第1661-1695行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:47:00 +08:00
rootandClaude cc4b69e631 添加定时任务日志API错误处理和调试日志
为了诊断500错误,在日志API中添加:

1. 完整的try-except错误捕获
2. 详细的调试日志输出:
   - 请求用户和任务ID
   - 权限检查结果
   - 查询过程和结果
   - 具体的错误信息和堆栈跟踪

3. 返回详细错误信息给前端(开发模式)

现在可以通过应用日志查看具体的500错误原因。

位置: app.py 第3274-3301行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:37:23 +08:00
rootandClaude b289d197a0 添加详细的定时任务调试日志
为了帮助诊断用户定时任务无法执行的问题,在每个检查点添加详细日志:

1. 任务ID和名称
2. 时间匹配检查结果
3. 星期匹配检查结果
4. 今日执行状态检查结果
5. 最终是否通过所有检查

现在可以通过应用日志清晰看到每个任务在哪个检查点被跳过。

位置: app.py 第2872-2906行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:27:13 +08:00
rootandClaude 3e54789ee4 修复定时任务日志500错误
问题原因:
- SQL查询中使用AS别名在SQLite Row对象转换时可能失败

修复方案:
- 改为查询所有字段后在Python中进行字段映射
- 添加字段映射:execute_time → created_at
- 添加字段映射:success_accounts → success_count
- 添加字段映射:failed_accounts → failed_count
- 添加字段映射:duration_seconds → duration

位置: database.py 第1661-1683行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:24:10 +08:00
rootandClaude c22e2f6a19 修复定时任务日志和增强功能
1. 修复定时任务日志字段映射问题
   - 修正execute_time → created_at
   - 修正success_accounts → success_count
   - 修正failed_accounts → failed_count
   - 修正duration_seconds → duration
   - 位置: database.py 第1661-1686行

2. 添加定时任务调试日志
   - 显示当前检查时间和任务匹配情况
   - 帮助诊断定时任务不执行问题
   - 位置: app.py 第2869-2875行

3. 新增VIP权限对比表格
   - 在VIP信息弹窗中添加权限对比
   - 对比普通用户和VIP用户的6项权限
   - 位置: templates/index.html 第549-593行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:16:45 +08:00
rootandClaude 97d1406e11 清理项目多余文件
删除以下文件:
- 8个修复脚本(fix_*.py)
- 5个app.py备份文件
- 7个templates备份文件
- 1个重复的admin.html文件

共删除21个临时文件和备份文件,保持项目整洁。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:57:39 +08:00
rootandClaude f9aa511806 修复三个关键Bug
1. 修复定时任务设置时间不执行问题
   - 将定时任务检查频率从60秒提升到5秒
   - 确保定时任务在设定时间准时执行
   - 位置: app.py 第3010-3011行

2. 修复账号管理卡片设置按钮无法点击问题
   - 修正JavaScript引号转义错误
   - 位置: templates/index.html 第886行

3. 修复用户反馈和后台反馈进度不同步问题
   - 前端改为检查status字段而非reply字段
   - 新增closed状态支持
   - 正确显示待处理/已回复/已关闭三种状态
   - 位置: templates/index.html 第249-251行, 第1550-1567行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:53:53 +08:00
Yu Yon b5344cd55e 修复所有bug并添加新功能
- 修复添加账号按钮无反应问题
- 添加账号备注字段(可选)
- 添加账号设置按钮(修改密码/备注)
- 修复用户反馈���能
- 添加定时任务执行日志
- 修复容器重启后账号加载问题
- 修复所有JavaScript语法错误
- 优化账号加载机制(4层保障)

🤖 Generated with Claude Code
2025-12-10 11:19:16 +08:00
Yu Yon 0fd7137cea Initial commit: 知识管理平台
主要功能:
- 多用户管理系统
- 浏览器自动化(Playwright)
- 任务编排和执行
- Docker容器化部署
- 数据持久化和日志管理

技术栈:
- Flask 3.0.0
- Playwright 1.40.0
- SQLite with connection pooling
- Docker + Docker Compose

部署说明详见README.md
2025-11-16 19:03:07 +08:00
298 changed files with 18794 additions and 43952 deletions
+1 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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/
+102 -250
View File
@@ -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支持
-24
View File
@@ -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?
-13
View File
@@ -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>
-1845
View File
File diff suppressed because it is too large Load Diff
-23
View File
@@ -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
View File
@@ -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

-3
View File
@@ -1,3 +0,0 @@
<template>
<RouterView />
</template>
-46
View File
@@ -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
}
-33
View File
@@ -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
}
-11
View File
@@ -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)
}
-46
View File
@@ -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,
}
}
-149
View File
@@ -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)
},
)
-33
View File
@@ -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
}
-40
View File
@@ -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
}
-17
View File
@@ -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
}
-17
View File
@@ -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
}
-63
View File
@@ -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
}
-36
View File
@@ -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
}
-17
View File
@@ -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()
}
-38
View File
@@ -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
}
-68
View File
@@ -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
}
-42
View File
@@ -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>
-418
View File
@@ -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>
-12
View File
@@ -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>
-924
View File
@@ -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>
-247
View File
@@ -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>
-294
View File
@@ -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
-836
View File
@@ -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>
-530
View File
@@ -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>
-900
View File
@@ -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">低于该阈值不会计入慢 SQL0 表示关闭慢 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-1000 表示不限制</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>
-342
View File
@@ -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>
-43
View File
@@ -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
-269
View File
@@ -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;
}
}
-29
View File
@@ -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
}
-13
View File
@@ -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: '' }
}
-43
View File
@@ -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 }
}
-32
View File
@@ -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'
},
},
},
},
})
+157 -492
View File
@@ -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,10 +126,10 @@ class APIBrowser:
def _get_aspnet_fields(self, soup): def _get_aspnet_fields(self, soup):
"""获取 ASP.NET 隐藏字段""" """获取 ASP.NET 隐藏字段"""
fields = {} fields = {}
for name in ["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"]: for name in ['__VIEWSTATE', '__VIEWSTATEGENERATOR', '__EVENTVALIDATION']:
field = soup.find("input", {"name": name}) field = soup.find('input', {'name': name})
if field: if field:
fields[name] = field.get("value", "") fields[name] = field.get('value', '')
return fields return fields
def get_real_name(self) -> Optional[str]: def get_real_name(self) -> Optional[str]:
@@ -338,18 +143,18 @@ class APIBrowser:
try: try:
url = f"{BASE_URL}/admin/center.aspx" url = f"{BASE_URL}/admin/center.aspx"
resp = self._request_with_retry("get", url) resp = self._request_with_retry('get', url)
soup = BeautifulSoup(resp.text, "html.parser") soup = BeautifulSoup(resp.text, 'html.parser')
# 查找包含"姓名:"的元素 # 查找包含"姓名:"的元素
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li> # 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
nlist = soup.find("div", {"class": "nlist-5"}) nlist = soup.find('div', {'class': 'nlist-5'})
if nlist: if nlist:
first_li = nlist.find("li") first_li = nlist.find('li')
if first_li: if first_li:
text = first_li.get_text() text = first_li.get_text()
# 解析姓名:格式为 "姓名:XXX(手机号)" # 解析姓名:格式为 "姓名:XXX(手机号)"
match = re.search(r"姓名[:]\s*([^\(]+)", text) match = re.search(r'姓名[:]\s*([^\(]+)', text)
if match: if match:
real_name = match.group(1).strip() real_name = match.group(1).strip()
if real_name: if real_name:
@@ -363,36 +168,37 @@ class APIBrowser:
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 +211,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 +317,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 +336,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 +427,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 +445,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
-5
View File
@@ -1,5 +0,0 @@
dist
node_modules
.DS_Store
.vite
-4
View File
@@ -1,4 +0,0 @@
# app-frontend
前台(用户端)Vue3 + Vite 工程,构建产物输出到 `static/app/`
-14
View File
@@ -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>
-13
View File
@@ -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>
-2476
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -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"
}
}
-6
View File
@@ -1,6 +0,0 @@
<script setup></script>
<template>
<RouterView />
</template>
-57
View File
@@ -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
}
-12
View File
@@ -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
}
-56
View File
@@ -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
}
-12
View File
@@ -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
}
-168
View File
@@ -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)
},
)
-41
View File
@@ -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
}
-16
View File
@@ -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
}
-61
View File
@@ -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
}
-7
View File
@@ -1,7 +0,0 @@
import { publicApi } from './http'
export async function fetchRunStats() {
const { data } = await publicApi.get('/run_stats')
return data
}
-12
View File
@@ -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>
-15
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
import { createApp } from 'vue'
import LoginPage from './pages/LoginPage.vue'
import './style.css'
createApp(LoginPage).mount('#app')
-10
View File
@@ -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
-939
View File
@@ -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>
-346
View File
@@ -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>
-703
View File
@@ -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>
-406
View File
@@ -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>
-157
View File
@@ -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>
-40
View File
@@ -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
-34
View File
@@ -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
}
},
},
})
-75
View File
@@ -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%;
}
}
-7
View File
@@ -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: '' }
}
-62
View File
@@ -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
},
},
},
},
})
Regular → Executable
+4635 -476
View File
File diff suppressed because it is too large Load Diff
+53 -206
View File
@@ -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_urlscheme://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环境使用NoneHTTP环境使用Lax # SameSite配置:HTTPS环境使用NoneHTTP环境使用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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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("✓ 工具模块加载成功!")
+214
View File
@@ -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)
+160
View File
@@ -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
View File
@@ -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
View File
@@ -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