commit 67d19911b73171e4504bd2d48a0129a0a0f6cb46 Author: superlishunqin <852326703@qq.com> Date: Mon Sep 15 00:15:37 2025 +0800 develop-progress-1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab781c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Flask配置 +FLASK_ENV=development +SECRET_KEY=your-secret-key-here-change-in-production +PORT=5000 +# 数据库配置 +MYSQL_HOST=119.91.236.167 +MYSQL_PORT=3306 +MYSQL_USER=Language_learning +MYSQL_PASSWORD=cosyvoice +MYSQL_DB=language_learning +# 邮箱配置 +EMAIL_HOST=smtp.qq.com +EMAIL_PORT=587 +EMAIL_ENCRYPTION=starttls +EMAIL_USERNAME=3399560459@qq.com +EMAIL_PASSWORD=fzwhyirhbqdzcjgf +EMAIL_FROM=3399560459@qq.com +EMAIL_FROM_NAME=儿童语言学习系统 +# 其他配置 +MAX_CONTENT_LENGTH=104857600 +VERIFICATION_CODE_EXPIRE_MINUTES=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d97f086 --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ +.env +.ENV/ +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +# Flask +instance/ +.webassets-cache +# Database +*.db +*.sqlite +*.sqlite3 +# Logs +logs/*.log +*.log +# Uploads +uploads/ +tmp/ +# Docker +.dockerignore +Dockerfile +docker-compose.yml +# Environment variables +.env +.env.local +.env.production +# Coverage +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json +# Backup files +*.bak +*.backup +*.orig +# Temporary files +*.tmp +*.temp diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..251b350 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.16 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f298a8e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,31 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from config import config + +# 初始化扩展 +db = SQLAlchemy() +login_manager = LoginManager() + +def create_app(config_name=None): + app = Flask(__name__) + + # 加载配置 + config_name = config_name or 'default' + app.config.from_object(config[config_name]) + + # 初始化扩展 + db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = 'auth.login' + login_manager.login_message = '请先登录访问此页面' + login_manager.login_message_category = 'info' + + # 注册蓝图 + from app.routes.auth import auth_bp + from app.routes.main import main_bp + + app.register_blueprint(main_bp) + app.register_blueprint(auth_bp, url_prefix='/auth') + + return app diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..799da17 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,85 @@ +from datetime import datetime, timedelta +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from app import db +import random +import string + +class User(UserMixin, db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(100), nullable=False) + age = db.Column(db.SmallInteger, nullable=False) + gender = db.Column(db.SmallInteger, nullable=False, comment='0-男, 1-女') + parent_contact = db.Column(db.String(255), nullable=True, comment='家长联系方式') + is_verified = db.Column(db.Boolean, default=False, comment='邮箱是否验证') + created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def set_password(self, password): + """设置密码""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """验证密码""" + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + +class EmailVerification(db.Model): + __tablename__ = 'email_verifications' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), nullable=False, index=True) + verification_code = db.Column(db.String(6), nullable=False, comment='6位数字验证码') + expires_at = db.Column(db.DateTime, nullable=False, index=True) + is_used = db.Column(db.Boolean, default=False, comment='是否已使用') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + @classmethod + def generate_code(cls, email, expire_minutes=5): + """生成验证码""" + # 清理过期的验证码 + cls.query.filter( + cls.email == email, + cls.expires_at < datetime.utcnow() + ).delete() + + # 生成6位数字验证码 + code = ''.join(random.choices(string.digits, k=6)) + expires_at = datetime.utcnow() + timedelta(minutes=expire_minutes) + + verification = cls( + email=email, + verification_code=code, + expires_at=expires_at + ) + + db.session.add(verification) + db.session.commit() + + return code + + @classmethod + def verify_code(cls, email, code): + """验证验证码""" + verification = cls.query.filter( + cls.email == email, + cls.verification_code == code, + cls.expires_at > datetime.utcnow(), + cls.is_used == False + ).first() + + if verification: + verification.is_used = True + db.session.commit() + return True + return False + + def __repr__(self): + return f'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..1efa489 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# 路由包初始化文件 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..9f81f87 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,216 @@ +from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify +from flask_login import login_user, logout_user, login_required, current_user +from app.models import User, EmailVerification +from app import db, login_manager +from utils import send_verification_email, send_password_reset_email +import re + +auth_bp = Blueprint('auth', __name__) + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + """用户注册""" + if request.method == 'POST': + try: + # 获取表单数据 + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '').strip() + confirm_password = request.form.get('confirm_password', '').strip() + name = request.form.get('name', '').strip() + age = request.form.get('age', '').strip() + gender = request.form.get('gender', '').strip() + parent_contact = request.form.get('parent_contact', '').strip() + verification_code = request.form.get('verification_code', '').strip() + + # 验证必填字段 + if not all([email, password, confirm_password, name, age, gender, verification_code]): + flash('请填写所有必填字段', 'error') + return render_template('auth/register.html') + + # 邮箱格式验证 + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, email): + flash('请输入有效的邮箱地址', 'error') + return render_template('auth/register.html') + + # 密码验证 + if len(password) < 6: + flash('密码长度至少6位', 'error') + return render_template('auth/register.html') + + if password != confirm_password: + flash('两次输入的密码不一致', 'error') + return render_template('auth/register.html') + + # 验证年龄 + try: + age = int(age) + if age < 3 or age > 6: + flash('年龄必须在3-6岁之间', 'error') + return render_template('auth/register.html') + except ValueError: + flash('请输入有效的年龄', 'error') + return render_template('auth/register.html') + + # 验证性别 + if gender not in ['0', '1']: + flash('请选择性别', 'error') + return render_template('auth/register.html') + + # 检查邮箱是否已注册 + existing_user = User.query.filter_by(email=email).first() + if existing_user: + flash('该邮箱已被注册', 'error') + return render_template('auth/register.html') + + # 验证邮箱验证码 + if not EmailVerification.verify_code(email, verification_code): + flash('验证码错误或已过期', 'error') + return render_template('auth/register.html') + + # 创建新用户 + new_user = User( + email=email, + name=name, + age=age, + gender=int(gender), + parent_contact=parent_contact if parent_contact else None, + is_verified=True # 通过邮箱验证码验证,标记为已验证 + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + flash('注册成功!请登录', 'success') + return redirect(url_for('auth.login')) + + except Exception as e: + db.session.rollback() + flash('注册失败,请重试', 'error') + return render_template('auth/register.html') + + return render_template('auth/register.html') + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """用户登录""" + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '').strip() + remember = bool(request.form.get('remember')) + + if not email or not password: + flash('请输入邮箱和密码', 'error') + return render_template('auth/login.html') + + user = User.query.filter_by(email=email).first() + + if user and user.check_password(password): + login_user(user, remember=remember) + flash(f'欢迎回来,{user.name}!', 'success') + + # 重定向到之前访问的页面,如果没有则到主页 + next_page = request.args.get('next') + if next_page: + return redirect(next_page) + return redirect(url_for('main.dashboard')) + else: + flash('邮箱或密码错误', 'error') + + return render_template('auth/login.html') + +@auth_bp.route('/logout') +@login_required +def logout(): + """用户登出""" + logout_user() + flash('已成功登出', 'success') + return redirect(url_for('main.index')) + +@auth_bp.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + """忘记密码""" + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + verification_code = request.form.get('verification_code', '').strip() + new_password = request.form.get('new_password', '').strip() + confirm_password = request.form.get('confirm_password', '').strip() + + # 检查用户是否存在 + user = User.query.filter_by(email=email).first() + if not user: + flash('该邮箱未注册', 'error') + return render_template('auth/forgot_password.html') + + # 如果是重置密码请求 + if verification_code and new_password: + if len(new_password) < 6: + flash('密码长度至少6位', 'error') + return render_template('auth/forgot_password.html') + + if new_password != confirm_password: + flash('两次输入的密码不一致', 'error') + return render_template('auth/forgot_password.html') + + # 验证验证码 + if not EmailVerification.verify_code(email, verification_code): + flash('验证码错误或已过期', 'error') + return render_template('auth/forgot_password.html') + + # 更新密码 + user.set_password(new_password) + db.session.commit() + + flash('密码重置成功,请使用新密码登录', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/forgot_password.html') + +@auth_bp.route('/send-verification-code', methods=['POST']) +def send_verification_code(): + """发送邮箱验证码""" + try: + email = request.json.get('email', '').strip().lower() + code_type = request.json.get('type', 'register') # register 或 reset_password + + if not email: + return jsonify({'success': False, 'message': '请输入邮箱地址'}) + + # 邮箱格式验证 + 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': '请输入有效的邮箱地址'}) + + # 根据类型检查邮箱 + if code_type == 'register': + # 注册时检查邮箱是否已存在 + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return jsonify({'success': False, 'message': '该邮箱已被注册'}) + elif code_type == 'reset_password': + # 重置密码时检查邮箱是否存在 + existing_user = User.query.filter_by(email=email).first() + if not existing_user: + return jsonify({'success': False, 'message': '该邮箱未注册'}) + + # 生成验证码 + verification_code = EmailVerification.generate_code(email, expire_minutes=5) + + # 发送邮件 + if code_type == 'register': + success = send_verification_email(email, verification_code) + else: + success = send_password_reset_email(email, verification_code) + + if success: + return jsonify({'success': True, 'message': '验证码已发送到您的邮箱,5分钟内有效'}) + else: + return jsonify({'success': False, 'message': '验证码发送失败,请重试'}) + + except Exception as e: + return jsonify({'success': False, 'message': '发送失败,请重试'}) diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..df983ce --- /dev/null +++ b/app/routes/main.py @@ -0,0 +1,17 @@ +from flask import Blueprint, render_template +from flask_login import current_user + +main_bp = Blueprint('main', __name__) + +@main_bp.route('/') +def index(): + """首页""" + return render_template('index.html') + +@main_bp.route('/dashboard') +def dashboard(): + """用户主页(需要登录)""" + if current_user.is_authenticated: + return render_template('dashboard.html') + else: + return render_template('index.html') diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..4669ecd --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,470 @@ +/* 全局样式 */ +:root { + --primary-color: #4a90e2; + --secondary-color: #7b68ee; + --success-color: #28a745; + --warning-color: #ffc107; + --danger-color: #dc3545; + --info-color: #17a2b8; + --light-color: #f8f9fa; + --dark-color: #343a40; + --pink-color: #e91e63; + + /* === Kid-Friendly Colors === */ + --kid-primary: #FFC107; /* Amber */ + --kid-secondary: #00BCD4; /* Cyan */ + --kid-accent: #FF5722; /* Deep Orange */ + --kid-bg: #F0F8FF; /* AliceBlue */ + --kid-text: #5D4037; /* Brown */ +} + +body { + font-family: 'Helvetica Neue', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background-color: #ffffff; + color: #444; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +main { + flex-grow: 1; +} + +/* 导航栏 */ +.navbar-brand { + font-size: 1.5rem; + font-weight: 700; +} +.navbar-brand i { + color: var(--primary-color); +} + +/* === Kid-Friendly Auth Styles === */ +.auth-page-container { + background-color: var(--kid-bg); + background-image: + radial-gradient(circle at 20% 20%, rgba(0, 188, 212, 0.1) 8%, transparent 0), + radial-gradient(circle at 80% 70%, rgba(255, 193, 7, 0.1) 8%, transparent 0); + background-size: 250px 250px; +} +.kid-auth-card { + border: none; + border-radius: 20px; + overflow: hidden; + background-color: #ffffff; +} +.kid-auth-header { + background: linear-gradient(135deg, var(--kid-primary), #FFD54F); + color: white; + padding: 2rem 1.5rem; + text-align: center; + border-bottom: 5px solid #ffb300; + position: relative; + clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%); + margin-bottom: -2rem; +} +.kid-auth-header .icon { + font-size: 3.5rem; + margin-bottom: 0.5rem; + transform: rotate(-10deg) scale(1.1); + display: inline-block; + color: white; + text-shadow: 2px 2px 5px rgba(0,0,0,0.25); +} +.kid-auth-header h2 { + font-weight: 900; + font-size: 2rem; + margin: 0; + letter-spacing: 1px; +} +.kid-auth-card .card-body { + padding-top: 3rem !important; +} +.kid-auth-card .form-label { + font-weight: 600; + color: var(--kid-text); + margin-bottom: 0.25rem; +} +.kid-auth-card .form-control, .kid-auth-card .form-select { + border-radius: 30px; + padding-left: 20px; + padding-top: .6rem; + padding-bottom: .6rem; + border: 2px solid #e0e0e0; + transition: all 0.3s ease; +} +.kid-auth-card .form-control:focus, .kid-auth-card .form-select:focus { + border-color: var(--kid-secondary); + box-shadow: 0 0 0 0.2rem rgba(0,188,212, 0.2); +} +.kid-auth-card .input-group-text { + border-radius: 30px 0 0 30px; + background-color: #f5f5f5; + border: 2px solid #e0e0e0; border-right: none; + color: var(--kid-secondary); +} +.kid-auth-card .input-group .form-control { border-radius: 0 30px 30px 0; } +.kid-auth-card .input-group .btn { + border-radius: 0 30px 30px 0 !important; + border-color: #e0e0e0; + box-shadow: none; +} +.kid-auth-card .btn-primary, .btn-kid-accent { + background: linear-gradient(45deg, var(--kid-accent), #FF8A65); + border: none; + border-radius: 30px; + font-weight: 700; + padding: 12px 24px; + font-size: 1.1rem; + box-shadow: 0 4px 12px rgba(255, 87, 34, 0.3); + text-transform: uppercase; + letter-spacing: 1px; + color: white !important; +} +.kid-auth-card .btn-primary:hover, .btn-kid-accent:hover { + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(255, 87, 34, 0.45); +} +.kid-auth-card a { + color: var(--kid-secondary); + text-decoration: none !important; + transition: color .2s; +} +.kid-auth-card a:hover { color: var(--kid-accent); } + +/* === 修复后的轮播图样式 === */ +.hero-carousel-fixed { + position: relative; + overflow: hidden; +} + +.hero-carousel-fixed .carousel-item { + height: 70vh; + min-height: 500px; + position: relative; +} + +.hero-carousel-fixed .carousel-item img { + object-fit: cover; + width: 100%; + height: 100%; +} + +.carousel-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 1; +} + +/* 漂浮装饰元素 */ +.floating-decorations { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + pointer-events: none; +} + +.float-emoji { + position: absolute; + font-size: 2.5rem; + animation: floatAnimation 4s ease-in-out infinite; +} + +.float-emoji:nth-child(1) { top: 15%; left: 10%; animation-delay: 0s; } +.float-emoji:nth-child(2) { top: 25%; right: 15%; animation-delay: 1s; } +.float-emoji:nth-child(3) { bottom: 30%; left: 15%; animation-delay: 2s; } +.float-emoji:nth-child(4) { bottom: 20%; right: 10%; animation-delay: 3s; } + +@keyframes floatAnimation { + 0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.7; } + 50% { transform: translateY(-20px) rotate(5deg); opacity: 1; } +} + +/* 轮播内容样式 */ +.hero-carousel-fixed .carousel-caption { + position: absolute; + bottom: 20%; + left: 50%; + transform: translateX(-50%); + z-index: 10; + color: white; + text-align: center; + max-width: 800px; + width: 90%; +} + +.hero-badge { + display: inline-block; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.hero-title { + font-size: 3.5rem; + font-weight: 900; + line-height: 1.1; + margin-bottom: 1.5rem; + text-shadow: 2px 2px 10px rgba(0,0,0,0.5); +} + +.hero-title .highlight { + background: linear-gradient(45deg, #FFD700, #FFA500); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + position: relative; +} + +.hero-subtitle { + font-size: 1.25rem; + line-height: 1.6; + margin-bottom: 2.5rem; + opacity: 0.95; + text-shadow: 1px 1px 5px rgba(0,0,0,0.5); +} + +.btn-hero { + background: linear-gradient(45deg, #FF6B6B, #4ECDC4); + border: none; + padding: 15px 35px; + border-radius: 50px; + color: white; + font-size: 1.1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + box-shadow: 0 8px 25px rgba(0,0,0,0.3); + transition: all 0.3s ease; + text-decoration: none !important; +} + +.btn-hero:hover { + transform: translateY(-3px) scale(1.05); + box-shadow: 0 12px 35px rgba(0,0,0,0.4); + color: white; +} + +/* 轮播指示器和控制器 */ +.hero-carousel-fixed .carousel-indicators { + bottom: 30px; + z-index: 15; +} + +.hero-carousel-fixed .carousel-indicators button { + width: 15px !important; + height: 15px !important; + border-radius: 50% !important; + margin: 0 10px !important; + background: rgba(255, 255, 255, 0.5) !important; + border: 2px solid white !important; + transition: all 0.3s ease; +} + +.hero-carousel-fixed .carousel-indicators .active { + background: white !important; + transform: scale(1.2); +} + +.hero-carousel-fixed .carousel-control-prev, +.hero-carousel-fixed .carousel-control-next { + z-index: 15; + width: 60px; + opacity: 0.8; +} + +.hero-carousel-fixed .carousel-control-prev:hover, +.hero-carousel-fixed .carousel-control-next:hover { + opacity: 1; +} + +/* 波浪分隔符 */ +.wavy-divider { + height: 100px; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320'%3e%3cpath fill='%23ffffff' fill-opacity='1' d='M0,192L80,176C160,160,320,128,480,133.3C640,139,800,181,960,186.7C1120,192,1280,160,1360,144L1440,128L1440,0L1360,0C1280,0,1120,0,960,0C800,0,640,0,480,0C320,0,160,0,80,0L0,0Z'%3e%3c/path%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: cover; + margin-top: -100px; + position: relative; + z-index: 10; +} + +/* 页面区块通用样式 */ +.page-section { + padding: 80px 0; +} +.bg-light-kid { + background-color: #fefcfa; +} +.section-title { + text-align: center; + margin-bottom: 50px; +} +.section-title h2 { + font-size: 2.5rem; + font-weight: 800; + color: var(--kid-text); + position: relative; + display: inline-block; + padding-bottom: 10px; +} +.section-title h2::after { + content: ''; + display: block; + width: 60px; + height: 5px; + background: linear-gradient(to right, var(--kid-primary), var(--kid-accent)); + border-radius: 5px; + margin: 10px auto 0; +} +.section-title p { + color: #777; + font-size: 1.1rem; +} + +/* 新特色卡片 */ +.kid-feature-card { + background: #fff; + border-radius: 15px; + padding: 30px; + text-align: center; + box-shadow: 0 10px 30px rgba(0,0,0,0.05); + transition: all 0.3s ease; + height: 100%; +} +.kid-feature-card:hover { + transform: translateY(-8px); + box-shadow: 0 15px 40px rgba(0,0,0,0.1); +} +.kid-feature-card .icon-bubble { + width: 80px; + height: 80px; + border-radius: 50%; + margin: 0 auto 20px; + display: flex; + align-items: center; + justify-content: center; +} +.kid-feature-card .icon-bubble i { + font-size: 2.5rem; +} +.kid-feature-card h5 { + font-size: 1.25rem; + font-weight: 700; + color: var(--kid-text); + margin-bottom: 10px; +} +.kid-feature-card p { + font-size: 0.9rem; + color: #666; +} + +/* 新场景卡片 */ +.scenario-card-new { + background-color: #fff; + border-radius: 20px; + padding: 2rem; + text-align: center; + box-shadow: 0 8px 25px rgba(0,0,0,0.07); + transition: all 0.3s ease-in-out; +} +.scenario-card-new:hover { + transform: scale(1.05); + box-shadow: 0 12px 35px rgba(0,0,0,0.12); +} +.scenario-card-new .scenario-icon { + width: 70px; + height: 70px; + border-radius: 50%; + margin: 0 auto 1.5rem; + background-color: #e3f2fd; + color: #2196F3; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; +} +.scenario-card-new h5 { + font-weight: 700; + margin-bottom: 1rem; +} +.scenario-card-new p { + color: #777; + font-size: 0.95rem; + min-height: 60px; +} +.scenario-card-new .tags { + margin-top: 1rem; +} +.scenario-card-new .tag { + background-color: #eee; + color: #555; + padding: 5px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin: 0 5px; + display: inline-block; +} + +/* 新CTA区块 */ +.cta-section-kid { + background: linear-gradient(135deg, var(--kid-secondary), var(--kid-primary)); + color: white; + padding: 100px 0; +} +.cta-section-kid h2 { + font-size: 2.8rem; + font-weight: 800; + text-shadow: 1px 1px 3px rgba(0,0,0,0.2); +} +.cta-section-kid p { + font-size: 1.2rem; + opacity: 0.9; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .hero-carousel-fixed .carousel-item { + height: 60vh; + min-height: 400px; + } + + .hero-title { + font-size: 2.5rem; + } + + .hero-subtitle { + font-size: 1.1rem; + } + + .float-emoji { + font-size: 1.8rem; + } + + .btn-hero { + padding: 12px 25px; + font-size: 1rem; + } + + .hero-carousel-fixed .carousel-caption { + bottom: 15%; + width: 95%; + } +} + +.text-pink { + color: var(--pink-color) !important; +} diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..1cb17b5 --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,177 @@ +// 全局JavaScript功能 +document.addEventListener('DOMContentLoaded', function() { + // 自动隐藏提示消息 + const alerts = document.querySelectorAll('.alert'); + alerts.forEach(function(alert) { + setTimeout(function() { + alert.style.opacity = '0'; + setTimeout(function() { + alert.style.display = 'none'; + }, 300); + }, 5000); // 5秒后自动隐藏 + }); + // 平滑滚动 + const links = document.querySelectorAll('a[href^="#"]'); + links.forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); + }); + // 表单验证增强 + const forms = document.querySelectorAll('form'); + forms.forEach(function(form) { + form.addEventListener('submit', function(e) { + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn && !submitBtn.disabled) { + // 防止重复提交 + setTimeout(function() { + submitBtn.disabled = true; + }, 100); + } + }); + }); + // 输入框焦点效果 + const inputs = document.querySelectorAll('.form-control'); + inputs.forEach(function(input) { + input.addEventListener('focus', function() { + this.parentNode.classList.add('focused'); + }); + + input.addEventListener('blur', function() { + this.parentNode.classList.remove('focused'); + }); + }); + // 工具提示初始化 + if (typeof bootstrap !== 'undefined') { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function(tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); + // 弹出框初始化 + const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); + popoverTriggerList.map(function(popoverTriggerEl) { + return new bootstrap.Popover(popoverTriggerEl); + }); + } + // 邮箱验证码倒计时功能 + window.startVerificationCountdown = function(buttonId, duration = 60) { + const button = document.getElementById(buttonId); + if (!button) return; + + let count = duration; + const originalText = button.textContent; + + button.disabled = true; + + const timer = setInterval(function() { + button.textContent = `${count}秒后重试`; + count--; + + if (count < 0) { + clearInterval(timer); + button.disabled = false; + button.textContent = originalText === '发送验证码' ? '重新发送' : originalText; + } + }, 1000); + + return timer; + }; + // AJAX请求封装 + window.sendAjaxRequest = function(url, data, successCallback, errorCallback) { + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + if (successCallback) successCallback(data); + } else { + if (errorCallback) errorCallback(data.message || '请求失败'); + } + }) + .catch(error => { + console.error('请求错误:', error); + if (errorCallback) errorCallback('网络错误,请检查连接'); + }); + }; + // 显示加载状态 + window.showLoading = function(button, loadingText = '处理中...') { + if (!button) return; + + button.disabled = true; + const originalHTML = button.innerHTML; + button.innerHTML = `${loadingText}`; + + return function() { + button.disabled = false; + button.innerHTML = originalHTML; + }; + }; + // 显示消息提示 + window.showMessage = function(message, type = 'info') { + const alertContainer = document.querySelector('.container'); + if (!alertContainer) return; + const alertDiv = document.createElement('div'); + alertDiv.className = `alert alert-${type} alert-dismissible fade show`; + alertDiv.innerHTML = ` + + ${message} + + `; + + alertContainer.insertBefore(alertDiv, alertContainer.firstChild); + + // 自动隐藏 + setTimeout(function() { + alertDiv.style.opacity = '0'; + setTimeout(function() { + if (alertDiv.parentNode) { + alertDiv.parentNode.removeChild(alertDiv); + } + }, 300); + }, 5000); + }; + // 验证邮箱格式 + window.validateEmail = function(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + // 验证密码强度 + window.validatePassword = function(password) { + if (password.length < 6) { + return { valid: false, message: '密码长度至少6位' }; + } + return { valid: true, message: '密码强度可以' }; + }; + // 数字输入限制 + window.restrictToNumbers = function(inputElement) { + inputElement.addEventListener('input', function(e) { + e.target.value = e.target.value.replace(/[^0-9]/g, ''); + }); + }; + // 初始化数字验证码输入框 + const codeInputs = document.querySelectorAll('input[name="verification_code"]'); + codeInputs.forEach(function(input) { + window.restrictToNumbers(input); + }); +}); +// 全局错误处理 +window.addEventListener('error', function(e) { + console.error('全局错误:', e.error); +}); +// 全局未处理的Promise拒绝 +window.addEventListener('unhandledrejection', function(e) { + console.error('未处理的Promise拒绝:', e.reason); +}); diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..2fbd7da --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,206 @@ +{% extends "base.html" %} + +{% block title %}找回密码 - 儿童语言学习系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +

忘记密码了?

+
+
+

别担心,我们用魔法帮你找回!

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ + +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..510293e --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} + +{% block title %}登录 - 儿童语言学习系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +

欢迎回来!

+
+
+

继续你的语言学习大冒险!

+ +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+
+ + +
+ 忘记密码? +
+ + +
+ +
+ +
+

还没有账户? + + 立即加入 + +

+
+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..6007032 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,261 @@ +{% extends "base.html" %} + +{% block title %}注册 - 儿童语言学习系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +

加入我们!

+
+
+

开启一段奇妙的语言学习之旅!

+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+ + + + + +
+
+
+ +
+ + + + + +
+
+
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+

已有账户? + + 马上登录 + +

+
+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..66679a4 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,99 @@ + + + + + + {% block title %}儿童语言学习系统{% endblock %} + + + + + + + + + {% block head %}{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + +
+ {% block content %}{% endblock %} +
+ + +
+
+

+ © 2025 儿童语言学习系统 - 让孩子快乐学语言 +

+
+
+ + + + + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..f5d61b2 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,272 @@ +{% extends "base.html" %} + +{% block title %}学习主页 - 儿童语言学习系统{% endblock %} + +{% block content %} +
+ +
+
+
+
+
+

+ + 早安,{{ current_user.name }}小朋友! +

+

+ 准备好今天的语言学习之旅了吗?让我们一起探索有趣的世界吧! +

+
+
+ +
+
+
+
+
+ + +
+
+

+ 快速开始 +

+
+
+ +
+
+
+
+
+
+ +
+
录制声音
+

+ 上传你的声音样本,让AI学会说话像你一样 +

+ +
+ 即将上线 +
+
+
+
+
+ +
+
+
+
+
+ +
+
开始对话
+

+ 选择有趣的场景,和AI朋友一起聊天学习 +

+ +
+ 即将上线 +
+
+
+
+
+ +
+
+
+
+
+ +
+
查看进度
+

+ 了解你的学习情况,看看哪里可以做得更好 +

+ +
+ 即将上线 +
+
+
+
+
+
+ + +
+
+

+ 学习成就 +

+
+
+ +
+
+
+
+
+ +

0

+ 学习时长(分钟) +
+
+
+
+ +
+
+
+
+ +

0

+ 对话次数 +
+
+
+
+ +
+
+
+
+ +

0

+ 获得星星 +
+
+
+
+ +
+
+
+
+ +

0

+ 完成场景 +
+
+
+
+
+ + +
+
+

+ 推荐场景 +

+
+
+ +
+
+
+
+
+
+
+ +
+
+
和小明交朋友
+ 社交互动 +
+ 简单 +
+

+ 学习如何与新朋友进行自我介绍和基础交流 +

+
+
+ + 0人完成 + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
餐厅点餐
+ 日常生活 +
+ 中等 +
+

+ 学习在餐厅如何礼貌地点餐和与服务员交流 +

+
+
+ + 0人完成 + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
邀请朋友玩游戏
+ 游戏娱乐 +
+ 中等 +
+

+ 学习如何邀请朋友一起玩游戏并协调活动 +

+
+
+ + 0人完成 + +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f6b7c9b --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} + +{% block title %}首页 - 儿童语言学习系统{% endblock %} + +{% block content %} + + + + +
+ + +
+
+
+

为什么选择我们?

+

创新技术 × 教育理念 = 完美的学习体验

+
+
+
+
+
+
语音克隆技术
+

克隆孩子的声音,听到"自己"说话,激发学习兴趣。

+
+
+
+
+
+
智能对话系统
+

AI老师提供个性化、有趣的对话学习体验。

+
+
+
+
+
+
科学评估体系
+

基于官方指南的四维度评估,科学跟踪进展。

+
+
+
+
+
+
场景化学习
+

丰富的生活场景,在真实对话中提升语言能力。

+
+
+
+
+
+ + +
+
+
+

丰富的学习场景

+

在游戏中学习,在对话中成长

+
+
+
+
+
+
社交互动
+

学习如何交朋友、邀请游戏,培养社交语言技能。

+
交朋友团队合作
+
+
+
+
+
+
日常生活
+

模拟餐厅点餐、超市购物等真实生活情景。

+
点餐购物
+
+
+
+
+
+
游戏娱乐
+

在角色扮演和趣味问答中,快乐地组织语言。

+
益智游戏角色扮演
+
+
+
+
+
+ + +
+
+

准备好开始奇妙的语言之旅了吗?

+

立即加入我们,让孩子在快乐中成长,在对话中发现语言的魅力!

+ {% if not current_user.is_authenticated %} + + 立即开始免费体验 + + {% endif %} +
+
+{% endblock %} diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..7ec6149 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,50 @@ +import os +from datetime import timedelta + +class Config: + # 基础配置 + SECRET_KEY = os.environ.get('SECRET_KEY') or 'your-secret-key-here-change-in-production' + + # 数据库配置 + MYSQL_HOST = os.environ.get('MYSQL_HOST', '119.91.236.167') + MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306)) + MYSQL_USER = os.environ.get('MYSQL_USER', 'Language_learning') + MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'cosyvoice') + MYSQL_DB = os.environ.get('MYSQL_DB', 'language_learning') + + SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}?charset=utf8mb4' + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 300, + } + + # 邮箱配置 + EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.qq.com') + EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587)) + EMAIL_ENCRYPTION = os.environ.get('EMAIL_ENCRYPTION', 'starttls') + EMAIL_USERNAME = os.environ.get('EMAIL_USERNAME', '3399560459@qq.com') + EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD', 'fzwhyirhbqdzcjgf') + EMAIL_FROM = os.environ.get('EMAIL_FROM', '3399560459@qq.com') + EMAIL_FROM_NAME = os.environ.get('EMAIL_FROM_NAME', 'BOOKSYSTEM_OFFICIAL') + + # Session配置 + PERMANENT_SESSION_LIFETIME = timedelta(hours=24) + + # 验证码配置 + VERIFICATION_CODE_EXPIRE_MINUTES = 5 + + # 文件上传配置 + MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB + +class DevelopmentConfig(Config): + DEBUG = True + +class ProductionConfig(Config): + DEBUG = False + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65a2c25 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.0.5 +Flask-Login==0.6.3 +Flask-WTF==1.1.1 +WTForms==3.0.1 +PyMySQL==1.1.0 +bcrypt==4.0.1 +python-dotenv==1.0.0 +email-validator==2.0.0 +cryptography==41.0.4 +Werkzeug==2.3.7 diff --git a/run.py b/run.py new file mode 100644 index 0000000..aeb2794 --- /dev/null +++ b/run.py @@ -0,0 +1,133 @@ +import os +import logging +from datetime import datetime +from flask import Flask +from app import create_app, db +from app.models import User, EmailVerification + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(name)s %(message)s', + handlers=[ + logging.FileHandler('logs/app.log'), + logging.StreamHandler() + ] +) + +# 创建Flask应用 +app = create_app(os.environ.get('FLASK_ENV', 'development')) + +# 创建CLI命令 +@app.cli.command() +def init_db(): + """初始化数据库""" + try: + # 创建所有表 + with app.app_context(): + db.create_all() + print("✅ 数据库表创建成功") + + # 创建默认数据 + create_default_data() + print("✅ 默认数据创建成功") + + except Exception as e: + print(f"❌ 数据库初始化失败: {str(e)}") + +@app.cli.command() +def drop_db(): + """删除所有数据库表""" + try: + with app.app_context(): + db.drop_all() + print("✅ 数据库表删除成功") + except Exception as e: + print(f"❌ 数据库表删除失败: {str(e)}") + +@app.cli.command() +def reset_db(): + """重置数据库""" + try: + with app.app_context(): + db.drop_all() + db.create_all() + create_default_data() + print("✅ 数据库重置成功") + except Exception as e: + print(f"❌ 数据库重置失败: {str(e)}") + +def create_default_data(): + """创建默认数据""" + with app.app_context(): + # 检查是否已有数据 + if User.query.first(): + print("⚠️ 数据库已有数据,跳过默认数据创建") + return + + # 可以在这里创建测试用户或其他默认数据 + print("ℹ️ 暂无默认数据需要创建") + +@app.cli.command() +def test_email(): + """测试邮件发送功能""" + try: + from utils import send_verification_email + + test_email = input("请输入测试邮箱地址: ").strip() + if not test_email: + print("❌ 邮箱地址不能为空") + return + + test_code = "123456" + + with app.app_context(): + success = send_verification_email(test_email, test_code) + + if success: + print(f"✅ 测试邮件发送成功到: {test_email}") + else: + print("❌ 测试邮件发送失败") + + except Exception as e: + print(f"❌ 邮件测试失败: {str(e)}") + +@app.cli.command() +def clean_expired_codes(): + """清理过期的验证码""" + try: + with app.app_context(): + expired_codes = EmailVerification.query.filter( + EmailVerification.expires_at < datetime.utcnow() + ) + count = expired_codes.count() + expired_codes.delete() + db.session.commit() + print(f"✅ 清理了 {count} 个过期验证码") + except Exception as e: + print(f"❌ 清理过期验证码失败: {str(e)}") + +@app.shell_context_processor +def make_shell_context(): + """为flask shell命令提供上下文""" + return { + 'db': db, + 'User': User, + 'EmailVerification': EmailVerification + } + +if __name__ == '__main__': + # 确保日志目录存在 + os.makedirs('logs', exist_ok=True) + + # 运行应用 + port = int(os.environ.get('PORT', 50003)) + # 强制开启Debug模式,便于前端开发 + debug = True + + print("🚀 启动儿童语言学习系统...") + print(f"📱 访问地址: http://localhost:{port}") + print(f"🔧 调试模式: {debug}") + print(f"📊 数据库: {app.config['SQLALCHEMY_DATABASE_URI']}") + + app.run(host='0.0.0.0', port=port, debug=debug) diff --git a/setup_python_env.sh b/setup_python_env.sh new file mode 100755 index 0000000..7a96183 --- /dev/null +++ b/setup_python_env.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +echo "🔧 开始配置Python 3.10.16环境..." + +# 颜色定义 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}📋 步骤1: 检查当前配置${NC}" +echo "当前.zshrc中的pyenv配置:" +tail -5 ~/.zshrc | grep -E "(PYENV|pyenv)" || echo "未找到pyenv配置" + +echo -e "\n${YELLOW}📋 步骤2: 设置本地Python版本${NC}" +pyenv local 3.10.16 +echo "已设置本地Python版本为3.10.16" + +echo -e "\n${YELLOW}📋 步骤3: 验证Python版本${NC}" +echo "当前Python版本: $(python --version)" +echo "Python路径: $(which python)" + +echo -e "\n${YELLOW}📋 步骤4: 重新创建虚拟环境${NC}" +# 删除现有虚拟环境 +if [ -d "venv" ]; then + echo "删除现有虚拟环境..." + rm -rf venv +fi + +# 创建新的虚拟环境 +echo "创建新的虚拟环境..." +python -m venv venv + +# 激活虚拟环境 +echo "激活虚拟环境..." +source venv/bin/activate + +echo "虚拟环境中的Python版本: $(python --version)" + +echo -e "\n${YELLOW}📋 步骤5: 更新.gitignore${NC}" +# 检查.gitignore中是否已有venv/ +if ! grep -q "venv/" .gitignore 2>/dev/null; then + echo "venv/" >> .gitignore + echo "已添加venv/到.gitignore" +else + echo ".gitignore中已存在venv/" +fi + +echo -e "\n${YELLOW}📋 步骤6: 验证配置${NC}" +echo "检查.python-version文件:" +if [ -f ".python-version" ]; then + echo "✅ .python-version存在,内容: $(cat .python-version)" +else + echo "❌ .python-version文件不存在" +fi + +echo -e "\n${GREEN}✅ 环境配置完成!${NC}" +echo -e "${GREEN}📝 重要提示:${NC}" +echo "1. 虚拟环境已激活,Python版本: $(python --version)" +echo "2. 每次进入项目目录时,使用: source venv/bin/activate" +echo "3. 退出虚拟环境使用: deactivate" +echo "4. .python-version文件确保项目目录始终使用Python 3.10.16" + +echo -e "\n${YELLOW}🧪 测试建议:${NC}" +echo "重新打开终端,进入项目目录,运行以下命令测试:" +echo "cd $(pwd)" +echo "python --version # 应该显示Python 3.10.16" +echo "source venv/bin/activate" +echo "python --version # 应该显示Python 3.10.16" + diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..3370dc6 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,124 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from flask import current_app +import logging + +def _send_email(recipient, subject, html_body): + """ + 统一的邮件发送内部函数. + :param recipient: 收件人邮箱 + :param subject: 邮件主题 + :param html_body: HTML格式的邮件内容 + :return: True if success, False otherwise. + """ + try: + # 创建邮件对象 + msg = MIMEMultipart() + + # 使用 formataddr 来正确编码包含非ASCII字符(如中文)的发件人名称 + msg['From'] = formataddr(( + current_app.config['EMAIL_FROM_NAME'], + current_app.config['EMAIL_FROM'] + )) + + msg['To'] = recipient + msg['Subject'] = subject + + msg.attach(MIMEText(html_body, 'html', 'utf-8')) + + # 发送邮件 + server = smtplib.SMTP(current_app.config['EMAIL_HOST'], current_app.config['EMAIL_PORT']) + if current_app.config['EMAIL_ENCRYPTION'] == 'starttls': + server.starttls() + server.login(current_app.config['EMAIL_USERNAME'], current_app.config['EMAIL_PASSWORD']) + server.send_message(msg) + server.quit() + + current_app.logger.info(f"成功发送邮件到: {recipient}, 主题: {subject}") + return True + + except Exception as e: + # 统一记录错误日志 + current_app.logger.error(f"发送邮件到 {recipient} 失败 (主题: {subject}): {str(e)}") + return False + +def send_verification_email(email, verification_code): + """发送注册验证码邮件""" + subject = '【儿童语言学习系统】邮箱验证码' + body = f""" + + +
+
+

儿童语言学习系统

+
+
+ +
+

您的验证码

+
+ {verification_code} +
+

+ 注意:验证码5分钟内有效,请及时使用。如果不是您本人操作,请忽略此邮件。 +

+
+ +
+

安全提示

+

+ 为了您的账号安全,请不要将验证码泄露给他人。我们不会主动向您索要验证码。 +

+
+ +
+

此邮件由系统自动发送,请勿直接回复

+

© 儿童语言学习系统 - 让孩子快乐学语言

+
+
+ + + """ + return _send_email(email, subject, body) + +def send_password_reset_email(email, verification_code): + """发送密码重置邮件""" + subject = '【儿童语言学习系统】密码重置验证码' + body = f""" + + +
+
+

密码重置

+
+
+ +
+

密码重置验证码

+
+ {verification_code} +
+

+ 注意:验证码5分钟内有效,请及时使用。如果不是您本人操作,请立即修改密码并联系我们。 +

+
+ +
+

重要提醒

+

+ 如果您没有申请密码重置,请忽略此邮件。为了账号安全,建议您定期更换密码。 +

+
+ +
+

此邮件由系统自动发送,请勿直接回复

+

© 儿童语言学习系统 - 让孩子快乐学语言

+
+
+ + + """ + # 调用统一的发送函数 + return _send_email(email, subject, body)