Compare commits

...

10 Commits

Author SHA1 Message Date
Jowe
3c114cdf0b docs: 保存2026-02-23开发进度记录 2026-02-23 23:34:42 +08:00
Jowe
b22627a066 chore: 调整发布策略,移除SSH直连权限
改为本地开发 → push到Gitea → 手动SSH拉取的安全部署流程
禁止Claude直接操作生产服务器

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:26:28 +08:00
Jowe
a9a4f5f8e8 feat: 添加日常更新部署脚本 update.sh
git pull 拉取代码后自动 systemctl restart zjpb,并验证服务状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:16:28 +08:00
Jowe
1ddd8664ae fix: 后台网站管理列表改为按创建时间倒序排列
最新添加的网站排在最前面,与新闻管理保持一致

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:03:51 +08:00
Jowe
2e31d2bfd6 fix: 修复前台获取新闻时验证码验证失败的问题
问题原因:fetch请求缺少credentials选项,导致浏览器不发送session cookie
解决方案:添加credentials: 'same-origin'确保发送session cookie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:19:34 +08:00
Jowe
1118db4837 fix: 修复NewsAdmin的joinedload导致计数查询报错
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:11:05 +08:00
Jowe
3fdbc2ac8e perf: 优化管理后台性能 - 修复N+1查询、添加索引、优化统计
- 修复NewsAdmin的N+1查询问题,使用joinedload预加载
- 添加数据库索引迁移脚本(Site、News、Tag表)
- 优化管理后台统计查询,减少数据传输

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:00:35 +08:00
Jowe
03bf1c3de7 docs: 添加v3.2开发进度文档
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 23:32:38 +08:00
Jowe
2eefaa8cc9 feat: v3.2 - 用户管理功能和后台菜单统一
新增功能:
- 用户管理列表页面(搜索、分页)
- 用户详情页面(基本信息、收藏统计)
- 管理员重置用户密码功能
- 管理员修改用户昵称功能
- 管理后台首页添加用户统计卡片

优化改进:
- 统一后台菜单结构,创建可复用的 sidebar 组件
- 所有后台页面使用统一菜单,避免硬编码
- 优化权限配置文件,清理冗余规则

技术文档:
- 添加任务分解规则文档
- 添加后台菜单统一规则文档
- 添加数据库字段修复脚本

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 23:20:35 +08:00
Jowe
c61969dfc9 feat: v3.1 - 用户密码管理和邮箱验证功能
新增功能:
1. 修改密码功能
   - 用户可以修改自己的密码
   - 需要验证旧密码
   - 新密码至少6位且不能与旧密码相同

2. 邮箱绑定功能
   - 用户可以绑定/修改邮箱
   - 邮箱格式验证和唯一性检查
   - 修改邮箱后需要重新验证

3. 邮箱验证功能
   - 发送验证邮件(24小时有效)
   - 点击邮件链接完成验证
   - 验证状态显示

技术实现:
- 新增4个数据库字段(email_verified等)
- 封装邮件发送工具(utils/email_sender.py)
- 新增5个API接口
- 新增修改密码页面
- 集成邮箱管理到个人中心

文件变更:
- 修改:app.py, models.py, base_new.html, profile.html
- 新增:change_password.html, email_sender.py, migrate_email_verification.py
- 文档:server-update.md, SERVER_RESTART_GUIDE.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 23:26:02 +08:00
27 changed files with 3973 additions and 332 deletions

120
.claude/admin-menu-rules.md Normal file
View File

@@ -0,0 +1,120 @@
# 后台管理菜单统一规则
## 📋 规则说明
所有后台管理页面必须使用统一的菜单结构,不允许硬编码不同的菜单。
---
## 🎯 菜单结构
### 主菜单(按顺序)
1. **控制台** - `{{ url_for('admin.index') }}`
2. **网站管理** - `{{ url_for('site.index_view') }}`
3. **标签管理** - `{{ url_for('tag.index_view') }}`
4. **新闻管理** - `{{ url_for('news.index_view') }}`
5. **Prompt管理** - `{{ url_for('prompttemplate.index_view') }}`
6. **管理员** - `{{ url_for('admin_users.index_view') }}`
### 系统菜单(按顺序)
1. **用户管理** - `{{ url_for('admin_users') }}`
2. **SEO工具** - `{{ url_for('seo_tools') }}`
3. **批量导入** - `{{ url_for('batch_import') }}`
4. **修改密码** - `{{ url_for('change_password') }}`
5. **查看网站** - `{{ url_for('index') }}` (target="_blank")
6. **退出登录** - `{{ url_for('admin_logout') }}`
---
## 📝 实现方式
### 方式1使用 admin/master.html推荐
对于新增的后台页面,应该继承 `admin/master.html`
```jinja2
{% extends 'admin/master.html' %}
{% block body %}
<!-- 页面内容 -->
{% endblock %}
```
**注意**:需要在路由中传递 `admin_view` 对象。
### 方式2使用统一菜单组件
对于独立HTML页面使用 `{% include %}` 引入统一菜单组件:
```jinja2
{% set active_page = 'page_name' %}
{% include 'admin/components/sidebar.html' %}
```
**注意**
- `active_page` 变量用于标记当前激活的菜单项
- 统一菜单组件位于 `templates/admin/components/sidebar.html`
- 禁止复制粘贴菜单代码,必须使用 include 方式
---
## ⚠️ 重要提醒
1. **禁止删减菜单项** - 所有页面必须显示完整菜单
2. **禁止修改顺序** - 菜单顺序必须一致
3. **禁止硬编码菜单** - 必须使用统一组件 `admin/components/sidebar.html`
4. **新增菜单项** - 只需在统一组件中添加,所有页面自动生效
5. **endpoint 名称** - 必须使用正确的 Flask-Admin endpoint
---
## 🔍 检查清单
添加新后台页面时,必须检查:
- [ ] 主菜单包含6个项目
- [ ] 系统菜单包含6个项目
- [ ] endpoint 名称正确
- [ ] 图标使用 Material Symbols
- [ ] 当前页面有 `active`
---
## 📂 涉及的文件
**统一菜单组件:**
- `templates/admin/components/sidebar.html` - 唯一的菜单源文件
**使用统一菜单的页面:**
- `templates/admin/master.html` - Flask-Admin 页面基础模板
- `templates/admin/batch_import.html` - 批量导入页面
- `templates/admin/change_password.html` - 修改密码页面
- `templates/admin/seo_tools.html` - SEO工具页面
- `templates/admin/users/list.html` - 用户列表页面
- `templates/admin/users/detail.html` - 用户详情页面
---
## 🛠️ 维护指南
### 添加新菜单项
1.`templates/admin/components/sidebar.html` 中添加
2. 更新本文档
3. 所有使用该组件的页面自动生效
### 删除菜单项
1.`templates/admin/components/sidebar.html` 中删除
2. 更新本文档
3. 所有使用该组件的页面自动生效
### 修改菜单顺序
1.`templates/admin/components/sidebar.html` 中调整
2. 更新本文档
3. 所有使用该组件的页面自动生效
### 新增后台页面
1. 如果是 Flask-Admin 页面,继承 `admin/master.html`
2. 如果是独立页面,使用 `{% include 'admin/components/sidebar.html' %}`
3. 设置 `active_page` 变量标记当前页面
---
**最后更新**: 2025-02-08
**维护人**: Claude Sonnet 4.5

View File

@@ -1,49 +1,36 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(if [ -d \".git\" ])",
"Bash(then echo \"Git repository exists\")",
"Bash(else echo \"No git repository\")",
"Bash(fi)",
"Bash(python:*)",
"Bash(python3:*)",
"Bash(py test_db.py:*)",
"Bash(where:*)",
"Bash(/c/Users/linha/AppData/Local/Microsoft/WindowsApps/python test_db.py)",
"Bash(pip install:*)",
"Bash(pip uninstall:*)",
"Bash(tasklist:*)",
"Bash(findstr:*)",
"Bash(dir:*)",
"Bash(git init:*)", "Bash(git init:*)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(curl:*)", "Bash(git push:*)",
"WebFetch(domain:zjpb.net)", "Bash(git pull:*)",
"Bash(del import_bookmarks.py test_bookmark_parse.py test_simple_parse.py result.txt)", "Bash(git checkout:*)",
"Bash(git status:*)",
"Bash(git tag:*)", "Bash(git tag:*)",
"Bash(if [ -f .env ])", "Bash(git config:*)",
"Bash(then echo \"exists\")",
"Bash(else echo \"not exists\")",
"Bash(timeout /t 3 /nobreak)",
"Bash(ping:*)",
"Bash(git diff-tree:*)", "Bash(git diff-tree:*)",
"Bash(git format-patch:*)", "Bash(git format-patch:*)",
"WebFetch(domain:bocha-ai.feishu.cn)", "Bash(git log:*)",
"Bash(git diff:*)",
"Bash(python:*)",
"Bash(python3:*)",
"Bash(pip install:*)",
"Bash(pip uninstall:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(git pull:*)", "Bash(dir:*)",
"Bash(del nul)", "WebFetch(domain:zjpb.net)",
"Bash(git checkout:*)", "WebFetch(domain:bocha-ai.feishu.cn)"
"Bash(git push:*)", ],
"Bash(netstat:*)", "deny": [
"Bash(git config:*)",
"Bash(taskkill:*)",
"Bash(cmd /c:*)",
"Bash(powershell:*)",
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(start:*)", "Bash(scp:*)",
"Bash(git status --porcelain=v1)", "Bash(sftp:*)",
"Bash(timeout 3 cmd:*)" "Bash(curl:*)",
"Bash(wget:*)",
"Bash(cmd /c:*)",
"Bash(powershell:*)"
] ]
} }
} }

View File

@@ -0,0 +1,169 @@
# ZJPB 服务器更新标准流程
## 适用场景
- 代码更新后需要部署到服务器
- 数据库结构变更需要迁移
- 新功能上线
## 服务器环境
- **部署方式**: 1Panel 管理
- **项目路径**: `/opt/1panel/apps/zjpb`
- **运行方式**: Python 直接运行或 Gunicorn
- **数据库**: MySQL/MariaDB
---
## 标准更新流程
### 第一步:提交代码到 Git
```bash
# 在本地开发环境
git add .
git commit -m "feat: 功能描述"
git push origin master
```
### 第二步:登录服务器
```bash
ssh your_username@server_ip
```
### 第三步:进入项目目录
```bash
cd /opt/1panel/apps/zjpb
```
### 第四步:停止应用
```bash
# 查找进程
ps aux | grep python | grep -v grep
# 停止进程使用进程ID
kill <PID>
# 或者强制停止
pkill -f "python app.py"
```
### 第五步:拉取最新代码
```bash
git pull origin master
```
### 第六步:激活虚拟环境
```bash
source venv/bin/activate
```
### 第七步:安装/更新依赖(如有)
```bash
pip install -r requirements.txt
```
### 第八步:运行数据库迁移(如有)
```bash
# 根据具体的迁移脚本名称
python migrate_xxx.py
```
### 第九步:启动应用
```bash
# 后台运行
nohup python app.py > app.log 2>&1 &
# 或使用 Gunicorn
nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 &
```
### 第十步:验证启动成功
```bash
# 检查进程
ps aux | grep python | grep -v grep
# 查看日志
tail -f app.log
# 测试访问
curl http://localhost:5000/
```
---
## 快速命令(一键执行)
```bash
# 进入目录并更新
cd /opt/1panel/apps/zjpb && \
pkill -f "python app.py" && \
git pull origin master && \
source venv/bin/activate && \
nohup python app.py > app.log 2>&1 & && \
tail -f app.log
```
---
## 注意事项
1. **数据库迁移前必须备份**
```bash
mysqldump -u root -p zjpb > backup_$(date +%Y%m%d_%H%M%S).sql
```
2. **检查环境变量配置**
- 确保 `.env` 文件存在且配置正确
- 新增的环境变量需要手动添加
3. **日志监控**
- 启动后观察日志至少 1-2 分钟
- 确认没有错误信息
4. **回滚方案**
```bash
# 如果出现问题,回滚到上一个版本
git reset --hard HEAD~1
# 重启应用
```
---
## 常见问题
### 问题1端口被占用
```bash
# 查找占用端口的进程
lsof -i :5000
# 杀死进程
kill -9 <PID>
```
### 问题2虚拟环境找不到
```bash
# 重新创建虚拟环境
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### 问题3数据库连接失败
```bash
# 检查数据库服务状态
systemctl status mysql
# 检查 .env 配置
cat .env | grep DATABASE
```
---
**创建日期**: 2025-02-07
**最后更新**: 2025-02-07

View File

@@ -0,0 +1,168 @@
# 任务分解规则
## 📋 规则说明
在接到新任务后,必须将主任务分解成合适的子任务,通过分步执行的方式来保障开发的稳定性和准确性。
---
## 🎯 核心原则
1. **先分解,后执行** - 不要直接开始编码,先进行任务分解
2. **小步快跑** - 每个子任务应该是独立、可测试的小单元
3. **逐步验证** - 完成一个子任务后,验证无误再进行下一个
4. **降低风险** - 避免一次性修改过多文件导致的连锁错误
---
## 📝 任务分解流程
### 第一步:理解需求
- 仔细阅读用户的需求描述
- 明确功能目标和验收标准
- 识别涉及的技术栈和文件
### 第二步:分解任务
将主任务分解为 3-8 个子任务,每个子任务应该:
- **独立性** - 可以独立完成和测试
- **原子性** - 只做一件事情
- **可验证** - 有明确的完成标准
- **有序性** - 按照依赖关系排序
### 第三步:列出任务清单
使用 TaskCreate 工具创建任务清单,包括:
- 任务标题(简短、动词开头)
- 详细描述(包含具体要做什么)
- 预期结果(如何验证完成)
### 第四步:逐个执行
- 使用 TaskUpdate 标记任务为 in_progress
- 完成后标记为 completed
- 遇到问题及时反馈,不要继续下一个任务
---
## ✅ 良好的任务分解示例
**主任务:** 添加用户管理功能
**子任务分解:**
1. 创建用户列表页面路由和模板
2. 创建用户详情页面路由和模板
3. 实现重置密码 API 接口
4. 实现修改用户名 API 接口
5. 在管理后台首页添加用户统计
6. 在管理后台导航添加用户管理入口
---
## ❌ 不良的任务分解示例
**错误示例1任务过大**
- ❌ "完成用户管理功能" - 太笼统,无法独立验证
**错误示例2任务过细**
- ❌ "创建 user_list.html 文件"
- ❌ "在 user_list.html 中添加表格"
- ❌ "在表格中添加用户名列"
- 这样分解过于琐碎,失去了任务管理的意义
**错误示例3任务无序**
- ❌ 先做"添加导航入口",后做"创建页面路由"
- 应该先有功能,再添加入口
---
## 🔍 任务粒度参考
### 合适的任务粒度:
- 创建一个完整的页面(路由 + 模板 + 样式)
- 实现一个 API 接口(路由 + 逻辑 + 错误处理)
- 添加一个数据库表(模型 + 迁移脚本)
- 实现一个完整的功能模块(前端 + 后端)
### 任务太大的信号:
- 需要修改超过 5 个文件
- 预计耗时超过 30 分钟
- 包含多个不相关的功能点
- 难以用一句话描述清楚
### 任务太小的信号:
- 只修改几行代码
- 无法独立测试
- 必须和其他任务一起才有意义
---
## 🛠️ 使用工具
### TaskCreate - 创建任务
```
subject: "创建用户列表页面"
description: "创建 /admin/users 路由,实现用户列表展示,包括搜索和分页功能"
activeForm: "创建用户列表页面"
```
### TaskUpdate - 更新任务状态
```
taskId: "1"
status: "in_progress" # 开始工作时
```
```
taskId: "1"
status: "completed" # 完成后
```
### TaskList - 查看任务列表
定期查看任务列表,了解整体进度
---
## 📊 任务分解模板
### 模板1新增功能页面
1. 创建后端路由和数据查询逻辑
2. 创建前端页面模板
3. 添加页面样式和交互
4. 在导航菜单中添加入口
5. 测试功能完整性
### 模板2API 接口开发
1. 设计 API 接口规范URL、参数、返回值
2. 实现后端路由和业务逻辑
3. 添加参数验证和错误处理
4. 实现前端调用逻辑
5. 测试接口功能
### 模板3数据库变更
1. 设计数据库表结构
2. 创建数据库迁移脚本
3. 更新 ORM 模型定义
4. 运行迁移并验证
5. 更新相关业务逻辑
---
## ⚠️ 注意事项
1. **不要跳过分解步骤** - 即使任务看起来简单,也要先分解
2. **及时调整计划** - 如果发现任务分解不合理,及时调整
3. **记录遇到的问题** - 在任务描述中记录遇到的问题和解决方案
4. **保持沟通** - 遇到不确定的地方,及时向用户确认
---
## 🎯 预期效果
通过任务分解,可以达到:
- ✅ 降低开发风险,减少大规模返工
- ✅ 提高代码质量,每个子任务都经过验证
- ✅ 便于进度跟踪,用户可以看到实时进展
- ✅ 提升开发效率,问题可以及早发现和解决
---
**创建日期**: 2025-02-08
**最后更新**: 2025-02-08
**维护人**: Claude Sonnet 4.5

229
PROGRESS_2025-02-07.md Normal file
View File

@@ -0,0 +1,229 @@
# ZJPB 开发进度记录 - 2025-02-07
## 📅 开发日期
2025年2月7日
---
## ✅ 今天完成的功能
### 1. 修改密码功能
- **后端API**: `PUT /api/user/change-password`
- 验证旧密码
- 检查新密码长度至少6位
- 确保新旧密码不同
- **前端页面**: `templates/user/change_password.html`
- 密码可见性切换
- 实时表单验证
- AJAX提交
- **导航入口**:
- 用户菜单base_new.html
- 个人中心侧边栏profile.html
### 2. 邮箱绑定功能
- **后端API**: `PUT /api/user/email`
- 邮箱格式验证(正则表达式)
- 唯一性检查(防止重复绑定)
- 修改邮箱后重置验证状态
- **前端界面**: 集成到 `templates/user/profile.html`
- 邮箱管理模块
- 弹窗式编辑
- 验证状态显示
### 3. 邮箱验证功能
- **数据库字段**: 在 User 模型新增4个字段
- `email_verified` (Boolean) - 是否已验证
- `email_verified_at` (DateTime) - 验证时间
- `email_verify_token` (String) - 验证令牌
- `email_verify_token_expires` (DateTime) - 令牌过期时间
- **邮件工具**: `utils/email_sender.py`
- SMTP邮件发送
- HTML邮件模板
- 错误处理和日志
- **验证流程API**:
- `POST /api/user/send-verify-email` - 发送验证邮件
- `GET /verify-email/<token>` - 验证邮箱链接
- 令牌24小时有效期
- **前端界面**: 集成到个人中心
- 验证状态徽章
- 发送验证邮件按钮
### 4. 项目文档
- **服务器更新流程**: `.claude/skills/server-update.md`
- **服务器重启指南**: `SERVER_RESTART_GUIDE.md`
- **数据库迁移脚本**: `migrate_email_verification.py`
---
## 📦 代码提交记录
**提交ID**: c61969d
**分支**: master
**提交信息**: feat: v3.1 - 用户密码管理和邮箱验证功能
**变更文件**:
- 修改: app.py, models.py, templates/base_new.html, templates/user/profile.html
- 新增: templates/user/change_password.html, utils/email_sender.py, migrate_email_verification.py
- 文档: .claude/skills/server-update.md, SERVER_RESTART_GUIDE.md
**代码统计**:
- 9 个文件变更
- 1242 行新增
- 1 行删除
---
## 🚀 部署状态
### 已完成
- ✅ 代码已推送到 Gitea
- ✅ 服务器已拉取最新代码
- ✅ 数据库迁移已执行
- ✅ 邮件环境变量已配置
- ✅ 应用已重启
### 服务器信息
- **服务器地址**: server.zjpb.net
- **项目路径**: /opt/1panel/apps/zjpb
- **运行方式**: python app.py (临时)
- **监听端口**: 5000
- **进程ID**: 1644430, 1644432
### 环境变量配置
已在 `.env` 文件添加:
```
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=配置完成
SMTP_PASSWORD=配置完成
FROM_EMAIL=配置完成
FROM_NAME=ZJPB
```
---
## ⚠️ 待优化事项
### 高优先级
1. **改回 Gunicorn 启动** (生产环境推荐)
```bash
pkill -f "python app.py"
nohup gunicorn -c gunicorn_config.py wsgi:app --daemon
```
2. **更新 server-update.md** 文档
- 记录 Gunicorn 启动方式
- 补充邮件配置说明
### 中优先级
3. **安全性增强** (后续版本)
- 登录失败次数限制
- 密码强度检查(大小写+数字+特殊字符)
- CSRF 保护
- 会话安全配置
4. **功能完善**
- 密码重置功能(忘记密码)
- 两因素认证2FA
- 登录日志审计
---
## 📋 下次开发建议
### 可选方向1: 安全性加固
- 实现登录失败限制
- 添加 CSRF 保护
- 增强密码复杂性要求
- 配置安全的 Cookie 属性
### 可选方向2: 功能扩展
- 密码重置功能
- 用户头像上传
- 账户安全中心
- 登录设备管理
### 可选方向3: 用户体验优化
- 前端密码强度提示
- 邮箱可用性实时检查
- 更友好的错误提示
- 社交账号登录OAuth
---
## 🔧 技术细节
### API 接口清单
```
# 密码管理
PUT /api/user/change-password 修改密码
GET /user/change-password 修改密码页面
# 邮箱管理
PUT /api/user/email 更新邮箱
POST /api/user/send-verify-email 发送验证邮件
GET /verify-email/<token> 验证邮箱
```
### 数据库模型变更
```python
# User 模型新增字段
email_verified = db.Column(db.Boolean, default=False)
email_verified_at = db.Column(db.DateTime)
email_verify_token = db.Column(db.String(100))
email_verify_token_expires = db.Column(db.DateTime)
```
### 核心依赖
- Flask-Login: 用户会话管理
- Flask-SQLAlchemy: 数据库ORM
- smtplib: 邮件发送
- secrets: 生成安全令牌
---
## 📝 开发日志
### 开发过程
1. **需求分析** (15分钟)
- 讨论用户系统现状
- 确定优化方向和优先级
2. **功能细分** (10分钟)
- 将大功能拆解为9个小模块
- 创建任务清单
3. **功能开发** (90分钟)
- 逐个实现各模块
- 保持代码独立性和稳定性
4. **测试与部署** (30分钟)
- 功能完整性检查
- 提交到 Gitea
- 服务器部署
### 遇到的问题
1. **服务器运行方式不明确**
- 解决:查看进程发现是 Gunicorn
- 使用 `pkill -f gunicorn` 停止
2. **邮箱验证令牌生成**
- 解决:使用 `secrets.token_urlsafe(32)` 生成安全令牌
---
## 🎯 成果总结
本次开发成功实现了用户密码管理和邮箱验证功能,为后续的安全性加固和功能扩展打下了良好基础。
**核心亮点**:
- ✅ 模块化开发,功能独立稳定
- ✅ 完善的错误处理和用户提示
- ✅ 标准化的服务器部署流程
- ✅ 详细的开发文档和进度记录
---
**记录人**: Claude Sonnet 4.5
**记录时间**: 2025-02-07 23:50
**版本**: v3.1

256
PROGRESS_2025-02-08.md Normal file
View File

@@ -0,0 +1,256 @@
# ZJPB 开发进度 - 2025-02-08
## 📦 版本信息
- **版本号**: v3.2
- **提交ID**: 2eefaa8
- **提交时间**: 2025-02-08
- **部署状态**: ✅ 已部署到生产环境
---
## 🎯 本次更新内容
### 1⃣ 用户管理功能(核心功能)
#### 用户列表页面
- **路由**: `/admin/users`
- **功能**:
- 显示所有注册用户列表
- 支持按用户名、邮箱搜索
- 分页显示每页20条
- 显示用户基本信息:用户名、邮箱、收藏数、文件夹数、注册时间、最后登录、状态
- 点击用户可查看详情
#### 用户详情页面
- **路由**: `/admin/users/<user_id>`
- **功能**:
- 显示用户完整信息
- 统计数据:收藏总数、文件夹总数
- 最近收藏列表前10条
- 所有文件夹列表
- 管理员操作:
- 重置用户密码随机生成6位密码
- 修改用户昵称
#### API 接口
- `POST /api/admin/users/<id>/reset-password` - 重置用户密码
- `POST /api/admin/users/<id>/update-username` - 修改用户昵称
#### 管理后台首页优化
- 新增用户统计卡片
- 显示注册用户总数
- 紫色主题图标
---
### 2⃣ 后台菜单统一(重要优化)
#### 问题背景
- 之前各个后台页面硬编码菜单,导致菜单不一致
- 新增菜单项需要在多个文件中同步修改
- 维护困难,容易出错
#### 解决方案
- 创建统一菜单组件:`templates/admin/components/sidebar.html`
- 所有后台页面使用 `{% include %}` 引入统一菜单
- 通过 `active_page` 变量标记当前激活页面
#### 涉及文件
-`templates/admin/batch_import.html` - 批量导入
-`templates/admin/change_password.html` - 修改密码
-`templates/admin/seo_tools.html` - SEO工具
-`templates/admin/users/list.html` - 用户列表
-`templates/admin/users/detail.html` - 用户详情
-`templates/admin/master.html` - Flask-Admin 基础模板
#### 菜单结构
**主菜单6项**
1. 控制台
2. 网站管理
3. 标签管理
4. 新闻管理
5. Prompt管理
6. 管理员
**系统菜单6项**
1. 用户管理 ⭐ 新增
2. SEO工具
3. 批量导入
4. 修改密码
5. 查看网站
6. 退出登录
---
### 3⃣ 规则文档(项目规范)
#### 任务分解规则
- **文件**: `.claude/task-breakdown-rules.md`
- **目的**: 保障开发稳定性和准确性
- **核心原则**:
- 先分解,后执行
- 小步快跑,逐步验证
- 降低风险,避免大规模返工
- **流程**: 理解需求 → 分解任务3-8个子任务→ 列出清单 → 逐个执行
#### 后台菜单统一规则
- **文件**: `.claude/admin-menu-rules.md`
- **目的**: 确保所有后台页面菜单一致
- **核心要求**:
- 禁止硬编码菜单
- 必须使用统一组件 `admin/components/sidebar.html`
- 新增菜单项只需修改统一组件
- **实现方式**:
- 方式1继承 `admin/master.html`(推荐)
- 方式2使用 `{% include 'admin/components/sidebar.html' %}`
#### 权限配置优化
- **文件**: `.claude/settings.local.json`
- **优化内容**:
- 清理临时的特定文件删除权限
- 清理重复的权限规则
- 按类型分组,便于维护
- 从 47 行减少到 37 行
---
## 📂 新增文件
### 后端文件
- `app.py` - 新增用户管理路由约170行代码
### 前端文件
- `templates/admin/components/sidebar.html` - 统一菜单组件
- `templates/admin/users/list.html` - 用户列表页面
- `templates/admin/users/detail.html` - 用户详情页面
### 数据库迁移
- `fix_user_fields.py` - 邮箱验证字段修复脚本
### 文档文件
- `.claude/task-breakdown-rules.md` - 任务分解规则
- `.claude/admin-menu-rules.md` - 后台菜单统一规则
- `PROGRESS_2025-02-07.md` - 上次进度记录
- `PROGRESS_2025-02-08.md` - 本次进度记录
---
## 🔧 修改文件
### 后端修改
- `app.py` - 新增用户管理相关路由
### 前端修改
- `templates/admin/index.html` - 新增用户统计卡片
- `templates/admin/master.html` - 更新菜单,添加用户管理入口
- `templates/admin/batch_import.html` - 使用统一菜单组件
- `templates/admin/change_password.html` - 使用统一菜单组件
- `templates/admin/seo_tools.html` - 使用统一菜单组件
### 配置修改
- `.claude/settings.local.json` - 优化权限配置
---
## 📊 代码统计
- **新增代码**: 约 2168 行
- **删除代码**: 约 297 行
- **净增加**: 约 1871 行
- **修改文件**: 14 个
- **新增文件**: 7 个
---
## 🚀 部署记录
### 部署时间
- 2025-02-08 23:29
### 部署环境
- **服务器**: 112.124.42.38
- **项目路径**: /opt/1panel/apps/zjpb
- **运行方式**: Gunicorn (5 workers)
- **数据库**: MySQL
### 部署步骤
1. ✅ 停止 Gunicorn 进程
2. ✅ 拉取最新代码commit: 2eefaa8
3. ✅ 运行数据库迁移脚本
4. ✅ 启动 Gunicorn 服务
### 数据库迁移
- 运行 `fix_user_fields.py`
- 字段已存在,跳过重复添加
- 迁移成功
---
## 🎯 功能测试清单
### 用户管理功能
- [ ] 访问用户列表页面
- [ ] 测试搜索功能
- [ ] 测试分页功能
- [ ] 查看用户详情
- [ ] 测试重置密码功能
- [ ] 测试修改用户名功能
### 后台菜单
- [ ] 检查所有后台页面菜单是否一致
- [ ] 检查菜单激活状态是否正确
- [ ] 检查新增的"用户管理"菜单项
### 管理后台首页
- [ ] 检查用户统计卡片是否显示
- [ ] 检查用户数量是否正确
---
## 📝 已知问题
暂无
---
## 🔜 下次工作计划
### 待定功能
- 根据实际使用情况决定
### 优化建议
- 用户管理可以添加更多筛选条件(按注册时间、最后登录时间等)
- 可以添加批量操作功能
- 可以添加用户行为日志
---
## 📌 重要提醒
### 服务器信息
- **IP**: 112.124.42.38
- **用户**: root
- **密码**: Ccwh1683!@#
- **项目路径**: /opt/1panel/apps/zjpb
### Git 仓库
- **远程地址**: http://server.zjpb.net:3000/jowelin/zjpb.git
- **当前分支**: master
- **最新提交**: 2eefaa8
### 数据库
- 邮箱验证相关字段已添加
- 用户表结构完整
---
## 👥 协作信息
**开发人员**: Claude Sonnet 4.5
**项目负责人**: lisacc
**开发日期**: 2025-02-08
**文档版本**: v1.0
---
**下次开发时,请先阅读本文档,了解当前进度和代码结构。**

143
PROGRESS_2026-02-23.md Normal file
View File

@@ -0,0 +1,143 @@
# ZJPB 开发进度 - 2026-02-23
## 📦 版本信息
- **最新提交**: b22627a
- **提交时间**: 2026-02-23
- **部署状态**: ✅ 代码已推送到 Gitea待手动部署
---
## 🎯 本次完成内容
### 1⃣ 性能优化(后台加载慢 >1000ms
#### NewsAdmin N+1 查询修复
-`NewsAdmin` 添加 `get_query()` 方法,使用 `joinedload` 预加载关联的 `site`
- 移除导致报错的 `get_count_query()`count 查询不支持 joinedload
#### 数据库索引优化
- 新建 `migrations/add_performance_indexes.py`
-`sites``news``tags` 表添加 10 个高频查询字段索引
- 已在生产环境执行完成
#### 管理后台统计查询优化
- 控制台首页多次统计查询合并
- `recent_sites` 改为只查必要字段,减少数据传输
---
### 2⃣ 验证码 Bug 修复
**问题**:前台点击获取新闻,输入正确验证码仍提示失败
**原因**`detail_new.html` 中 fetch 请求缺少 `credentials: 'same-origin'`,导致浏览器不发送 session cookie验证码 session 无法匹配
**修复**`templates/detail_new.html` fetch 请求中添加 `credentials: 'same-origin'`
---
### 3⃣ 后台网站管理排序修复
**问题**:网站列表按创建时间正序排列,最新的反而排在最后
**修复**`SiteAdmin` 添加 `column_default_sort = ('created_at', True)`,改为倒序
---
### 4⃣ Systemd 服务配置
**目的**:替代手动 nohup 启动,服务器重启后自动恢复
**文件**`/etc/systemd/system/zjpb.service`
```ini
[Unit]
Description=ZJPB Flask Application
After=network.target mysql.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/1panel/apps/zjpb
ExecStart=/opt/1panel/apps/zjpb/venv/bin/gunicorn -c gunicorn_config.py app:create_app()
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**常用命令**
```bash
systemctl start zjpb # 启动
systemctl stop zjpb # 停止
systemctl restart zjpb # 重启
systemctl status zjpb # 状态
journalctl -u zjpb -f # 实时日志
```
**同步修改**`gunicorn_config.py``daemon = False``pidfile` 改为绝对路径
---
### 5⃣ 一键部署脚本
**文件**`update.sh`
**用法**SSH 登录服务器后执行 `./update.sh`
**功能**
1. `git pull` 拉取最新代码
2. `systemctl restart zjpb` 重启服务
3. 自动验证服务状态,失败时输出日志
---
### 6⃣ 发布策略调整
**调整前**Claude 可以直接 SSH 连接生产服务器操作,风险高
**调整后**
```
本地开发 → git push 到 Gitea → 手动 SSH → ./update.sh
```
**权限文件**`.claude/settings.local.json`
- 移除:`ssh``scp``curl``wget``cmd``powershell`
- 保留:`git` 操作、`python`/`pip`
---
## 📂 修改文件清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `app.py` | 修改 | NewsAdmin joinedload、控制台查询优化、SiteAdmin 排序 |
| `templates/detail_new.html` | 修改 | fetch 添加 credentials |
| `gunicorn_config.py` | 修改 | daemon=Falsepidfile 绝对路径 |
| `.claude/settings.local.json` | 修改 | 移除 SSH 权限 |
| `update.sh` | 新增 | 一键部署脚本 |
| `migrations/add_performance_indexes.py` | 新增 | 数据库索引迁移脚本 |
| `/etc/systemd/system/zjpb.service` | 新增(仅服务器) | systemd 服务配置 |
---
## 🚀 待完成(下次登录后)
- [ ] 服务器执行 `git pull && chmod +x update.sh` 完成本次部署
---
## 🖥️ 服务器信息
- **IP**: 112.124.42.38
- **SSH**: `ssh zjpb-prod`(使用 `~/.ssh/id_rsa_atplist` 密钥)
- **项目路径**: `/opt/1panel/apps/zjpb`
- **服务管理**: `systemctl restart zjpb`
- **Gitea**: `http://server.zjpb.net:3000/jowelin/zjpb.git`
---
**开发人员**: Claude Sonnet 4.6
**项目负责人**: lisacc
**开发日期**: 2026-02-23

212
SERVER_RESTART_GUIDE.md Normal file
View File

@@ -0,0 +1,212 @@
# ZJPB 服务器重启指南1Panel 环境)
## 第一步:确认当前部署方式
请在服务器上执行以下命令,找出应用的运行方式:
### 1. 检查是否有运行的 Python 进程
```bash
ps aux | grep -E "python|gunicorn|flask|app.py" | grep -v grep
```
### 2. 检查 1Panel 的应用配置
```bash
# 查看 1Panel 应用目录
ls -la /opt/1panel/apps/
# 查看项目实际路径
pwd
# 查看是否有启动脚本
ls -la *.sh start.* run.*
```
### 3. 检查 Nginx 配置
```bash
# 查看 Nginx 配置
cat /etc/nginx/sites-enabled/* | grep zjpb
# 或者
cat /etc/nginx/conf.d/* | grep zjpb
```
### 4. 检查是否使用 Docker
```bash
docker ps | grep zjpb
```
---
## 常见的 1Panel 部署方式
### 方式 1直接运行 Python 脚本
如果找到类似这样的进程:
```
python app.py
```
**重启方法:**
```bash
# 停止旧进程
pkill -f "python app.py"
# 启动新进程(后台运行)
nohup python app.py > app.log 2>&1 &
```
---
### 方式 2使用 Gunicorn
如果找到类似这样的进程:
```
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
**重启方法:**
```bash
# 停止旧进程
pkill -f gunicorn
# 启动新进程
gunicorn -w 4 -b 0.0.0.0:5000 app:app --daemon
```
---
### 方式 3使用启动脚本
如果项目中有 `start.sh` 或类似脚本:
**重启方法:**
```bash
# 停止
./stop.sh
# 或者
pkill -f "python app.py"
# 启动
./start.sh
```
---
### 方式 4使用 Docker
如果使用 Docker 容器:
**重启方法:**
```bash
# 查看容器
docker ps | grep zjpb
# 重启容器
docker restart <container_id>
```
---
### 方式 51Panel 面板管理
如果通过 1Panel 面板部署:
**重启方法:**
1. 登录 1Panel 管理面板
2. 找到 ZJPB 应用
3. 点击"重启"按钮
---
## 推荐的重启流程
### 步骤 1找到当前进程
```bash
ps aux | grep python | grep -v grep
```
记录下进程 PID 和启动命令。
### 步骤 2停止进程
```bash
# 方法 1使用 PID
kill <PID>
# 方法 2使用进程名
pkill -f "python app.py"
# 方法 3强制停止如果上面不行
pkill -9 -f "python app.py"
```
### 步骤 3确认已停止
```bash
ps aux | grep python | grep -v grep
```
应该没有输出。
### 步骤 4启动应用
```bash
# 进入项目目录
cd /opt/1panel/apps/zjpb
# 激活虚拟环境
source venv/bin/activate
# 启动应用(后台运行)
nohup python app.py > app.log 2>&1 &
# 或者使用 Gunicorn
nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 &
```
### 步骤 5验证启动成功
```bash
# 检查进程
ps aux | grep python | grep -v grep
# 检查日志
tail -f app.log
# 或者
tail -f gunicorn.log
# 测试访问
curl http://localhost:5000/
```
---
## 如果不确定如何重启
请执行以下命令并将结果发给我:
```bash
echo "=== 当前目录 ==="
pwd
echo -e "\n=== Python 进程 ==="
ps aux | grep python | grep -v grep
echo -e "\n=== 项目文件 ==="
ls -la
echo -e "\n=== 启动脚本 ==="
ls -la *.sh 2>/dev/null || echo "没有找到 .sh 脚本"
echo -e "\n=== 最近的日志 ==="
ls -lt *.log 2>/dev/null | head -5 || echo "没有找到日志文件"
```
---
**创建日期:** 2025-02-06
**适用版本:** v3.0.1

454
app.py
View File

@@ -2,17 +2,19 @@ import os
import markdown import markdown
import random import random
import string import string
import secrets
from io import BytesIO from io import BytesIO
from flask import Flask, render_template, redirect, url_for, request, flash, jsonify, session, send_file from flask import Flask, render_template, redirect, url_for, request, flash, jsonify, session, send_file
from flask_login import LoginManager, login_user, logout_user, login_required, current_user from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from flask_admin import Admin, AdminIndexView, expose from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView from flask_admin.contrib.sqla import ModelView
from datetime import datetime from datetime import datetime, timedelta
from config import config from config import config
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection
from utils.website_fetcher import WebsiteFetcher from utils.website_fetcher import WebsiteFetcher
from utils.tag_generator import TagGenerator from utils.tag_generator import TagGenerator
from utils.news_searcher import NewsSearcher from utils.news_searcher import NewsSearcher
from utils.email_sender import EmailSender
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
def create_app(config_name='default'): def create_app(config_name='default'):
@@ -1130,6 +1132,16 @@ def create_app(config_name='default'):
folders_count=folders_count, folders_count=folders_count,
recent_collections=recent_collections) recent_collections=recent_collections)
@app.route('/user/change-password')
@login_required
def user_change_password_page():
"""修改密码页面"""
if not isinstance(current_user, User):
flash('仅普通用户可访问', 'error')
return redirect(url_for('index'))
return render_template('user/change_password.html')
@app.route('/user/collections') @app.route('/user/collections')
@login_required @login_required
def user_collections(): def user_collections():
@@ -1201,6 +1213,233 @@ def create_app(config_name='default'):
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500 return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500
@app.route('/api/user/change-password', methods=['PUT'])
@login_required
def user_change_password():
"""普通用户修改密码"""
if not isinstance(current_user, User):
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
try:
data = request.get_json() or {}
old_password = data.get('old_password', '').strip()
new_password = data.get('new_password', '').strip()
confirm_password = data.get('confirm_password', '').strip()
# 验证旧密码
if not old_password:
return jsonify({'success': False, 'message': '请输入旧密码'}), 400
if not current_user.check_password(old_password):
return jsonify({'success': False, 'message': '旧密码错误'}), 400
# 验证新密码
if not new_password:
return jsonify({'success': False, 'message': '请输入新密码'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'message': '新密码长度至少6位'}), 400
if new_password != confirm_password:
return jsonify({'success': False, 'message': '两次输入的新密码不一致'}), 400
if old_password == new_password:
return jsonify({'success': False, 'message': '新密码不能与旧密码相同'}), 400
# 更新密码
current_user.set_password(new_password)
db.session.commit()
return jsonify({
'success': True,
'message': '密码修改成功'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'修改失败:{str(e)}'}), 500
@app.route('/api/user/email', methods=['PUT'])
@login_required
def update_user_email():
"""更新用户邮箱"""
if not isinstance(current_user, User):
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
try:
data = request.get_json() or {}
email = data.get('email', '').strip()
# 验证邮箱格式
if not email:
return jsonify({'success': False, 'message': '请输入邮箱地址'}), 400
import re
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, email):
return jsonify({'success': False, 'message': '邮箱格式不正确'}), 400
# 检查邮箱是否已被其他用户使用
existing_user = User.query.filter(
User.email == email,
User.id != current_user.id
).first()
if existing_user:
return jsonify({'success': False, 'message': '该邮箱已被其他用户使用'}), 400
# 更新邮箱
current_user.email = email
# 重置验证状态
current_user.email_verified = False
current_user.email_verified_at = None
current_user.email_verify_token = None
current_user.email_verify_token_expires = None
db.session.commit()
return jsonify({
'success': True,
'message': '邮箱已更新,请验证新邮箱'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500
@app.route('/api/user/send-verify-email', methods=['POST'])
@login_required
def send_verify_email():
"""发送邮箱验证邮件"""
if not isinstance(current_user, User):
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
try:
# 检查是否已绑定邮箱
if not current_user.email:
return jsonify({'success': False, 'message': '请先绑定邮箱'}), 400
# 检查是否已验证
if current_user.email_verified:
return jsonify({'success': False, 'message': '邮箱已验证,无需重复验证'}), 400
# 生成验证令牌32位随机字符串
token = secrets.token_urlsafe(32)
expires = datetime.now() + timedelta(hours=24)
# 保存令牌
current_user.email_verify_token = token
current_user.email_verify_token_expires = expires
db.session.commit()
# 发送验证邮件
verify_url = url_for('verify_email', token=token, _external=True)
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{ display: inline-block; padding: 12px 30px; background: #0ea5e9; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 20px; color: #64748b; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>验证您的邮箱</h1>
</div>
<div class="content">
<p>您好,{current_user.username}</p>
<p>感谢您注册 ZJPB。请点击下面的按钮验证您的邮箱地址</p>
<p style="text-align: center;">
<a href="{verify_url}" class="button">验证邮箱</a>
</p>
<p>或复制以下链接到浏览器:</p>
<p style="word-break: break-all; background: white; padding: 10px; border-radius: 4px;">{verify_url}</p>
<p style="color: #64748b; font-size: 14px;">此链接将在24小时后失效。</p>
</div>
<div class="footer">
<p>如果您没有注册 ZJPB请忽略此邮件。</p>
<p>&copy; 2025 ZJPB - 自己品吧</p>
</div>
</div>
</body>
</html>
"""
text_content = f"""
验证您的邮箱
您好,{current_user.username}
感谢您注册 ZJPB。请访问以下链接验证您的邮箱地址
{verify_url}
此链接将在24小时后失效。
如果您没有注册 ZJPB请忽略此邮件。
"""
email_sender = EmailSender()
success = email_sender.send_email(
to_email=current_user.email,
subject='验证您的邮箱 - ZJPB',
html_content=html_content,
text_content=text_content
)
if success:
return jsonify({
'success': True,
'message': '验证邮件已发送,请查收'
})
else:
return jsonify({
'success': False,
'message': '邮件发送失败,请稍后重试'
}), 500
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'发送失败:{str(e)}'}), 500
@app.route('/verify-email/<token>')
def verify_email(token):
"""验证邮箱"""
try:
# 查找令牌对应的用户
user = User.query.filter_by(email_verify_token=token).first()
if not user:
flash('验证链接无效', 'error')
return redirect(url_for('index'))
# 检查令牌是否过期
if user.email_verify_token_expires < datetime.now():
flash('验证链接已过期,请重新发送', 'error')
return redirect(url_for('user_profile'))
# 验证成功
user.email_verified = True
user.email_verified_at = datetime.now()
user.email_verify_token = None
user.email_verify_token_expires = None
db.session.commit()
flash('邮箱验证成功!', 'success')
return redirect(url_for('user_profile'))
except Exception as e:
flash(f'验证失败:{str(e)}', 'error')
return redirect(url_for('index'))
@app.route('/admin/change-password', methods=['GET', 'POST']) @app.route('/admin/change-password', methods=['GET', 'POST'])
@login_required @login_required
def change_password(): def change_password():
@@ -2279,6 +2518,174 @@ Sitemap: {}sitemap.xml
return render_template('admin/batch_import.html', results=results) return render_template('admin/batch_import.html', results=results)
# ========== 用户管理路由 ==========
@app.route('/admin/users')
@login_required
def admin_users():
"""用户管理列表页"""
if not isinstance(current_user, AdminModel):
flash('无权访问', 'error')
return redirect(url_for('index'))
# 获取分页参数
page = request.args.get('page', 1, type=int)
per_page = 20
# 获取搜索参数
search = request.args.get('search', '').strip()
# 构建查询
query = User.query
if search:
query = query.filter(
db.or_(
User.username.like(f'%{search}%'),
User.email.like(f'%{search}%')
)
)
# 排序:按注册时间倒序
query = query.order_by(User.created_at.desc())
# 分页
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
users = pagination.items
# 为每个用户统计收藏数据
user_stats = {}
for user in users:
user_stats[user.id] = {
'collections_count': Collection.query.filter_by(user_id=user.id).count(),
'folders_count': Folder.query.filter_by(user_id=user.id).count()
}
# 获取真实的 admin 实例
from flask import current_app
admin_instance = current_app.extensions['admin'][0]
# 创建模拟的 admin_view 对象
class MockAdminView:
name = '用户管理'
category = None
admin = admin_instance
return render_template('admin/users/list.html',
users=users,
pagination=pagination,
user_stats=user_stats,
search=search,
admin_view=MockAdminView())
@app.route('/admin/users/<int:user_id>')
@login_required
def admin_user_detail(user_id):
"""用户详情页"""
if not isinstance(current_user, AdminModel):
flash('无权访问', 'error')
return redirect(url_for('index'))
user = User.query.get_or_404(user_id)
# 统计数据
collections_count = Collection.query.filter_by(user_id=user.id).count()
folders_count = Folder.query.filter_by(user_id=user.id).count()
# 获取最近的收藏前10条
recent_collections = Collection.query.filter_by(user_id=user.id)\
.order_by(Collection.created_at.desc())\
.limit(10).all()
# 获取所有文件夹
folders = Folder.query.filter_by(user_id=user.id)\
.order_by(Folder.sort_order.desc(), Folder.created_at.desc())\
.all()
# 获取真实的 admin 实例
from flask import current_app
admin_instance = current_app.extensions['admin'][0]
# 创建模拟的 admin_view 对象
class MockAdminView:
name = '用户详情'
category = None
admin = admin_instance
return render_template('admin/users/detail.html',
user=user,
collections_count=collections_count,
folders_count=folders_count,
recent_collections=recent_collections,
folders=folders,
admin_view=MockAdminView())
@app.route('/api/admin/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
def admin_reset_user_password(user_id):
"""管理员重置用户密码"""
if not isinstance(current_user, AdminModel):
return jsonify({'success': False, 'message': '无权操作'}), 403
user = User.query.get_or_404(user_id)
data = request.get_json()
new_password = data.get('new_password', '').strip()
# 验证新密码
if not new_password:
return jsonify({'success': False, 'message': '新密码不能为空'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'message': '新密码至少需要6位'}), 400
try:
# 设置新密码
user.set_password(new_password)
db.session.commit()
return jsonify({
'success': True,
'message': f'已成功重置用户 {user.username} 的密码'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'重置密码失败: {str(e)}'}), 500
@app.route('/api/admin/users/<int:user_id>/update-username', methods=['POST'])
@login_required
def admin_update_username(user_id):
"""管理员修改用户昵称"""
if not isinstance(current_user, AdminModel):
return jsonify({'success': False, 'message': '无权操作'}), 403
user = User.query.get_or_404(user_id)
data = request.get_json()
new_username = data.get('new_username', '').strip()
# 验证新昵称
if not new_username:
return jsonify({'success': False, 'message': '昵称不能为空'}), 400
if len(new_username) < 2 or len(new_username) > 50:
return jsonify({'success': False, 'message': '昵称长度需要在2-50个字符之间'}), 400
# 检查昵称是否已被使用
existing_user = User.query.filter_by(username=new_username).first()
if existing_user and existing_user.id != user.id:
return jsonify({'success': False, 'message': '该昵称已被使用'}), 400
try:
old_username = user.username
user.username = new_username
db.session.commit()
return jsonify({
'success': True,
'message': f'已成功将昵称从 {old_username} 修改为 {new_username}'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'修改昵称失败: {str(e)}'}), 500
# ========== Flask-Admin 配置 ========== # ========== Flask-Admin 配置 ==========
class SecureModelView(ModelView): class SecureModelView(ModelView):
"""需要登录的模型视图""" """需要登录的模型视图"""
@@ -2313,16 +2720,40 @@ Sitemap: {}sitemap.xml
@expose('/') @expose('/')
def index(self): def index(self):
"""控制台首页,显示统计信息""" """控制台首页,显示统计信息"""
# 统计数据
# 优化查询:减少数据库往返次数
# 1. 获取 sites_count 和 total_views
site_stats = db.session.query(
db.func.count(Site.id).label('total'),
db.func.sum(Site.view_count).label('total_views')
).first()
# 2. 获取 active sites count
sites_count = Site.query.filter_by(is_active=True).count()
# 3. 获取 tags_count
tags_count = Tag.query.count()
# 4. 获取 news_count
news_count = News.query.filter_by(is_active=True).count()
# 5. 获取 users_count
users_count = User.query.count()
# 组装统计数据
stats = { stats = {
'sites_count': Site.query.filter_by(is_active=True).count(), 'sites_count': sites_count,
'tags_count': Tag.query.count(), 'tags_count': tags_count,
'news_count': News.query.filter_by(is_active=True).count(), 'news_count': news_count,
'total_views': db.session.query(db.func.sum(Site.view_count)).scalar() or 0 'total_views': site_stats.total_views or 0,
'users_count': users_count
} }
# 最近添加的工具最多5个 # 最近添加的工具最多5个- 只查询必要字段
recent_sites = Site.query.order_by(Site.created_at.desc()).limit(5).all() recent_sites = db.session.query(
Site.id, Site.name, Site.url, Site.logo,
Site.is_active, Site.view_count, Site.created_at
).order_by(Site.created_at.desc()).limit(5).all()
return self.render('admin/index.html', stats=stats, recent_sites=recent_sites) return self.render('admin/index.html', stats=stats, recent_sites=recent_sites)
@@ -2343,6 +2774,7 @@ Sitemap: {}sitemap.xml
action_disallowed_list = [] action_disallowed_list = []
column_list = ['id', 'code', 'name', 'url', 'slug', 'is_active', 'is_recommended', 'view_count', 'created_at'] column_list = ['id', 'code', 'name', 'url', 'slug', 'is_active', 'is_recommended', 'view_count', 'created_at']
column_default_sort = ('created_at', True) # 按创建时间倒序,最新的排在前面
column_searchable_list = ['code', 'name', 'url', 'description'] column_searchable_list = ['code', 'name', 'url', 'description']
column_filters = ['is_active', 'is_recommended', 'tags'] column_filters = ['is_active', 'is_recommended', 'tags']
column_labels = { column_labels = {
@@ -2561,6 +2993,12 @@ Sitemap: {}sitemap.xml
# 默认排序 # 默认排序
column_default_sort = ('published_at', True) # 按发布时间倒序排列 column_default_sort = ('published_at', True) # 按发布时间倒序排列
def get_query(self):
"""优化查询使用joinedload避免N+1问题"""
return super().get_query().options(
db.orm.joinedload(News.site)
)
# Prompt模板管理视图 # Prompt模板管理视图
class PromptAdmin(SecureModelView): class PromptAdmin(SecureModelView):
can_edit = True can_edit = True

47
fix_user_fields.py Normal file
View File

@@ -0,0 +1,47 @@
"""
修复用户表缺失字段
"""
from app import create_app
from models import db
def fix_fields():
app = create_app()
with app.app_context():
with db.engine.connect() as conn:
try:
# 添加 email_verified_at
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间'
"""))
conn.commit()
print("[OK] 添加 email_verified_at")
except Exception as e:
print(f"[SKIP] email_verified_at: {e}")
try:
# 添加 email_verify_token
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌'
"""))
conn.commit()
print("[OK] 添加 email_verify_token")
except Exception as e:
print(f"[SKIP] email_verify_token: {e}")
try:
# 添加 email_verify_token_expires
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verify_token_expires DATETIME COMMENT '验证令牌过期时间'
"""))
conn.commit()
print("[OK] 添加 email_verify_token_expires")
except Exception as e:
print(f"[SKIP] email_verify_token_expires: {e}")
print("\n[SUCCESS] 字段修复完成!")
if __name__ == '__main__':
fix_fields()

View File

@@ -0,0 +1,69 @@
"""
添加邮箱验证相关字段的数据库迁移脚本
运行方式: python migrate_email_verification.py
"""
from app import create_app
from models import db
def migrate():
"""执行数据库迁移"""
app = create_app()
with app.app_context():
try:
# 添加邮箱验证相关字段
with db.engine.connect() as conn:
# 检查字段是否已存在
result = conn.execute(db.text("""
SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_name='users' AND column_name='email_verified'
"""))
exists = result.fetchone()[0] > 0
if not exists:
print("开始添加邮箱验证字段...")
# 添加 email_verified 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN DEFAULT FALSE COMMENT '邮箱是否已验证'
"""))
conn.commit()
print("[OK] 添加 email_verified 字段")
# 添加 email_verified_at 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间'
"""))
conn.commit()
print("[OK] 添加 email_verified_at 字段")
# 添加 email_verify_token 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌'
"""))
conn.commit()
print("[OK] 添加 email_verify_token 字段")
# 添加 email_verify_token_expires 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verify_token_expires DATETIME COMMENT '验证令牌过期时间'
"""))
conn.commit()
print("[OK] 添加 email_verify_token_expires 字段")
print("\n[SUCCESS] 邮箱验证字段迁移完成!")
else:
print("[SKIP] 邮箱验证字段已存在,跳过迁移")
except Exception as e:
print(f"[ERROR] 迁移失败: {str(e)}")
raise
if __name__ == '__main__':
migrate()

View File

@@ -0,0 +1,71 @@
"""
数据库索引优化迁移脚本
为Site、News表的高频查询字段添加索引提升查询性能
执行方式: python migrations/add_performance_indexes.py
"""
import os
import sys
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from models import Site, News, Tag
def add_indexes():
"""添加性能优化索引"""
app = create_app('development')
with app.app_context():
# 检查并添加索引
indexes_to_add = [
# Site表索引
('idx_site_is_active', 'sites', 'is_active'),
('idx_site_created_at', 'sites', 'created_at'),
('idx_site_view_count', 'sites', 'view_count'),
('idx_site_is_recommended', 'sites', 'is_recommended'),
('idx_site_sort_order', 'sites', 'sort_order'),
# News表索引
('idx_news_site_id', 'news', 'site_id'),
('idx_news_is_active', 'news', 'is_active'),
('idx_news_published_at', 'news', 'published_at'),
('idx_news_created_at', 'news', 'created_at'),
# Tag表索引
('idx_tag_sort_order', 'tags', 'sort_order'),
]
for index_name, table_name, column_name in indexes_to_add:
try:
# 检查索引是否已存在
check_sql = f"""
SELECT COUNT(*) as count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{table_name}'
AND INDEX_NAME = '{index_name}'
"""
result = db.session.execute(db.text(check_sql)).fetchone()
if result[0] == 0:
# 创建索引
db.session.execute(db.text(
f"CREATE INDEX {index_name} ON {table_name}({column_name})"
))
print(f"✓ 添加索引: {index_name} ON {table_name}({column_name})")
else:
print(f"- 索引已存在: {index_name}")
except Exception as e:
print(f"✗ 添加索引失败 {index_name}: {str(e)}")
# 提交更改
db.session.commit()
print("\n索引优化完成!")
if __name__ == '__main__':
add_indexes()

View File

@@ -194,6 +194,10 @@ class User(UserMixin, db.Model):
username = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='用户名') username = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='用户名')
password_hash = db.Column(db.String(255), nullable=False, comment='密码哈希') password_hash = db.Column(db.String(255), nullable=False, comment='密码哈希')
email = db.Column(db.String(100), unique=True, nullable=True, index=True, comment='邮箱') email = db.Column(db.String(100), unique=True, nullable=True, index=True, comment='邮箱')
email_verified = db.Column(db.Boolean, default=False, comment='邮箱是否已验证')
email_verified_at = db.Column(db.DateTime, comment='邮箱验证时间')
email_verify_token = db.Column(db.String(100), comment='邮箱验证令牌')
email_verify_token_expires = db.Column(db.DateTime, comment='验证令牌过期时间')
avatar = db.Column(db.String(500), comment='头像URL') avatar = db.Column(db.String(500), comment='头像URL')
bio = db.Column(db.String(200), comment='个人简介') bio = db.Column(db.String(200), comment='个人简介')
is_active = db.Column(db.Boolean, default=True, comment='是否启用') is_active = db.Column(db.Boolean, default=True, comment='是否启用')

View File

@@ -21,101 +21,8 @@
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
</head> </head>
<body class="admin-sidebar-layout"> <body class="admin-sidebar-layout">
<!-- 左侧菜单栏 --> {% set active_page = 'batch_import' %}
<aside class="admin-sidebar"> {% include 'admin/components/sidebar.html' %}
<!-- Logo -->
<div class="sidebar-logo">
<span class="material-symbols-outlined logo-icon">blur_on</span>
<span class="logo-text">ZJPB - 自己品吧</span>
</div>
<!-- 主菜单 -->
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主菜单</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('admin.index') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">dashboard</span>
<span class="nav-text">控制台</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('site.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">public</span>
<span class="nav-text">网站管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">label</span>
<span class="nav-text">标签管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('news.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">newspaper</span>
<span class="nav-text">新闻管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
<span class="nav-text">管理员</span>
</a>
</li>
</ul>
</div>
<!-- 系统菜单 -->
<div class="nav-section">
<div class="nav-section-title">系统</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('seo_tools') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">search</span>
<span class="nav-text">SEO工具</span>
</a>
</li>
<li class="nav-item active">
<a href="{{ url_for('batch_import') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">upload_file</span>
<span class="nav-text">批量导入</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('change_password') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">lock_reset</span>
<span class="nav-text">修改密码</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
<span class="material-symbols-outlined nav-icon">open_in_new</span>
<span class="nav-text">查看网站</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_logout') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">logout</span>
<span class="nav-text">退出登录</span>
</a>
</li>
</ul>
</div>
</nav>
<!-- 用户信息 -->
<div class="sidebar-user">
<div class="user-avatar">
<span class="material-symbols-outlined">account_circle</span>
</div>
<div class="user-info">
<div class="user-name">{{ current_user.username }}</div>
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
</div>
</div>
</aside>
<!-- 右侧主内容区 --> <!-- 右侧主内容区 -->
<div class="admin-main"> <div class="admin-main">

View File

@@ -21,101 +21,8 @@
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
</head> </head>
<body class="admin-sidebar-layout"> <body class="admin-sidebar-layout">
<!-- 左侧菜单栏 --> {% set active_page = 'change_password' %}
<aside class="admin-sidebar"> {% include 'admin/components/sidebar.html' %}
<!-- Logo -->
<div class="sidebar-logo">
<span class="material-symbols-outlined logo-icon">blur_on</span>
<span class="logo-text">ZJPB - 自己品吧</span>
</div>
<!-- 主菜单 -->
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主菜单</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('admin.index') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">dashboard</span>
<span class="nav-text">控制台</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('site.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">public</span>
<span class="nav-text">网站管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">label</span>
<span class="nav-text">标签管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('news.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">newspaper</span>
<span class="nav-text">新闻管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
<span class="nav-text">管理员</span>
</a>
</li>
</ul>
</div>
<!-- 系统菜单 -->
<div class="nav-section">
<div class="nav-section-title">系统</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('seo_tools') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">search</span>
<span class="nav-text">SEO工具</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('batch_import') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">upload_file</span>
<span class="nav-text">批量导入</span>
</a>
</li>
<li class="nav-item active">
<a href="{{ url_for('change_password') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">lock_reset</span>
<span class="nav-text">修改密码</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
<span class="material-symbols-outlined nav-icon">open_in_new</span>
<span class="nav-text">查看网站</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_logout') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">logout</span>
<span class="nav-text">退出登录</span>
</a>
</li>
</ul>
</div>
</nav>
<!-- 用户信息 -->
<div class="sidebar-user">
<div class="user-avatar">
<span class="material-symbols-outlined">account_circle</span>
</div>
<div class="user-info">
<div class="user-name">{{ current_user.username }}</div>
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
</div>
</div>
</aside>
<!-- 右侧主内容区 --> <!-- 右侧主内容区 -->
<div class="admin-main"> <div class="admin-main">

View File

@@ -0,0 +1,107 @@
<!-- 左侧菜单栏 -->
<aside class="admin-sidebar">
<!-- Logo -->
<div class="sidebar-logo">
<span class="material-symbols-outlined logo-icon">blur_on</span>
<span class="logo-text">ZJPB - 自己品吧</span>
</div>
<!-- 主菜单 -->
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主菜单</div>
<ul class="nav-menu">
<li class="nav-item {% if active_page == 'dashboard' %}active{% endif %}">
<a href="{{ url_for('admin.index') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">dashboard</span>
<span class="nav-text">控制台</span>
</a>
</li>
<li class="nav-item {% if active_page == 'site' %}active{% endif %}">
<a href="{{ url_for('site.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">public</span>
<span class="nav-text">网站管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'tag' %}active{% endif %}">
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">label</span>
<span class="nav-text">标签管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'news' %}active{% endif %}">
<a href="{{ url_for('news.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">newspaper</span>
<span class="nav-text">新闻管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'prompt' %}active{% endif %}">
<a href="{{ url_for('prompttemplate.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">psychology</span>
<span class="nav-text">Prompt管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'admin_user' %}active{% endif %}">
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
<span class="nav-text">管理员</span>
</a>
</li>
</ul>
</div>
<!-- 系统菜单 -->
<div class="nav-section">
<div class="nav-section-title">系统</div>
<ul class="nav-menu">
<li class="nav-item {% if active_page == 'users' %}active{% endif %}">
<a href="{{ url_for('admin_users') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">group</span>
<span class="nav-text">用户管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'seo' %}active{% endif %}">
<a href="{{ url_for('seo_tools') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">search</span>
<span class="nav-text">SEO工具</span>
</a>
</li>
<li class="nav-item {% if active_page == 'batch_import' %}active{% endif %}">
<a href="{{ url_for('batch_import') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">upload_file</span>
<span class="nav-text">批量导入</span>
</a>
</li>
<li class="nav-item {% if active_page == 'change_password' %}active{% endif %}">
<a href="{{ url_for('change_password') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">lock_reset</span>
<span class="nav-text">修改密码</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
<span class="material-symbols-outlined nav-icon">open_in_new</span>
<span class="nav-text">查看网站</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_logout') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">logout</span>
<span class="nav-text">退出登录</span>
</a>
</li>
</ul>
</div>
</nav>
<!-- 用户信息 -->
<div class="sidebar-user">
<div class="user-avatar">
<span class="material-symbols-outlined">account_circle</span>
</div>
<div class="user-info">
<div class="user-name">{{ current_user.username }}</div>
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
</div>
</div>
</aside>

View File

@@ -4,7 +4,7 @@
<div class="dashboard-container"> <div class="dashboard-container">
<div class="row"> <div class="row">
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="col-md-3 mb-4"> <div class="col-md-2-4 mb-4">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon" style="background: rgba(0, 82, 217, 0.1); color: #0052D9;"> <div class="stat-icon" style="background: rgba(0, 82, 217, 0.1); color: #0052D9;">
<span class="material-symbols-outlined">public</span> <span class="material-symbols-outlined">public</span>
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="col-md-3 mb-4"> <div class="col-md-2-4 mb-4">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon" style="background: rgba(0, 168, 112, 0.1); color: #00A870;"> <div class="stat-icon" style="background: rgba(0, 168, 112, 0.1); color: #00A870;">
<span class="material-symbols-outlined">label</span> <span class="material-symbols-outlined">label</span>
@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
<div class="col-md-3 mb-4"> <div class="col-md-2-4 mb-4">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon" style="background: rgba(227, 115, 24, 0.1); color: #E37318;"> <div class="stat-icon" style="background: rgba(227, 115, 24, 0.1); color: #E37318;">
<span class="material-symbols-outlined">newspaper</span> <span class="material-symbols-outlined">newspaper</span>
@@ -40,7 +40,19 @@
</div> </div>
</div> </div>
<div class="col-md-3 mb-4"> <div class="col-md-2-4 mb-4">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(123, 97, 255, 0.1); color: #7B61FF;">
<span class="material-symbols-outlined">group</span>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.users_count or 0 }}</div>
<div class="stat-label">注册用户</div>
</div>
</div>
</div>
<div class="col-md-2-4 mb-4">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon" style="background: rgba(213, 73, 65, 0.1); color: #D54941;"> <div class="stat-icon" style="background: rgba(213, 73, 65, 0.1); color: #D54941;">
<span class="material-symbols-outlined">visibility</span> <span class="material-symbols-outlined">visibility</span>
@@ -189,6 +201,25 @@
color: #606266; color: #606266;
} }
.col-md-2-4 {
flex: 0 0 20%;
max-width: 20%;
}
@media (max-width: 768px) {
.col-md-2-4 {
flex: 0 0 50%;
max-width: 50%;
}
}
@media (max-width: 480px) {
.col-md-2-4 {
flex: 0 0 100%;
max-width: 100%;
}
}
.quick-actions { .quick-actions {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -79,6 +79,18 @@
<div class="nav-section"> <div class="nav-section">
<div class="nav-section-title">系统</div> <div class="nav-section-title">系统</div>
<ul class="nav-menu"> <ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('admin_users') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">group</span>
<span class="nav-text">用户管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('seo_tools') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">search</span>
<span class="nav-text">SEO工具</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="{{ url_for('batch_import') }}" class="nav-link"> <a href="{{ url_for('batch_import') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">upload_file</span> <span class="material-symbols-outlined nav-icon">upload_file</span>

View File

@@ -24,101 +24,8 @@
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
</head> </head>
<body class="admin-sidebar-layout"> <body class="admin-sidebar-layout">
<!-- 左侧菜单栏 --> {% set active_page = 'seo' %}
<aside class="admin-sidebar"> {% include 'admin/components/sidebar.html' %}
<!-- Logo -->
<div class="sidebar-logo">
<span class="material-symbols-outlined logo-icon">blur_on</span>
<span class="logo-text">ZJPB - 自己品吧</span>
</div>
<!-- 主菜单 -->
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主菜单</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="{{ url_for('admin.index') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">dashboard</span>
<span class="nav-text">控制台</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('site.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">public</span>
<span class="nav-text">网站管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">label</span>
<span class="nav-text">标签管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('news.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">newspaper</span>
<span class="nav-text">新闻管理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
<span class="nav-text">管理员</span>
</a>
</li>
</ul>
</div>
<!-- 系统菜单 -->
<div class="nav-section">
<div class="nav-section-title">系统</div>
<ul class="nav-menu">
<li class="nav-item active">
<a href="{{ url_for('seo_tools') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">search</span>
<span class="nav-text">SEO工具</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('batch_import') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">upload_file</span>
<span class="nav-text">批量导入</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('change_password') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">lock_reset</span>
<span class="nav-text">修改密码</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
<span class="material-symbols-outlined nav-icon">open_in_new</span>
<span class="nav-text">查看网站</span>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin_logout') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">logout</span>
<span class="nav-text">退出登录</span>
</a>
</li>
</ul>
</div>
</nav>
<!-- 用户信息 -->
<div class="sidebar-user">
<div class="user-avatar">
<span class="material-symbols-outlined">account_circle</span>
</div>
<div class="user-info">
<div class="user-name">{{ current_user.username }}</div>
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
</div>
</div>
</aside>
<!-- 右侧主内容区 --> <!-- 右侧主内容区 -->
<div class="admin-main"> <div class="admin-main">

View File

@@ -0,0 +1,884 @@
{% extends 'admin/master.html' %}
{% block body %}
<div class="user-detail-container">
<!-- 返回按钮 -->
<div class="back-nav">
<a href="{{ url_for('admin_users') }}" class="btn-back">
<span class="material-symbols-outlined">arrow_back</span>
返回用户列表
</a>
</div>
<!-- 用户基本信息卡片 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">基本信息</h5>
</div>
<div class="card-body">
<div class="user-profile">
<div class="user-avatar">
{% if user.avatar %}
<img src="{{ user.avatar }}" alt="{{ user.username }}">
{% else %}
<div class="avatar-placeholder">
<span class="material-symbols-outlined">person</span>
</div>
{% endif %}
</div>
<div class="user-info-grid">
<div class="info-item">
<label>用户ID</label>
<div class="info-value">{{ user.id }}</div>
</div>
<div class="info-item">
<label>用户名</label>
<div class="info-value">
<strong>{{ user.username }}</strong>
<button class="btn-icon" onclick="showEditUsernameModal()" title="修改昵称">
<span class="material-symbols-outlined">edit</span>
</button>
</div>
</div>
<div class="info-item">
<label>邮箱</label>
<div class="info-value">
{% if user.email %}
{{ user.email }}
{% if user.email_verified %}
<span class="badge badge-success-sm">
<span class="material-symbols-outlined">verified</span>
已验证
</span>
{% else %}
<span class="badge badge-warning-sm">未验证</span>
{% endif %}
{% else %}
<span class="text-muted">未设置</span>
{% endif %}
</div>
</div>
<div class="info-item">
<label>注册时间</label>
<div class="info-value">{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '-' }}</div>
</div>
<div class="info-item">
<label>最后登录</label>
<div class="info-value">{{ user.last_login.strftime('%Y-%m-%d %H:%M:%S') if user.last_login else '从未登录' }}</div>
</div>
<div class="info-item">
<label>账户状态</label>
<div class="info-value">
{% if user.is_active %}
<span class="badge badge-success">正常</span>
{% else %}
<span class="badge badge-secondary">已禁用</span>
{% endif %}
</div>
</div>
<div class="info-item">
<label>个人简介</label>
<div class="info-value">{{ user.bio if user.bio else '暂无' }}</div>
</div>
<div class="info-item">
<label>资料公开</label>
<div class="info-value">
{% if user.is_public_profile %}
<span class="badge badge-info-sm">公开</span>
{% else %}
<span class="badge badge-secondary-sm">私密</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 管理操作卡片 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">管理操作</h5>
</div>
<div class="card-body">
<div class="action-buttons">
<button class="btn btn-warning" onclick="showResetPasswordModal()">
<span class="material-symbols-outlined">lock_reset</span>
重置密码
</button>
</div>
</div>
</div>
<!-- 收藏统计卡片 -->
<div class="row mb-4">
<div class="col-md-6">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(0, 82, 217, 0.1); color: #0052D9;">
<span class="material-symbols-outlined">bookmark</span>
</div>
<div class="stat-info">
<div class="stat-value">{{ collections_count }}</div>
<div class="stat-label">收藏的工具</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(0, 168, 112, 0.1); color: #00A870;">
<span class="material-symbols-outlined">folder</span>
</div>
<div class="stat-info">
<div class="stat-value">{{ folders_count }}</div>
<div class="stat-label">收藏分组</div>
</div>
</div>
</div>
</div>
<!-- 收藏分组列表 -->
{% if folders %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">收藏分组 ({{ folders_count }})</h5>
</div>
<div class="card-body">
<div class="folders-grid">
{% for folder in folders %}
<div class="folder-item">
<div class="folder-icon">{{ folder.icon }}</div>
<div class="folder-info">
<div class="folder-name">{{ folder.name }}</div>
<div class="folder-meta">
<span>{{ folder.collections.count() }} 个工具</span>
{% if folder.is_public %}
<span class="badge badge-info-sm">公开</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- 最近收藏 -->
{% if recent_collections %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">最近收藏 (最多显示10条)</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>工具名称</th>
<th>所属分组</th>
<th>收藏时间</th>
</tr>
</thead>
<tbody>
{% for collection in recent_collections %}
<tr>
<td>
<div class="site-info">
{% if collection.site.logo %}
<img src="{{ collection.site.logo }}" alt="{{ collection.site.name }}" class="site-logo">
{% endif %}
<a href="/site/{{ collection.site.code }}" target="_blank">{{ collection.site.name }}</a>
</div>
</td>
<td>
{% if collection.folder %}
<span class="folder-badge">{{ collection.folder.icon }} {{ collection.folder.name }}</span>
{% else %}
<span class="text-muted">未分组</span>
{% endif %}
</td>
<td>{{ collection.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<!-- 重置密码弹窗 -->
<div id="resetPasswordModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h5>重置用户密码</h5>
<button class="close-btn" onclick="closeResetPasswordModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted mb-3">为用户 <strong>{{ user.username }}</strong> 设置新密码</p>
<div class="form-group">
<label>新密码 <span class="text-danger">*</span></label>
<input type="password" id="newPassword" class="form-control" placeholder="至少6位字符">
</div>
<div class="form-group">
<label>确认密码 <span class="text-danger">*</span></label>
<input type="password" id="confirmPassword" class="form-control" placeholder="再次输入新密码">
</div>
<div id="resetPasswordError" class="alert alert-danger" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeResetPasswordModal()">取消</button>
<button class="btn btn-warning" onclick="submitResetPassword()">确认重置</button>
</div>
</div>
</div>
<!-- 修改昵称弹窗 -->
<div id="editUsernameModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h5>修改用户昵称</h5>
<button class="close-btn" onclick="closeEditUsernameModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>当前昵称</label>
<input type="text" class="form-control" value="{{ user.username }}" disabled>
</div>
<div class="form-group">
<label>新昵称 <span class="text-danger">*</span></label>
<input type="text" id="newUsername" class="form-control" placeholder="2-50个字符" value="{{ user.username }}">
</div>
<div id="editUsernameError" class="alert alert-danger" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeEditUsernameModal()">取消</button>
<button class="btn btn-primary" onclick="submitEditUsername()">确认修改</button>
</div>
</div>
</div>
<script>
// 重置密码弹窗
function showResetPasswordModal() {
document.getElementById('resetPasswordModal').style.display = 'flex';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
document.getElementById('resetPasswordError').style.display = 'none';
}
function closeResetPasswordModal() {
document.getElementById('resetPasswordModal').style.display = 'none';
}
function submitResetPassword() {
const newPassword = document.getElementById('newPassword').value.trim();
const confirmPassword = document.getElementById('confirmPassword').value.trim();
const errorDiv = document.getElementById('resetPasswordError');
if (!newPassword) {
errorDiv.textContent = '请输入新密码';
errorDiv.style.display = 'block';
return;
}
if (newPassword.length < 6) {
errorDiv.textContent = '密码至少需要6位字符';
errorDiv.style.display = 'block';
return;
}
if (newPassword !== confirmPassword) {
errorDiv.textContent = '两次输入的密码不一致';
errorDiv.style.display = 'block';
return;
}
fetch('/api/admin/users/{{ user.id }}/reset-password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ new_password: newPassword })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
closeResetPasswordModal();
} else {
errorDiv.textContent = data.message;
errorDiv.style.display = 'block';
}
})
.catch(error => {
errorDiv.textContent = '操作失败,请重试';
errorDiv.style.display = 'block';
});
}
// 修改昵称弹窗
function showEditUsernameModal() {
document.getElementById('editUsernameModal').style.display = 'flex';
document.getElementById('editUsernameError').style.display = 'none';
}
function closeEditUsernameModal() {
document.getElementById('editUsernameModal').style.display = 'none';
}
function submitEditUsername() {
const newUsername = document.getElementById('newUsername').value.trim();
const errorDiv = document.getElementById('editUsernameError');
if (!newUsername) {
errorDiv.textContent = '请输入新昵称';
errorDiv.style.display = 'block';
return;
}
if (newUsername.length < 2 || newUsername.length > 50) {
errorDiv.textContent = '昵称长度需要在2-50个字符之间';
errorDiv.style.display = 'block';
return;
}
fetch('/api/admin/users/{{ user.id }}/update-username', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ new_username: newUsername })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
errorDiv.textContent = data.message;
errorDiv.style.display = 'block';
}
})
.catch(error => {
errorDiv.textContent = '操作失败,请重试';
errorDiv.style.display = 'block';
});
}
window.onclick = function(event) {
const resetModal = document.getElementById('resetPasswordModal');
const editModal = document.getElementById('editUsernameModal');
if (event.target === resetModal) closeResetPasswordModal();
if (event.target === editModal) closeEditUsernameModal();
}
</script>
<style>
.user-detail-container {
max-width: 1200px;
}
.back-nav {
margin-bottom: 20px;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #F5F7FA;
border: 1px solid #DCDFE6;
border-radius: 4px;
color: #606266;
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.btn-back:hover {
background: #ECF2FE;
border-color: #0052D9;
color: #0052D9;
text-decoration: none;
}
.btn-back .material-symbols-outlined {
font-size: 18px;
}
.card {
background: white;
border: 1px solid #DCDFE6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #F0F0F0;
}
.card-header h5 {
font-size: 16px;
font-weight: 600;
color: #000000;
margin: 0;
}
.card-body {
padding: 20px;
}
.mb-4 {
margin-bottom: 24px;
}
.user-profile {
display: flex;
gap: 24px;
}
.user-avatar {
flex-shrink: 0;
}
.user-avatar img {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
background: #F5F7FA;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder .material-symbols-outlined {
font-size: 48px;
color: #C0C4CC;
}
.user-info-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.info-item label {
display: block;
font-size: 13px;
color: #909399;
margin-bottom: 6px;
}
.info-value {
font-size: 14px;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.btn-icon {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #909399;
display: inline-flex;
align-items: center;
border-radius: 4px;
transition: all 0.2s;
}
.btn-icon:hover {
background: #F5F7FA;
color: #0052D9;
}
.btn-icon .material-symbols-outlined {
font-size: 18px;
}
.text-muted {
color: #909399;
}
.badge {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.badge-success {
background: rgba(0, 168, 112, 0.1);
color: #00A870;
}
.badge-success-sm {
background: rgba(0, 168, 112, 0.1);
color: #00A870;
padding: 2px 6px;
}
.badge-success-sm .material-symbols-outlined {
font-size: 14px;
}
.badge-warning-sm {
background: rgba(227, 115, 24, 0.1);
color: #E37318;
padding: 2px 6px;
}
.badge-secondary {
background: #F5F5F5;
color: #606266;
}
.badge-secondary-sm {
background: #F5F5F5;
color: #606266;
padding: 2px 6px;
}
.badge-info-sm {
background: rgba(0, 82, 217, 0.1);
color: #0052D9;
padding: 2px 6px;
}
.row {
display: flex;
gap: 16px;
margin: 0 -8px;
}
.col-md-6 {
flex: 1;
padding: 0 8px;
}
.stat-card {
background: white;
border: 1px solid #DCDFE6;
border-radius: 6px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon .material-symbols-outlined {
font-size: 28px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #000000;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #606266;
}
.action-buttons {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
transition: all 0.2s;
}
.btn-warning {
background: #E37318;
color: white;
}
.btn-warning:hover {
background: #C96316;
}
.btn .material-symbols-outlined {
font-size: 18px;
}
.folders-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.folder-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #F5F7FA;
border: 1px solid #DCDFE6;
border-radius: 6px;
}
.folder-icon {
font-size: 24px;
}
.folder-info {
flex: 1;
}
.folder-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.folder-meta {
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 8px;
}
.p-0 {
padding: 0;
}
.table-responsive {
overflow-x: auto;
}
.table {
width: 100%;
}
.table thead th {
background: #F5F7FA;
color: #606266;
font-weight: 600;
font-size: 14px;
padding: 12px 16px;
border-bottom: 1px solid #DCDFE6;
}
.table tbody td {
padding: 12px 16px;
border-bottom: 1px solid #F0F0F0;
font-size: 14px;
color: #303133;
}
.table tbody tr:hover {
background: #F5F7FA;
}
.site-info {
display: flex;
align-items: center;
gap: 8px;
}
.site-logo {
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: cover;
}
.site-info a {
color: #0052D9;
text-decoration: none;
}
.site-info a:hover {
text-decoration: underline;
}
.folder-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #F5F7FA;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
/* 弹窗样式 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #F0F0F0;
}
.modal-header h5 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #000000;
}
.close-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #909399;
display: flex;
align-items: center;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: #F5F7FA;
color: #303133;
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #DCDFE6;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: #0052D9;
}
.form-control:disabled {
background: #F5F7FA;
color: #909399;
cursor: not-allowed;
}
.text-danger {
color: #D54941;
}
.alert {
padding: 12px;
border-radius: 4px;
font-size: 14px;
margin-top: 12px;
}
.alert-danger {
background: rgba(213, 73, 65, 0.1);
color: #D54941;
border: 1px solid rgba(213, 73, 65, 0.2);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #F0F0F0;
}
.btn-secondary {
background: #F5F5F5;
color: #606266;
}
.btn-secondary:hover {
background: #E5E5E5;
}
.btn-primary {
background: #0052D9;
color: white;
}
.btn-primary:hover {
background: #0041A8;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,383 @@
{% extends 'admin/master.html' %}
{% block body %}
<div class="users-container">
<!-- 页面标题和搜索 -->
<div class="page-header">
<div>
<h4 class="mb-1">用户管理</h4>
<p class="text-muted mb-0">管理平台注册用户</p>
</div>
<div class="header-actions">
<form method="GET" action="{{ url_for('admin_users') }}" class="search-form">
<div class="input-group">
<input type="text" name="search" class="form-control" placeholder="搜索用户名或邮箱" value="{{ search }}">
<button type="submit" class="btn btn-primary">
<span class="material-symbols-outlined">search</span>
</button>
{% if search %}
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">
<span class="material-symbols-outlined">close</span>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- 用户列表 -->
<div class="card">
<div class="card-body p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>收藏统计</th>
<th>注册时间</th>
<th>最后登录</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>
<div class="user-info">
<strong>{{ user.username }}</strong>
</div>
</td>
<td>
{% if user.email %}
<div class="email-info">
{{ user.email }}
{% if user.email_verified %}
<span class="badge badge-success-sm" title="邮箱已验证">
<span class="material-symbols-outlined" style="font-size: 14px;">verified</span>
</span>
{% endif %}
</div>
{% else %}
<span class="text-muted">未设置</span>
{% endif %}
</td>
<td>
<div class="stats-info">
<span class="stat-item">
<span class="material-symbols-outlined">bookmark</span>
{{ user_stats[user.id].collections_count }}
</span>
<span class="stat-item">
<span class="material-symbols-outlined">folder</span>
{{ user_stats[user.id].folders_count }}
</span>
</div>
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '从未登录' }}</td>
<td>
{% if user.is_active %}
<span class="badge badge-success">正常</span>
{% else %}
<span class="badge badge-secondary">已禁用</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin_user_detail', user_id=user.id) }}" class="btn btn-sm btn-primary">
查看详情
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<div class="pagination-container">
<nav>
<ul class="pagination mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_users', page=pagination.prev_num, search=search) }}">上一页</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">上一页</span>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_users', page=page_num, search=search) }}">{{ page_num }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_users', page=pagination.next_num, search=search) }}">下一页</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">下一页</span>
</li>
{% endif %}
</ul>
</nav>
<div class="pagination-info">
共 {{ pagination.total }} 个用户,第 {{ pagination.page }} / {{ pagination.pages }} 页
</div>
</div>
{% endif %}
{% else %}
<div class="empty-state">
<span class="material-symbols-outlined">person_off</span>
<p>{% if search %}未找到匹配的用户{% else %}暂无注册用户{% endif %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<style>
.users-container {
max-width: 1400px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 20px;
}
.page-header h4 {
font-size: 24px;
font-weight: 600;
color: #000000;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.search-form {
min-width: 350px;
}
.input-group {
display: flex;
gap: 8px;
}
.input-group .form-control {
flex: 1;
padding: 8px 12px;
border: 1px solid #DCDFE6;
border-radius: 4px;
font-size: 14px;
}
.input-group .btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-primary {
background: #0052D9;
color: white;
}
.btn-primary:hover {
background: #0041A8;
}
.btn-secondary {
background: #F5F5F5;
color: #606266;
}
.btn-secondary:hover {
background: #E5E5E5;
}
.card {
background: white;
border: 1px solid #DCDFE6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
}
.table {
width: 100%;
}
.table thead th {
background: #F5F7FA;
color: #606266;
font-weight: 600;
font-size: 14px;
padding: 12px 16px;
border-bottom: 1px solid #DCDFE6;
}
.table tbody td {
padding: 12px 16px;
border-bottom: 1px solid #F0F0F0;
font-size: 14px;
color: #303133;
}
.table tbody tr:hover {
background: #F5F7FA;
}
.user-info strong {
color: #000000;
}
.email-info {
display: flex;
align-items: center;
gap: 6px;
}
.stats-info {
display: flex;
gap: 12px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
}
.stat-item .material-symbols-outlined {
font-size: 16px;
}
.badge {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
font-weight: 500;
}
.badge-success {
background: rgba(0, 168, 112, 0.1);
color: #00A870;
}
.badge-success-sm {
background: rgba(0, 168, 112, 0.1);
color: #00A870;
padding: 2px 4px;
display: inline-flex;
align-items: center;
}
.badge-secondary {
background: #F5F5F5;
color: #606266;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-top: 1px solid #F0F0F0;
}
.pagination {
display: flex;
list-style: none;
padding: 0;
margin: 0;
gap: 4px;
}
.page-item .page-link {
padding: 6px 12px;
border: 1px solid #DCDFE6;
border-radius: 4px;
color: #606266;
text-decoration: none;
display: block;
}
.page-item.active .page-link {
background: #0052D9;
color: white;
border-color: #0052D9;
}
.page-item.disabled .page-link {
color: #C0C4CC;
cursor: not-allowed;
}
.page-item:not(.disabled):not(.active) .page-link:hover {
background: #F5F7FA;
}
.pagination-info {
color: #606266;
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #909399;
}
.empty-state .material-symbols-outlined {
font-size: 64px;
color: #DCDFE6;
margin-bottom: 16px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
</style>
{% endblock %}

View File

@@ -329,6 +329,7 @@
<div id="userDropdown" class="dropdown-menu" style="display: none;"> <div id="userDropdown" class="dropdown-menu" style="display: none;">
<a href="/user/profile">👤 个人中心</a> <a href="/user/profile">👤 个人中心</a>
<a href="/user/collections">⭐ 我的收藏</a> <a href="/user/collections">⭐ 我的收藏</a>
<a href="/user/change-password">🔒 修改密码</a>
<hr> <hr>
<a href="#" onclick="logout(event)">🚪 退出登录</a> <a href="#" onclick="logout(event)">🚪 退出登录</a>
</div> </div>

View File

@@ -1036,6 +1036,7 @@ function loadNewsWithCaptcha(siteCode, captcha) {
// 调用新闻获取API // 调用新闻获取API
fetch(`/api/fetch-news/${siteCode}`, { fetch(`/api/fetch-news/${siteCode}`, {
method: 'POST', method: 'POST',
credentials: 'same-origin', // 确保发送session cookie
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },

View File

@@ -0,0 +1,290 @@
{% extends 'base_new.html' %}
{% block title %}修改密码 - ZJPB{% endblock %}
{% block extra_css %}
<style>
.change-password-container {
max-width: 500px;
margin: 48px auto;
padding: 0 20px;
}
.password-card {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 32px;
}
.card-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.card-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 32px;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.password-input-wrapper {
position: relative;
}
.form-input {
width: 100%;
padding: 12px 40px 12px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-password:hover {
color: var(--primary-color);
}
.btn-primary {
width: 100%;
padding: 12px;
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
}
.btn-primary:disabled {
background: var(--border-color);
cursor: not-allowed;
transform: none;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
margin-bottom: 24px;
transition: color 0.2s;
}
.back-link:hover {
color: var(--primary-color);
}
.alert {
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 14px;
display: none;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
</style>
{% endblock %}
{% block content %}
<div class="change-password-container">
<a href="{{ url_for('user_profile') }}" class="back-link">
<span class="material-symbols-outlined" style="font-size: 18px;">arrow_back</span>
返回个人中心
</a>
<div class="password-card">
<h1 class="card-title">修改密码</h1>
<p class="card-subtitle">为了您的账户安全,请定期修改密码</p>
<div id="alert" class="alert"></div>
<form id="changePasswordForm">
<div class="form-group">
<label class="form-label" for="old_password">旧密码</label>
<div class="password-input-wrapper">
<input type="password" id="old_password" name="old_password" class="form-input" required>
<button type="button" class="toggle-password" onclick="togglePassword('old_password')">
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
</button>
</div>
</div>
<div class="form-group">
<label class="form-label" for="new_password">新密码</label>
<div class="password-input-wrapper">
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="6">
<button type="button" class="toggle-password" onclick="togglePassword('new_password')">
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
</button>
</div>
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 4px; display: block;">至少6个字符</small>
</div>
<div class="form-group">
<label class="form-label" for="confirm_password">确认新密码</label>
<div class="password-input-wrapper">
<input type="password" id="confirm_password" name="confirm_password" class="form-input" required minlength="6">
<button type="button" class="toggle-password" onclick="togglePassword('confirm_password')">
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
</button>
</div>
</div>
<button type="submit" class="btn-primary" id="submitBtn">
修改密码
</button>
</form>
</div>
</div>
<script>
// 密码可见性切换
function togglePassword(inputId) {
const input = document.getElementById(inputId);
const button = input.nextElementSibling;
const icon = button.querySelector('.material-symbols-outlined');
if (input.type === 'password') {
input.type = 'text';
icon.textContent = 'visibility_off';
} else {
input.type = 'password';
icon.textContent = 'visibility';
}
}
// 显示提示消息
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = `alert alert-${type}`;
alert.style.display = 'block';
// 3秒后自动隐藏
setTimeout(() => {
alert.style.display = 'none';
}, 3000);
}
// 表单提交
document.getElementById('changePasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const oldPassword = document.getElementById('old_password').value;
const newPassword = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
// 前端验证
if (newPassword !== confirmPassword) {
showAlert('两次输入的新密码不一致', 'error');
return;
}
if (newPassword.length < 6) {
showAlert('新密码长度至少6位', 'error');
return;
}
if (oldPassword === newPassword) {
showAlert('新密码不能与旧密码相同', 'error');
return;
}
// 禁用按钮
submitBtn.disabled = true;
submitBtn.textContent = '修改中...';
try {
const response = await fetch('/api/user/change-password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
confirm_password: confirmPassword
})
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
// 清空表单
document.getElementById('changePasswordForm').reset();
// 2秒后跳转到个人中心
setTimeout(() => {
window.location.href = '{{ url_for("user_profile") }}';
}, 2000);
} else {
showAlert(data.message, 'error');
submitBtn.disabled = false;
submitBtn.textContent = '修改密码';
}
} catch (error) {
showAlert('网络错误,请稍后重试', 'error');
submitBtn.disabled = false;
submitBtn.textContent = '修改密码';
}
});
</script>
{% endblock %}

View File

@@ -193,6 +193,7 @@
<ul class="nav-menu"> <ul class="nav-menu">
<li><a href="/user/profile" class="active">👤 个人资料</a></li> <li><a href="/user/profile" class="active">👤 个人资料</a></li>
<li><a href="/user/collections">⭐ 我的收藏</a></li> <li><a href="/user/collections">⭐ 我的收藏</a></li>
<li><a href="/user/change-password">🔒 修改密码</a></li>
</ul> </ul>
</div> </div>
@@ -213,6 +214,47 @@
</div> </div>
</div> </div>
<!-- 邮箱管理 -->
<div class="recent-section" style="margin-bottom: 32px;">
<h2>邮箱管理</h2>
<div style="background: white; padding: 20px; border: 1px solid var(--border-color); border-radius: var(--radius-md);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<div>
<div style="font-size: 14px; color: var(--text-secondary); margin-bottom: 4px;">当前邮箱</div>
<div style="font-size: 16px; font-weight: 600;" id="currentEmail">
{{ current_user.email or '未绑定' }}
</div>
</div>
{% if current_user.email %}
<div>
{% if current_user.email_verified %}
<span style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; background: #d1fae5; color: #065f46; border-radius: 12px; font-size: 12px;">
<span class="material-symbols-outlined" style="font-size: 16px;">check_circle</span>
已验证
</span>
{% else %}
<span style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; background: #fef3c7; color: #92400e; border-radius: 12px; font-size: 12px;">
<span class="material-symbols-outlined" style="font-size: 16px;">warning</span>
未验证
</span>
{% endif %}
</div>
{% endif %}
</div>
<div style="display: flex; gap: 12px;">
<button onclick="showEmailModal()" style="padding: 8px 16px; background: var(--primary-blue); color: white; border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 14px;">
{{ '修改邮箱' if current_user.email else '绑定邮箱' }}
</button>
{% if current_user.email and not current_user.email_verified %}
<button onclick="sendVerifyEmail()" id="verifyBtn" style="padding: 8px 16px; background: #f59e0b; color: white; border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 14px;">
发送验证邮件
</button>
{% endif %}
</div>
</div>
</div>
<!-- 最近收藏 --> <!-- 最近收藏 -->
<div class="recent-section"> <div class="recent-section">
<h2>最近收藏</h2> <h2>最近收藏</h2>
@@ -242,4 +284,146 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 邮箱管理弹窗 -->
<div id="emailModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 500px; width: 90%;">
<h2 style="font-size: 20px; font-weight: 700; margin-bottom: 24px;">{{ '修改邮箱' if current_user.email else '绑定邮箱' }}</h2>
<div id="emailAlert" style="display: none; padding: 12px; border-radius: var(--radius-md); margin-bottom: 16px; font-size: 14px;"></div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px;">邮箱地址</label>
<input type="email" id="emailInput" placeholder="请输入邮箱地址" style="width: 100%; padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: 14px;">
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button onclick="hideEmailModal()" style="padding: 10px 20px; background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-md); cursor: pointer;">
取消
</button>
<button onclick="updateEmail()" id="emailSubmitBtn" style="padding: 10px 20px; background: var(--primary-blue); color: white; border: none; border-radius: var(--radius-md); cursor: pointer;">
确定
</button>
</div>
</div>
</div>
<script>
// 显示邮箱弹窗
function showEmailModal() {
const modal = document.getElementById('emailModal');
const input = document.getElementById('emailInput');
input.value = '{{ current_user.email or "" }}';
modal.style.display = 'flex';
}
// 隐藏邮箱弹窗
function hideEmailModal() {
const modal = document.getElementById('emailModal');
modal.style.display = 'none';
document.getElementById('emailAlert').style.display = 'none';
}
// 显示弹窗提示
function showEmailAlert(message, type) {
const alert = document.getElementById('emailAlert');
alert.textContent = message;
alert.style.display = 'block';
if (type === 'success') {
alert.style.background = '#d1fae5';
alert.style.color = '#065f46';
alert.style.border = '1px solid #6ee7b7';
} else {
alert.style.background = '#fee2e2';
alert.style.color = '#991b1b';
alert.style.border = '1px solid #fca5a5';
}
}
// 更新邮箱
async function updateEmail() {
const email = document.getElementById('emailInput').value.trim();
const submitBtn = document.getElementById('emailSubmitBtn');
if (!email) {
showEmailAlert('请输入邮箱地址', 'error');
return;
}
// 验证邮箱格式
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailPattern.test(email)) {
showEmailAlert('邮箱格式不正确', 'error');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const response = await fetch('/api/user/email', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email })
});
const data = await response.json();
if (data.success) {
showEmailAlert(data.message, 'success');
setTimeout(() => {
location.reload();
}, 1500);
} else {
showEmailAlert(data.message, 'error');
submitBtn.disabled = false;
submitBtn.textContent = '确定';
}
} catch (error) {
showEmailAlert('网络错误,请稍后重试', 'error');
submitBtn.disabled = false;
submitBtn.textContent = '确定';
}
}
// 发送验证邮件
async function sendVerifyEmail() {
const btn = document.getElementById('verifyBtn');
btn.disabled = true;
btn.textContent = '发送中...';
try {
const response = await fetch('/api/user/send-verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
alert(data.message);
} else {
alert(data.message);
btn.disabled = false;
btn.textContent = '发送验证邮件';
}
} catch (error) {
alert('网络错误,请稍后重试');
btn.disabled = false;
btn.textContent = '发送验证邮件';
}
}
// 点击弹窗外部关闭
document.getElementById('emailModal').addEventListener('click', function(e) {
if (e.target === this) {
hideEmailModal();
}
});
</script>
{% endblock %} {% endblock %}

41
update.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# ZJPB 更新部署脚本
# 用法: ./update.sh
# 功能: git pull 拉取最新代码,然后重启服务
set -e
APP_DIR="/opt/1panel/apps/zjpb"
SERVICE_NAME="zjpb"
echo "=============================="
echo " ZJPB 更新部署"
echo "=============================="
cd "$APP_DIR"
# 1. 拉取最新代码
echo "[1/3] 拉取最新代码..."
git pull
echo " 完成"
# 2. 重启服务
echo "[2/3] 重启服务..."
systemctl restart "$SERVICE_NAME"
echo " 完成"
# 3. 验证服务状态
echo "[3/3] 验证服务状态..."
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo " 服务运行正常"
else
echo " [错误] 服务启动失败,查看日志:"
journalctl -u "$SERVICE_NAME" -n 20 --no-pager
exit 1
fi
echo ""
echo "=============================="
echo " 部署完成"
echo "=============================="

73
utils/email_sender.py Normal file
View File

@@ -0,0 +1,73 @@
"""
邮件发送工具模块
支持发送验证邮件、通知邮件等
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
import logging
logger = logging.getLogger(__name__)
class EmailSender:
"""邮件发送器"""
def __init__(self):
"""初始化邮件配置"""
self.smtp_server = os.environ.get('SMTP_SERVER', 'smtp.gmail.com')
self.smtp_port = int(os.environ.get('SMTP_PORT', '587'))
self.smtp_user = os.environ.get('SMTP_USER', '')
self.smtp_password = os.environ.get('SMTP_PASSWORD', '')
self.from_email = os.environ.get('FROM_EMAIL', self.smtp_user)
self.from_name = os.environ.get('FROM_NAME', 'ZJPB')
def send_email(self, to_email, subject, html_content, text_content=None):
"""
发送邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
html_content: HTML格式邮件内容
text_content: 纯文本格式邮件内容(可选)
Returns:
bool: 发送是否成功
"""
try:
# 检查配置
if not self.smtp_user or not self.smtp_password:
logger.error('邮件配置不完整请设置SMTP_USER和SMTP_PASSWORD环境变量')
return False
# 创建邮件对象
message = MIMEMultipart('alternative')
message['From'] = f'{self.from_name} <{self.from_email}>'
message['To'] = to_email
message['Subject'] = Header(subject, 'utf-8')
# 添加纯文本内容
if text_content:
text_part = MIMEText(text_content, 'plain', 'utf-8')
message.attach(text_part)
# 添加HTML内容
html_part = MIMEText(html_content, 'html', 'utf-8')
message.attach(html_part)
# 连接SMTP服务器并发送
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls() # 启用TLS加密
server.login(self.smtp_user, self.smtp_password)
server.send_message(message)
logger.info(f'邮件发送成功: {to_email}')
return True
except Exception as e:
logger.error(f'邮件发送失败: {str(e)}')
return False