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>
This commit is contained in:
Jowe
2026-02-07 23:26:02 +08:00
parent 1be1f35568
commit c61969dfc9
9 changed files with 1242 additions and 1 deletions

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

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

241
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():

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("✓ 添加 email_verified 字段")
# 添加 email_verified_at 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间'
"""))
conn.commit()
print("✓ 添加 email_verified_at 字段")
# 添加 email_verify_token 字段
conn.execute(db.text("""
ALTER TABLE users
ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌'
"""))
conn.commit()
print("✓ 添加 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("✓ 添加 email_verify_token_expires 字段")
print("\n✅ 邮箱验证字段迁移完成!")
else:
print("⚠️ 邮箱验证字段已存在,跳过迁移")
except Exception as e:
print(f"❌ 迁移失败: {str(e)}")
raise
if __name__ == '__main__':
migrate()

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

@@ -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

@@ -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 %}

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