Compare commits

..

9 Commits

Author SHA1 Message Date
superlishunqin
796b6dbe62 config 2025-04-22 23:40:51 +08:00
superlishunqin
34fa848271 chore: add .gitignore to exclude virtualenv and logs 2025-04-22 23:03:01 +08:00
superlishunqin
2fd78f34a7 提交所有项目文件,排除虚拟环境和日志 2025-04-22 22:55:14 +08:00
superlishunqin
6c68ae7c14 添加 .gitignore 文件 2025-04-22 22:54:56 +08:00
superlishunqin
5cef0065ad Merge branch 'recovery'
sumkim
2025-04-22 22:50:02 +08:00
superlishunqin
326e95e783 修改 models.py 的内容 2025-04-22 22:49:57 +08:00
superlishunqin
06769fa6f5 暂时移除大图片文件 2025-04-22 22:36:18 +08:00
superlishunqin
aa3a1fa97d 添加 .gitignore 配置 2025-04-22 22:33:14 +08:00
superlishunqin
e62a101da0 0422-1010 2025-04-22 22:10:16 +08:00
24 changed files with 2767 additions and 48 deletions

56
.gitignore vendored
View File

@ -1,56 +1,16 @@
venv/
env/
.venv/
.env/
study/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.so
.Python
env/
venv/
study/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Flask实例文件夹
instance/*
!instance/.gitkeep
# 环境变量文件
.env
.flaskenv
# 数据库文件
*.db
*.sqlite
*.sqlite3
# 日志文件 # 日志文件
*.log *.log
logs/ logs/
log/
# IDE相关 # 其他常见排除项
.DS_Store
.idea/ .idea/
.vscode/ .vscode/
*.swp
*.swo
.DS_Store
.coverage
htmlcov/
.pytest_cache/
# 迁移文件夹中的版本文件
migrations/versions/
# 开发环境的静态文件或上传文件
uploads/

View File

@ -0,0 +1,37 @@
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_babel import Babel
from app.config import Config
import os
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
babel = Babel()
def create_app(config_class=Config):
app = Flask(__name__, template_folder='../templates', static_folder='../static')
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
babel.init_app(app)
from app.routes import main_bp, auth_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp, url_prefix='/auth')
@babel.localeselector
def get_locale():
# 如果用户已经选择了语言从session中获取
if 'language' in session:
return session['language']
# 否则尝试匹配请求的语言
return request.accept_languages.best_match(app.config['LANGUAGES'])
return app

View File

@ -0,0 +1,10 @@
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-should-be-changed'
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://study_platform:sumkimadmin@27.124.22.104:3306/study_platform'
SQLALCHEMY_TRACK_MODIFICATIONS = False
LANGUAGES = ['en', 'zh']
DEFAULT_LANGUAGE = 'zh'
#aaaa

View File

@ -0,0 +1,87 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters long')
])
password2 = PasswordField('Repeat Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Register')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Email already registered.')
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters long')
])
password2 = PasswordField('Repeat Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Reset Password')
class EmailVerificationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('发送验证码')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('该邮箱已被注册')
# 验证邮箱是否属于允许的域名
if not email.data.endswith('@sq0715.com'):
pass # 可以在这里添加邮箱域名限制逻辑
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
verification_code = StringField('验证码', validators=[DataRequired(), Length(min=6, max=6)])
username = StringField('用户名', validators=[DataRequired()])
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8, message='密码至少需要8个字符')
])
password2 = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='两次输入的密码必须匹配')
])
submit = SubmitField('注册')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('该邮箱已被注册')
def validate_verification_code(self, verification_code):
from app.utils.email import verify_code
if not verify_code(self.email.data, verification_code.data):
raise ValidationError('验证码无效或已过期')

View File

@ -0,0 +1,45 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
import uuid
from datetime import datetime, timedelta
@login_manager.user_loader
def load_user(user_id):
return User.query.get(user_id)
# 添加验证码模型
class VerificationCode(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), nullable=False)
code = db.Column(db.String(10), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, default=lambda: datetime.utcnow() + timedelta(minutes=10))
is_used = db.Column(db.Boolean, default=False)
@property
def is_expired(self):
return datetime.utcnow() > self.expires_at
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
username = db.Column(db.String(64), nullable=False)
password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True)
language = db.Column(db.String(2), default='zh')
theme = db.Column(db.String(10), default='light')
reset_token = db.Column(db.String(36), unique=True)
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 generate_reset_token(self):
self.reset_token = str(uuid.uuid4())
return self.reset_token

View File

@ -0,0 +1,290 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.urls import url_parse
from app import db
from app.models import User
from app.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm
from app.translations import get_text
from datetime import datetime
from app.utils.email import generate_verification_code, send_verification_email, save_verification_code
from app.forms import EmailVerificationForm, RegistrationForm
import re
from flask import send_file
from PIL import Image, ImageDraw
import io
from flask import current_app as app
main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__)
# 辅助函数
def _get_translation(key):
lang = session.get('language', 'zh')
return get_text(key, lang)
# 主页路由
@main_bp.route('/')
def index():
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
return render_template('main/index.html')
# 切换语言
@main_bp.route('/language/<lang>')
def set_language(lang):
# 确保只接受有效的语言
if lang in ['zh', 'en']:
session['language'] = lang
if current_user.is_authenticated:
current_user.language = lang
db.session.commit()
# 确保重定向回正确的页面
next_page = request.args.get('next') or request.referrer or url_for('main.index')
return redirect(next_page)
# 切换主题
@main_bp.route('/theme/<theme>')
def set_theme(theme):
if theme not in ['light', 'dark']:
theme = 'light'
session['theme'] = theme
if current_user.is_authenticated:
current_user.theme = theme
db.session.commit()
next_page = request.args.get('next') or request.referrer or url_for('main.index')
return redirect(next_page)
# 认证路由
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is None or not user.check_password(form.password.data):
flash(_get_translation('invalid_credentials'))
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
# 更新最后登录时间
user.last_login = datetime.utcnow()
db.session.commit()
# 同步用户的语言和主题设置
session['language'] = user.language
session['theme'] = user.theme
flash(_get_translation('login_success'))
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
return redirect(next_page)
# 添加对模板的函数
def _(key):
return _get_translation(key)
return render_template('auth/login.html', form=form, _=_, hide_language_switch=True, hide_theme_switch=True)
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
email_form = EmailVerificationForm()
registration_form = RegistrationForm()
# 处理发送验证码请求
if 'send_code' in request.form and email_form.validate_on_submit():
email = email_form.email.data
code = generate_verification_code()
# 保存验证码
save_verification_code(email, code)
# 发送验证码邮件
if send_verification_email(email, code):
flash(_get_translation('verification_code_sent'))
# 将邮箱保存到表单,用于下一步注册
return render_template(
'auth/register.html',
email_form=email_form,
registration_form=registration_form,
email_verified=True,
verified_email=email
)
else:
flash(_get_translation('email_send_failed'))
# 处理注册表单提交
if 'register' in request.form and registration_form.validate_on_submit():
user = User(
email=registration_form.email.data,
username=registration_form.username.data,
language=session.get('language', 'zh'),
theme=session.get('theme', 'light')
)
user.set_password(registration_form.password.data)
db.session.add(user)
db.session.commit()
flash(_get_translation('account_created'))
return redirect(url_for('auth.login'))
# 检查是否有保存的已验证邮箱
email_verified = False
verified_email = request.args.get('email', '')
if verified_email:
email_verified = True
registration_form.email.data = verified_email
# 添加对模板的函数
def _(key):
return _get_translation(key)
return render_template(
'auth/register.html',
email_form=email_form,
registration_form=registration_form,
email_verified=email_verified,
verified_email=verified_email,
_=_,
hide_language_switch=True,
hide_theme_switch=True
)
@auth_bp.route('/send_verification_code', methods=['POST'])
def send_verification_code():
try:
email = request.form.get('email')
if not email or not re.match(r'[^@]+@[^@]+\.[^@]+', email):
return jsonify({'success': False, 'message': _get_translation('invalid_email')})
# 检查邮箱是否已被注册
try:
if User.query.filter_by(email=email).first():
return jsonify({'success': False, 'message': _get_translation('email_already_registered')})
except Exception as e:
app.logger.error(f"数据库查询错误: {str(e)}")
return jsonify({'success': False, 'message': _get_translation('server_error')})
# 生成并保存验证码
try:
code = generate_verification_code()
save_verification_code(email, code)
except Exception as e:
app.logger.error(f"验证码保存错误: {str(e)}")
return jsonify({'success': False, 'message': _get_translation('server_error')})
# 发送验证码邮件
try:
if send_verification_email(email, code):
return jsonify({'success': True, 'message': _get_translation('verification_code_sent')})
else:
return jsonify({'success': False, 'message': _get_translation('email_send_failed')})
except Exception as e:
app.logger.error(f"邮件发送错误: {str(e)}")
return jsonify({'success': False, 'message': _get_translation('email_send_failed')})
except Exception as e:
app.logger.error(f"验证码发送路由错误: {str(e)}")
return jsonify({'success': False, 'message': _get_translation('server_error')})
@auth_bp.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ResetPasswordRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = user.generate_reset_token()
db.session.commit()
# 这里应该发送邮件,但目前只是模拟
# 实际项目中需要集成邮件发送功能
flash(_get_translation('password_reset_sent'))
# 用于演示,实际项目中应该通过邮件发送这个链接
reset_url = url_for('auth.reset_password', token=token, _external=True)
print(f"Password reset URL: {reset_url}")
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_request.html', form=form)
@auth_bp.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
user = User.query.filter_by(reset_token=token).first()
if not user:
flash(_get_translation('invalid_reset_token'))
return redirect(url_for('auth.login'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
user.reset_token = None
db.session.commit()
flash(_get_translation('password_reset_success'))
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@main_bp.route('/api/placeholder/<width>/<height>')
def placeholder(width, height):
"""生成并返回指定尺寸的占位图像"""
try:
width = int(width)
height = int(height)
except ValueError:
width = 100
height = 100
# 限制最大尺寸
width = min(width, 1200)
height = min(height, 1200)
# 创建简单的占位图
img = Image.new('RGB', (width, height), color=(220, 220, 220))
d = ImageDraw.Draw(img)
# 绘制边框
d.rectangle([0, 0, width - 1, height - 1], outline=(200, 200, 200))
# 绘制文本
text = f"{width}x{height}"
# 将图像转换为字节流
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)
return send_file(img_byte_arr, mimetype='image/png')

125
app/translations.py Normal file
View File

@ -0,0 +1,125 @@
translations = {
'en': {
'login': 'Login',
'register': 'Register',
'forgot_password': 'Forgot Password?',
'email': 'Email',
'password': 'Password',
'remember_me': 'Remember Me (7 days)',
'sign_in': 'Sign In',
'username': 'Username',
'repeat_password': 'Repeat Password',
'reset_password': 'Reset Password',
'submit': 'Submit',
'welcome': 'Welcome to Study Platform',
'welcome_back': 'Welcome back, please log in to your account',
'switch_language': 'Switch Language',
'switch_theme': 'Switch Theme',
'logout': 'Logout',
'my_dashboard': 'My Dashboard',
'network_drive': 'Network Drive',
'email_system': 'Email System',
'code_compiler': 'Code Compiler',
'file_submission': 'File Submission',
'video_platform': 'Video Platform',
'blog_space': 'Blog Space',
'ai_platform': 'AI Platform',
'code_repository': 'Code Repository',
'password_reset_sent': 'Check your email for password reset instructions',
'invalid_reset_token': 'Invalid or expired token',
'password_reset_success': 'Your password has been reset',
'account_created': 'Account created successfully! You can now log in',
'login_success': 'Login successful',
'account_login': 'Account Login',
'qr_login': 'QR Code Login',
'email_placeholder': 'Please enter your email',
'password_placeholder': 'Please enter your password',
'other_login_methods': 'Other Login Methods',
'wechat_login': 'WeChat Login',
'qq_login': 'QQ Login',
'scan_qr_code': 'Please scan the QR code to log in',
'email_required': 'Email is required',
'invalid_email': 'Invalid email format',
'password_required': 'Password is required',
'password_too_short': 'Password must be at least 8 characters',
'no_account': 'Don\'t have an account?',
'privacy_policy': 'Privacy Policy',
'terms_of_service': 'Terms of Service',
'verification_code': 'Verification Code',
'send_verification_code': 'Send Verification Code',
'verification_code_sent': 'Verification code has been sent to your email',
'email_send_failed': 'Failed to send email, please try again later',
'email_already_registered': 'This email is already registered',
'resend_code': 'Resend Code',
'email_verification': 'Email Verification',
'next_step': 'Next Step',
'back': 'Back',
'account_info': 'Account Information',
'registration_success': 'Registration Successful',
'verification_code_invalid': 'Invalid verification code',
'verification_code_expired': 'Verification code has expired'
},
'zh': {
'verification_code': '验证码',
'send_verification_code': '发送验证码',
'verification_code_sent': '验证码已发送到您的邮箱',
'email_send_failed': '邮件发送失败,请稍后重试',
'email_already_registered': '该邮箱已被注册',
'resend_code': '重新发送',
'email_verification': '邮箱验证',
'next_step': '下一步',
'back': '返回',
'account_info': '账号信息',
'registration_success': '注册成功',
'verification_code_invalid': '验证码无效',
'verification_code_expired': '验证码已过期',
'login': '登录',
'register': '注册',
'forgot_password': '忘记密码?',
'email': '邮箱',
'password': '密码',
'remember_me': '记住我7天内免登录',
'sign_in': '登录',
'username': '用户名',
'repeat_password': '确认密码',
'reset_password': '重置密码',
'submit': '提交',
'welcome': '欢迎使用学习平台',
'welcome_back': '欢迎回来,请登录您的账户',
'switch_language': '切换语言',
'switch_theme': '切换主题',
'logout': '退出登录',
'my_dashboard': '我的面板',
'network_drive': '网盘系统',
'email_system': '邮箱系统',
'code_compiler': '在线代码编译',
'file_submission': '文件提交系统',
'video_platform': '在线视频网站',
'blog_space': '博客空间',
'ai_platform': 'AI 平台',
'code_repository': '代码仓库',
'password_reset_sent': '密码重置邮件已发送,请查收',
'invalid_reset_token': '无效或已过期的令牌',
'password_reset_success': '密码重置成功',
'account_created': '账户创建成功!您现在可以登录',
'login_success': '登录成功',
'account_login': '账号密码登录',
'qr_login': '扫码登录',
'email_placeholder': '请输入邮箱地址',
'password_placeholder': '请输入密码',
'other_login_methods': '其他登录方式',
'wechat_login': '微信登录',
'qq_login': 'QQ登录',
'scan_qr_code': '请扫描二维码登录',
'email_required': '邮箱不能为空',
'invalid_email': '邮箱格式不正确',
'password_required': '密码不能为空',
'password_too_short': '密码至少需要8个字符',
'no_account': '还没有账号?',
'privacy_policy': '隐私政策',
'terms_of_service': '服务条款'
}
}
def get_text(key, lang):
return translations.get(lang, {}).get(key, key)

117
app/utils/email.py Normal file
View File

@ -0,0 +1,117 @@
import smtplib
import random
import string
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app, session
from app import db
from app.models import VerificationCode
# 邮件配置
EMAIL_CONFIG = {
'host': 'mail.sq0715.com',
'port': 587,
'username': 'sumkim@sq0715.com',
'password': 'sumkim0715',
'from_email': 'sumkim@sq0715.com',
'from_name': 'QINAI_OFFICIAL'
}
def generate_verification_code(length=6):
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=length))
def save_verification_code(email, code):
"""保存验证码到数据库"""
# 删除之前的验证码
VerificationCode.query.filter_by(email=email).delete()
db.session.commit()
# 创建新验证码记录
verification = VerificationCode(email=email, code=code)
db.session.add(verification)
db.session.commit()
return verification
def verify_code(email, code):
"""验证验证码是否正确且在有效期内"""
verification = VerificationCode.query.filter_by(
email=email,
code=code,
is_used=False
).first()
if not verification:
return False
# 验证成功后标记为已使用
verification.is_used = True
db.session.commit()
return True
def send_verification_email(to_email, code):
"""发送验证码邮件"""
subject = "【高可用学习平台】您的注册验证码"
# 创建邮件正文支持HTML格式
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px; background-color: #f9f9f9;">
<h2 style="color: #4a89dc; text-align: center;">高可用学习平台 - 邮箱验证</h2>
<p>您好</p>
<p>感谢您注册高可用学习平台请使用以下验证码完成注册</p>
<div style="background-color: #4a89dc; color: white; font-size: 24px; font-weight: bold; text-align: center; padding: 15px; border-radius: 5px; letter-spacing: 5px; margin: 20px 0;">
{code}
</div>
<p>验证码有效期为10分钟请尽快完成注册</p>
<p>如果您没有进行注册操作请忽略此邮件</p>
<p style="margin-top: 30px; padding-top: 10px; border-top: 1px solid #eee; font-size: 12px; color: #666;">
此邮件由系统自动发送请勿直接回复
</p>
</div>
"""
plain_text = f"""
高可用学习平台 - 邮箱验证
您好
感谢您注册高可用学习平台请使用以下验证码完成注册
{code}
验证码有效期为10分钟请尽快完成注册
如果您没有进行注册操作请忽略此邮件
此邮件由系统自动发送请勿直接回复
"""
# 创建MIMEMultipart对象
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = f"{EMAIL_CONFIG['from_name']} <{EMAIL_CONFIG['from_email']}>"
message["To"] = to_email
# 添加文本和HTML版本
message.attach(MIMEText(plain_text, "plain"))
message.attach(MIMEText(html_content, "html"))
try:
# 连接到SMTP服务器
server = smtplib.SMTP(EMAIL_CONFIG['host'], EMAIL_CONFIG['port'])
server.starttls() # 启用TLS加密
server.login(EMAIL_CONFIG['username'], EMAIL_CONFIG['password'])
# 发送邮件
server.sendmail(EMAIL_CONFIG['from_email'], to_email, message.as_string())
server.quit()
return True
except Exception as e:
print(f"发送邮件失败: {str(e)}")
return False

21
main.py Normal file
View File

@ -0,0 +1,21 @@
from app import create_app, db
from app.models import User
import pymysql
app = create_app()
@app.before_first_request
def check_db_connection():
try:
# 使用SQLAlchemy检查连接
db.session.execute("SELECT 1")
app.logger.info("数据库连接成功")
except Exception as e:
app.logger.error(f"数据库连接错误: {str(e)}")
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=40911, debug=True)

View File

@ -0,0 +1,16 @@
Flask==2.0.1
Flask-Login==0.5.0
Flask-WTF==0.15.1
Flask-Migrate==3.1.0
Flask-Babel==2.0.0
PyMySQL==1.0.2
email-validator==1.1.3
python-dotenv==0.19.0
Werkzeug==2.0.3
sqlalchemy==1.4.46
# 以下是添加的电子邮件相关库
Flask-Mail==0.9.1
# 如果需要异步发送邮件
# celery==5.2.7
pillow
cryptography

72
static/css/dark.css Normal file
View File

@ -0,0 +1,72 @@
/* 暗色主题 */
body.theme-dark {
background-color: #212529;
color: #f8f9fa;
}
body.theme-dark .auth-form {
background-color: #343a40;
}
body.theme-dark .service-card {
background-color: #343a40;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
}
body.theme-dark .dashboard-header {
border-bottom-color: #495057;
}
body.theme-dark .language-switch a,
body.theme-dark .theme-switch a {
color: #6ea8fe;
}
body.theme-dark .language-switch a:hover,
body.theme-dark .theme-switch a:hover {
color: #9ec5fe;
}
body.theme-dark .auth-form a {
color: #6ea8fe;
}
body.theme-dark .auth-form a:hover {
color: #9ec5fe;
}
body.theme-dark .service-card p {
color: #adb5bd;
}
body.theme-dark .alert-info {
background-color: #032830;
color: #6edff6;
border-color: #055160;
}
body.theme-dark input.form-control,
body.theme-dark input.form-check-input {
background-color: #495057;
border-color: #6c757d;
color: #f8f9fa;
}
body.theme-dark input.form-control:focus {
background-color: #495057;
color: #f8f9fa;
}
body.theme-dark label {
color: #f8f9fa;
}
body.theme-dark .btn-outline-danger {
color: #ea868f;
border-color: #ea868f;
}
body.theme-dark .btn-outline-danger:hover {
background-color: #dc3545;
color: #fff;
}

52
static/css/light.css Normal file
View File

@ -0,0 +1,52 @@
/* 亮色主题 */
body.theme-light {
background-color: #f8f9fa;
color: #212529;
}
body.theme-light .auth-form {
background-color: #ffffff;
}
body.theme-light .service-card {
background-color: #ffffff;
}
body.theme-light .dashboard-header {
border-bottom-color: #dee2e6;
}
body.theme-light .language-switch a,
body.theme-light .theme-switch a {
color: #0d6efd;
}
body.theme-light .language-switch a:hover,
body.theme-light .theme-switch a:hover {
color: #0a58ca;
}
body.theme-light .auth-form a {
color: #0d6efd;
}
body.theme-light .auth-form a:hover {
color: #0a58ca;
}
body.theme-light .alert-info {
background-color: #cff4fc;
color: #055160;
border-color: #b6effb;
}
body.theme-light input.form-control,
body.theme-light input.form-check-input {
background-color: #fff;
border-color: #ced4da;
color: #212529;
}
body.theme-light label {
color: #212529;
}

578
static/css/login.css Normal file
View File

@ -0,0 +1,578 @@
/* 登录页面专用样式 */
body {
background-color: var(--bg-color);
background-image: url('https://source.unsplash.com/random/1920x1080/?technology');
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
}
.overlay {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(5px);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
}
.theme-toggle {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
cursor: pointer;
padding: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 18px;
width: 36px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: rotate(45deg);
}
.language-selector {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
}
.language-selector select {
padding: 8px 15px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
color: white;
font-size: 14px;
cursor: pointer;
outline: none;
transition: all 0.3s ease;
}
.language-selector select:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.language-selector select option {
background-color: var(--card-bg);
color: var(--text-color);
}
.main-container {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
width: 450px;
padding: 35px;
position: relative;
overflow: hidden;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-container::before {
content: "";
position: absolute;
top: -50px;
left: -50px;
width: 100px;
height: 100px;
background-color: var(--primary-color);
border-radius: 50%;
opacity: 0.1;
}
.login-container::after {
content: "";
position: absolute;
bottom: -50px;
right: -50px;
width: 100px;
height: 100px;
background-color: var(--secondary-color);
border-radius: 50%;
opacity: 0.1;
}
.logo {
text-align: center;
margin-bottom: 25px;
position: relative;
}
.logo img {
width: 90px;
height: 90px;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 5px;
background-color: #fff;
transition: transform 0.3s ease;
}
.logo img:hover {
transform: scale(1.05);
}
h1 {
text-align: center;
color: var(--text-color);
margin-bottom: 10px;
font-weight: 600;
font-size: 28px;
}
.subtitle {
text-align: center;
color: var(--light-text);
margin-bottom: 30px;
font-size: 14px;
}
.tab-container {
display: flex;
margin-bottom: 25px;
}
.tab {
flex: 1;
text-align: center;
padding: 12px 0;
cursor: pointer;
border-bottom: 2px solid var(--border-color);
transition: all 0.3s ease;
font-weight: 500;
}
.tab.active {
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
.form-group {
margin-bottom: 22px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
}
.input-with-icon {
position: relative;
}
.input-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--light-text);
}
.form-control {
width: 100%;
height: 48px;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0 15px 0 45px;
font-size: 15px;
transition: all 0.3s ease;
background-color: var(--card-bg);
color: var(--text-color);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 137, 220, 0.2);
outline: none;
}
.password-toggle {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: var(--light-text);
}
.validation-message {
margin-top: 6px;
font-size: 12px;
color: var(--error-color);
display: none;
}
.validation-message.show {
display: block;
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.remember-forgot {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.remember-me {
display: flex;
align-items: center;
}
.custom-checkbox {
position: relative;
padding-left: 30px;
cursor: pointer;
font-size: 14px;
user-select: none;
color: var(--light-text);
}
.custom-checkbox input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 18px;
width: 18px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
transition: all 0.2s ease;
}
.custom-checkbox:hover input ~ .checkmark {
border-color: var(--primary-color);
}
.custom-checkbox input:checked ~ .checkmark {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.custom-checkbox input:checked ~ .checkmark:after {
display: block;
}
.custom-checkbox .checkmark:after {
left: 6px;
top: 2px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.forgot-password a {
color: var(--primary-color);
text-decoration: none;
font-size: 14px;
transition: color 0.3s ease;
}
.forgot-password a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.btn-login {
width: 100%;
height: 48px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-login:hover {
background-color: var(--primary-hover);
}
.btn-login:active {
transform: scale(0.98);
}
.btn-login .loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.btn-login.loading-state {
color: transparent;
}
.btn-login.loading-state .loading {
display: block;
}
.divider {
display: flex;
align-items: center;
margin: 30px 0;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--border-color);
}
.divider span {
padding: 0 15px;
color: var(--light-text);
font-size: 14px;
}
.social-login {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 25px;
}
.social-btn {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: rgba(245, 245, 245, 0.1);
border: 1px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.social-btn::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%
);
transition: left 0.7s ease;
}
.social-btn:hover {
transform: translateY(-3px);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
}
.social-btn:hover::before {
left: 100%;
}
.social-btn img {
width: 25px;
height: 25px;
}
.social-btn.wechat {
background-color: rgba(9, 187, 7, 0.1);
border-color: rgba(9, 187, 7, 0.3);
}
.social-btn.qq {
background-color: rgba(18, 183, 245, 0.1);
border-color: rgba(18, 183, 245, 0.3);
}
.signup {
text-align: center;
margin-top: 25px;
font-size: 14px;
color: var(--light-text);
}
.signup a {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.signup a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.qr-code-login {
text-align: center;
}
.qr-code {
width: 180px;
height: 180px;
margin: 20px auto;
padding: 10px;
background-color: white;
border-radius: 6px;
}
.qr-code img {
width: 100%;
height: 100%;
}
.qr-code-tip {
font-size: 14px;
color: var(--light-text);
margin-bottom: 25px;
}
footer {
text-align: center;
padding: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
footer a {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* 响应式设计 */
@media (max-width: 576px) {
.login-container {
width: 100%;
padding: 25px;
border-radius: 10px;
}
.theme-toggle,
.language-selector {
top: 10px;
}
.logo img {
width: 70px;
height: 70px;
}
h1 {
font-size: 22px;
}
.social-login {
gap: 15px;
}
.social-btn {
width: 45px;
height: 45px;
}
.main-container {
padding: 0;
}
}
/* 暗色模式适配 */
body.theme-dark .overlay {
background-color: rgba(0, 0, 0, 0.6);
}
body.theme-dark .login-container {
background-color: rgba(45, 45, 45, 0.9);
}
body.theme-dark .logo img {
background-color: #333;
}
body.theme-dark .checkmark {
background-color: #333;
}
body.theme-dark .form-control {
background-color: rgba(60, 60, 60, 0.8);
color: #ddd;
}
body.theme-dark .social-btn {
background-color: rgba(50, 50, 50, 0.5);
}

166
static/css/register.css Normal file
View File

@ -0,0 +1,166 @@
/* 注册页面样式,继承登录页面样式 */
.step-container {
display: flex;
justify-content: space-between;
margin-bottom: 25px;
position: relative;
}
.step-container::before {
content: "";
position: absolute;
top: 15px;
left: 0;
width: 100%;
height: 2px;
background-color: var(--border-color);
z-index: 1;
}
.step {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: var(--bg-color);
border: 2px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
font-weight: 600;
color: var(--light-text);
position: relative;
z-index: 2;
}
.step.active {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.step.completed {
background-color: var(--success-color);
border-color: var(--success-color);
color: white;
}
.step-label {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: var(--light-text);
width: 80px;
text-align: center;
}
.verification-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.verification-input {
flex: 1;
}
.send-code-btn {
white-space: nowrap;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
padding: 10px 15px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
height: 48px;
}
.send-code-btn:hover {
background-color: var(--primary-hover);
}
.send-code-btn:disabled {
background-color: var(--border-color);
cursor: not-allowed;
}
.countdown {
font-size: 12px;
color: var(--light-text);
}
.registration-step {
display: none;
}
.registration-step.active {
display: block;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.form-buttons {
display: flex;
justify-content: space-between;
margin-top: 25px;
}
.btn-back {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-next {
background-color: var(--primary-color);
color: white;
}
.strength-meter {
height: 4px;
background-color: var(--border-color);
margin-top: 10px;
border-radius: 2px;
overflow: hidden;
}
.strength-meter-fill {
height: 100%;
width: 0;
transition: width 0.3s ease;
}
.strength-meter-fill.weak {
width: 25%;
background-color: #e74c3c;
}
.strength-meter-fill.medium {
width: 50%;
background-color: #f39c12;
}
.strength-meter-fill.strong {
width: 75%;
background-color: #3498db;
}
.strength-meter-fill.very-strong {
width: 100%;
background-color: #2ecc71;
}
.password-strength-text {
font-size: 12px;
color: var(--light-text);
text-align: right;
margin-top: 5px;
}

154
static/css/style.css Normal file
View File

@ -0,0 +1,154 @@
/* 基础样式 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* 语言和主题切换 */
.language-switch, .theme-switch {
text-align: right;
margin: 10px 0;
}
.language-switch a, .theme-switch a {
text-decoration: none;
margin-left: 10px;
}
/* 登录和注册表单 */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.auth-form {
width: 100%;
max-width: 400px;
padding: 25px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
.auth-form h2 {
margin-bottom: 20px;
text-align: center;
}
.error {
color: #dc3545;
font-size: 0.875rem;
display: block;
margin-top: 5px;
}
/* 仪表盘 */
.dashboard-container {
padding: 20px 0;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.service-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.05);
padding: 20px;
transition: transform 0.3s, box-shadow 0.3s;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.service-card h3 {
margin-top: 0;
margin-bottom: 10px;
}
.service-card p {
margin-bottom: 15px;
color: #666;
}
/* 响应式设计 */
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.services-grid {
grid-template-columns: 1fr;
}
.auth-form {
padding: 20px;
}
}
/* 通用样式 */
.btn {
cursor: pointer;
}
.flashes {
margin-bottom: 20px;
}
/* 消息提示 */
.alert {
margin-bottom: 15px;
padding: 12px 20px;
border-radius: 4px;
}
/* 表单元素 */
.form-control:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
border-color: #86b7fe;
}
/* 主页卡片特效 */
.service-card .btn {
width: 100%;
margin-top: auto;
}
.service-card {
display: flex;
flex-direction: column;
height: 100%;
min-height: 200px;
}

BIN
static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

78
static/js/main.js Normal file
View File

@ -0,0 +1,78 @@
// 主要功能和交互
document.addEventListener('DOMContentLoaded', function() {
// 初始化提示框
const toasts = document.querySelectorAll('.toast');
if (toasts.length) {
toasts.forEach(toast => {
new bootstrap.Toast(toast).show();
});
}
// 密码强度检查
const passwordInputs = document.querySelectorAll('input[type="password"]');
passwordInputs.forEach(input => {
input.addEventListener('input', function() {
checkPasswordStrength(this);
});
});
// 主题切换效果
const themeSwitch = document.querySelector('.theme-switch a');
if (themeSwitch) {
themeSwitch.addEventListener('click', function(e) {
// 先进行动画效果,然后让链接正常跳转
document.body.style.opacity = '0.5';
setTimeout(() => {
document.body.style.opacity = '1';
}, 300);
});
}
// 服务卡片hover效果增强
const serviceCards = document.querySelectorAll('.service-card');
serviceCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-10px)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
});
});
});
// 密码强度检查函数
function checkPasswordStrength(input) {
const password = input.value;
let strength = 0;
// 长度检查
if (password.length >= 8) strength += 1;
// 包含数字
if (/\d/.test(password)) strength += 1;
// 包含小写字母
if (/[a-z]/.test(password)) strength += 1;
// 包含大写字母
if (/[A-Z]/.test(password)) strength += 1;
// 包含特殊字符
if (/[^A-Za-z0-9]/.test(password)) strength += 1;
// 移除旧的强度指示器
const oldIndicator = input.parentNode.querySelector('.password-strength');
if (oldIndicator) oldIndicator.remove();
// 如果密码长度大于0显示强度指示器
if (password.length > 0) {
const strengthText = ['很弱', '弱', '中等', '强', '很强'][Math.min(strength, 4)];
const strengthClass = ['very-weak', 'weak', 'medium', 'strong', 'very-strong'][Math.min(strength, 4)];
const indicator = document.createElement('div');
indicator.className = `password-strength ${strengthClass}`;
indicator.textContent = `密码强度: ${strengthText}`;
input.parentNode.appendChild(indicator);
}
}

223
templates/auth/login.html Normal file
View File

@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}{{ _('login') }} - 高可用学习平台{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
{% endblock %}
{% block content %}
<div class="overlay"></div>
<div class="theme-toggle" id="theme-toggle">
{% if session.get('theme', 'light') == 'light' %}☀️{% else %}🌙{% endif %}
</div>
<div class="language-selector">
<select id="language-select">
<option value="zh" {% if session.get('language', 'zh') == 'zh' %}selected{% endif %}>简体中文</option>
<option value="en" {% if session.get('language', 'zh') == 'en' %}selected{% endif %}>English</option>
</select>
</div>
<div class="main-container">
<div class="login-container">
<div class="logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo" onerror="this.src='/api/placeholder/90/90'">
</div>
<h1>高可用学习平台</h1>
<p class="subtitle">{{ _('welcome_back') }}</p>
<div class="tab-container">
<div class="tab active" id="account-tab">{{ _('account_login') }}</div>
<div class="tab" id="qr-tab">{{ _('qr_login') }}</div>
</div>
<div id="account-login">
<form id="login-form" method="post" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="form-group">
<label for="email">{{ _('email') }}</label>
<div class="input-with-icon">
<span class="input-icon">📧</span>
{{ form.email(class="form-control", placeholder=_('email_placeholder')) }}
</div>
{% for error in form.email.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="email-error"></div>
</div>
<div class="form-group">
<label for="password">{{ _('password') }}</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
{{ form.password(class="form-control", placeholder=_('password_placeholder')) }}
<span class="password-toggle" id="password-toggle">👁️</span>
</div>
{% for error in form.password.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="password-error"></div>
</div>
<div class="remember-forgot">
<label class="custom-checkbox">
{{ form.remember_me(type="checkbox") }}
<span class="checkmark"></span>
{{ _('remember_me') }}
</label>
<div class="forgot-password">
<a href="{{ url_for('auth.reset_password_request') }}">{{ _('forgot_password') }}</a>
</div>
</div>
<button type="submit" class="btn-login" id="login-button">
<span>{{ _('login') }}</span>
<span class="loading"></span>
</button>
</form>
<div class="divider">
<span>{{ _('other_login_methods') }}</span>
</div>
<div class="social-login">
<div class="social-btn wechat" title="{{ _('wechat_login') }}">
<img src="/api/placeholder/25/25" alt="WeChat">
</div>
<div class="social-btn qq" title="{{ _('qq_login') }}">
<img src="/api/placeholder/25/25" alt="QQ">
</div>
</div>
<div class="signup">
{{ _('no_account') }} <a href="{{ url_for('auth.register') }}">{{ _('register') }}</a>
</div>
</div>
<div id="qr-code-login" style="display: none;">
<div class="qr-code">
<img src="/api/placeholder/180/180" alt="QR Code">
</div>
<p class="qr-code-tip">{{ _('scan_qr_code') }}</p>
</div>
</div>
</div>
<footer>
<p>© 2025 顺钦毕业论文_高可用学习平台 | <a href="#">{{ _('privacy_policy') }}</a> | <a href="#">{{ _('terms_of_service') }}</a></p>
</footer>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// 主题切换
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', function() {
const currentTheme = "{{ session.get('theme', 'light') }}";
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
window.location.href = "{{ url_for('main.set_theme', theme='') }}" + newTheme;
});
// 语言选择器
const languageSelect = document.getElementById('language-select');
languageSelect.addEventListener('change', function() {
window.location.href = "{{ url_for('main.set_language', lang='') }}" + this.value;
});
// 密码可见性切换
const passwordInput = document.getElementById('password');
const passwordToggle = document.getElementById('password-toggle');
if (passwordToggle && passwordInput) {
passwordToggle.addEventListener('click', function() {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
passwordToggle.innerHTML = type === 'password' ? '👁️' : '👁️‍🗨️';
});
}
// 账号/扫码登录切换
const accountTab = document.getElementById('account-tab');
const qrTab = document.getElementById('qr-tab');
const accountLogin = document.getElementById('account-login');
const qrCodeLogin = document.getElementById('qr-code-login');
if (accountTab && qrTab && accountLogin && qrCodeLogin) {
accountTab.addEventListener('click', function() {
accountTab.classList.add('active');
qrTab.classList.remove('active');
accountLogin.style.display = 'block';
qrCodeLogin.style.display = 'none';
});
qrTab.addEventListener('click', function() {
qrTab.classList.add('active');
accountTab.classList.remove('active');
qrCodeLogin.style.display = 'block';
accountLogin.style.display = 'none';
});
}
// 表单验证
const loginForm = document.getElementById('login-form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');
const passwordError = document.getElementById('password-error');
const loginButton = document.getElementById('login-button');
if (loginForm && emailInput && passwordInput) {
emailInput.addEventListener('input', validateEmail);
passwordInput.addEventListener('input', validatePassword);
loginForm.addEventListener('submit', function(e) {
if (!validateEmail() || !validatePassword()) {
e.preventDefault();
} else {
loginButton.classList.add('loading-state');
}
});
}
function validateEmail() {
if (!emailInput || !emailError) return true;
const value = emailInput.value.trim();
if (value === '') {
emailError.textContent = '{{ _("email_required") }}';
emailError.classList.add('show');
return false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
emailError.textContent = '{{ _("invalid_email") }}';
emailError.classList.add('show');
return false;
} else {
emailError.classList.remove('show');
return true;
}
}
function validatePassword() {
if (!passwordInput || !passwordError) return true;
const value = passwordInput.value;
if (value === '') {
passwordError.textContent = '{{ _("password_required") }}';
passwordError.classList.add('show');
return false;
} else if (value.length < 8) {
passwordError.textContent = '{{ _("password_too_short") }}';
passwordError.classList.add('show');
return false;
} else {
passwordError.classList.remove('show');
return true;
}
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,479 @@
{% extends "base.html" %}
{% block title %}{{ _('register') }} - 高可用学习平台{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/register.css') }}">
{% endblock %}
{% block full_content %}
<div class="overlay"></div>
<div class="theme-toggle" id="theme-toggle">
{% if session.get('theme', 'light') == 'light' %}☀️{% else %}🌙{% endif %}
</div>
<div class="language-selector">
<select id="language-select">
<option value="zh" {% if session.get('language', 'zh') == 'zh' %}selected{% endif %}>简体中文</option>
<option value="en" {% if session.get('language', 'zh') == 'en' %}selected{% endif %}>English</option>
</select>
</div>
<div class="main-container">
<div class="login-container">
<div class="logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo" onerror="this.src='/api/placeholder/90/90'">
</div>
<h1>高可用学习平台</h1>
<p class="subtitle">{{ _('create_account') }}</p>
<div class="step-container">
<div class="step {% if not email_verified %}active{% else %}completed{% endif %}" id="step-1">1
<div class="step-label">{{ _('email_verification') }}</div>
</div>
<div class="step {% if email_verified %}active{% endif %}" id="step-2">2
<div class="step-label">{{ _('account_info') }}</div>
</div>
<div class="step" id="step-3">3
<div class="step-label">{{ _('registration_success') }}</div>
</div>
</div>
<div class="registration-step {% if not email_verified %}active{% endif %}" id="step-1-content">
<form id="email-verification-form" method="post" action="{{ url_for('auth.register') }}">
{{ email_form.hidden_tag() }}
<input type="hidden" name="send_code" value="1">
<div class="form-group">
<label for="email">{{ _('email') }}</label>
<div class="verification-container">
<div class="input-with-icon verification-input">
<span class="input-icon">📧</span>
{{ email_form.email(class="form-control", placeholder=_('email_placeholder'), id="verification-email") }}
</div>
<button type="submit" class="send-code-btn" id="send-code-btn">
{{ _('send_verification_code') }}
</button>
</div>
{% for error in email_form.email.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="email-error"></div>
</div>
<div class="form-buttons">
<a href="{{ url_for('auth.login') }}" class="btn btn-login btn-back">{{ _('back_to_login') }}</a>
</div>
</form>
</div>
<div class="registration-step {% if email_verified %}active{% endif %}" id="step-2-content">
<form id="registration-form" method="post" action="{{ url_for('auth.register') }}">
{{ registration_form.hidden_tag() }}
<input type="hidden" name="register" value="1">
<div class="form-group">
<label for="email">{{ _('email') }}</label>
<div class="input-with-icon">
<span class="input-icon">📧</span>
{{ registration_form.email(class="form-control", readonly="readonly", value=verified_email) }}
</div>
</div>
<div class="form-group">
<label for="verification_code">{{ _('verification_code') }}</label>
<div class="input-with-icon">
<span class="input-icon">🔑</span>
{{ registration_form.verification_code(class="form-control", placeholder=_('enter_verification_code')) }}
</div>
{% for error in registration_form.verification_code.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="code-error"></div>
<div class="countdown" id="resend-countdown"></div>
<button type="button" class="btn-link" id="resend-btn" style="display: none; background: none; border: none; color: var(--primary-color); padding: 0; margin-top: 5px; cursor: pointer; text-decoration: underline;">{{ _('resend_code') }}</button>
</div>
<div class="form-group">
<label for="username">{{ _('username') }}</label>
<div class="input-with-icon">
<span class="input-icon">👤</span>
{{ registration_form.username(class="form-control", placeholder=_('enter_username')) }}
</div>
{% for error in registration_form.username.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="username-error"></div>
</div>
<div class="form-group">
<label for="password">{{ _('password') }}</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
{{ registration_form.password(class="form-control", placeholder=_('enter_password'), id="register-password") }}
<span class="password-toggle" id="password-toggle">👁️</span>
</div>
{% for error in registration_form.password.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="password-error"></div>
<div class="strength-meter">
<div class="strength-meter-fill" id="strength-meter-fill"></div>
</div>
<div class="password-strength-text" id="password-strength-text"></div>
</div>
<div class="form-group">
<label for="password2">{{ _('repeat_password') }}</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
{{ registration_form.password2(class="form-control", placeholder=_('repeat_password_placeholder')) }}
</div>
{% for error in registration_form.password2.errors %}
<div class="validation-message show">{{ error }}</div>
{% endfor %}
<div class="validation-message" id="password2-error"></div>
</div>
<div class="form-buttons">
<button type="button" class="btn btn-login btn-back" id="back-to-step1">{{ _('back') }}</button>
<button type="submit" class="btn btn-login btn-next">{{ _('register') }}</button>
</div>
</form>
</div>
</div>
</div>
<footer>
<p>© 2023 高可用学习平台 | <a href="#">{{ _('privacy_policy') }}</a> | <a href="#">{{ _('terms_of_service') }}</a></p>
</footer>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// 主题切换
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', function() {
const currentTheme = "{{ session.get('theme', 'light') }}";
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
window.location.href = "{{ url_for('main.set_theme', theme='') }}" + newTheme + "?next={{ request.path|urlencode }}";
});
// 语言选择器
const languageSelect = document.getElementById('language-select');
languageSelect.addEventListener('change', function() {
window.location.href = "{{ url_for('main.set_language', lang='') }}" + this.value + "?next={{ request.path|urlencode }}";
});
// 密码可见性切换
const passwordInput = document.getElementById('register-password');
const passwordToggle = document.getElementById('password-toggle');
if (passwordToggle && passwordInput) {
passwordToggle.addEventListener('click', function() {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
passwordToggle.innerHTML = type === 'password' ? '👁️' : '👁️‍🗨️';
});
}
// 密码强度检测
if (passwordInput) {
const strengthMeter = document.getElementById('strength-meter-fill');
const strengthText = document.getElementById('password-strength-text');
passwordInput.addEventListener('input', function() {
const password = this.value;
const strength = checkPasswordStrength(password);
// 更新强度指示器
strengthMeter.className = 'strength-meter-fill';
if (password.length === 0) {
strengthMeter.style.width = '0';
strengthText.textContent = '';
} else if (strength < 2) {
strengthMeter.classList.add('weak');
strengthText.textContent = '{{ _("password_weak") }}';
} else if (strength < 3) {
strengthMeter.classList.add('medium');
strengthText.textContent = '{{ _("password_medium") }}';
} else if (strength < 4) {
strengthMeter.classList.add('strong');
strengthText.textContent = '{{ _("password_strong") }}';
} else {
strengthMeter.classList.add('very-strong');
strengthText.textContent = '{{ _("password_very_strong") }}';
}
});
}
// 检查密码强度的函数
function checkPasswordStrength(password) {
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
return strength;
}
// 注册表单验证
const registrationForm = document.getElementById('registration-form');
const verificationCodeInput = document.getElementById('verification_code');
const usernameInput = document.getElementById('username');
const password2Input = document.getElementById('password2');
const codeError = document.getElementById('code-error');
const usernameError = document.getElementById('username-error');
const password2Error = document.getElementById('password2-error');
if (registrationForm) {
registrationForm.addEventListener('submit', function(e) {
let isValid = true;
// 验证码验证
if (verificationCodeInput.value.trim() === '') {
codeError.textContent = '{{ _("verification_code_required") }}';
codeError.classList.add('show');
isValid = false;
} else if (verificationCodeInput.value.length !== 6) {
codeError.textContent = '{{ _("verification_code_invalid") }}';
codeError.classList.add('show');
isValid = false;
} else {
codeError.classList.remove('show');
}
// 用户名验证
if (usernameInput.value.trim() === '') {
usernameError.textContent = '{{ _("username_required") }}';
usernameError.classList.add('show');
isValid = false;
} else {
usernameError.classList.remove('show');
}
// 密码匹配验证
if (password2Input.value !== passwordInput.value) {
password2Error.textContent = '{{ _("passwords_not_match") }}';
password2Error.classList.add('show');
isValid = false;
} else {
password2Error.classList.remove('show');
}
if (!isValid) {
e.preventDefault();
}
});
}
// 邮箱验证表单
const emailVerificationForm = document.getElementById('email-verification-form');
const emailInput = document.getElementById('verification-email');
const emailError = document.getElementById('email-error');
const sendCodeBtn = document.getElementById('send-code-btn');
if (emailVerificationForm) {
emailVerificationForm.addEventListener('submit', function(e) {
if (emailInput.value.trim() === '') {
e.preventDefault();
emailError.textContent = '{{ _("email_required") }}';
emailError.classList.add('show');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput.value.trim())) {
e.preventDefault();
emailError.textContent = '{{ _("invalid_email") }}';
emailError.classList.add('show');
} else {
emailError.classList.remove('show');
}
});
// 动态发送验证码
sendCodeBtn.addEventListener('click', function(e) {
if (emailInput.value.trim() === '' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput.value.trim())) {
return; // 让表单自己处理验证
}
e.preventDefault();
// 禁用按钮
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = '{{ _("sending") }}...';
// 发送Ajax请求
const xhr = new XMLHttpRequest();
xhr.open('POST', '{{ url_for("auth.send_verification_code") }}', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
// 显示成功消息
showNotification(response.message, 'success');
// 存储邮箱地址
const verifiedEmail = emailInput.value.trim();
// 显示下一步
document.getElementById('step-1').classList.remove('active');
document.getElementById('step-1').classList.add('completed');
document.getElementById('step-2').classList.add('active');
document.getElementById('step-1-content').classList.remove('active');
document.getElementById('step-2-content').classList.add('active');
// 设置邮箱字段
document.getElementById('email').value = verifiedEmail;
// 启动倒计时
startResendCountdown();
} else {
// 显示错误消息
showNotification(response.message, 'error');
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '{{ _("send_verification_code") }}';
}
} catch (e) {
showNotification('{{ _("server_error") }}', 'error');
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '{{ _("send_verification_code") }}';
}
} else {
showNotification('{{ _("server_error") }}', 'error');
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '{{ _("send_verification_code") }}';
}
}
};
xhr.send('email=' + encodeURIComponent(emailInput.value.trim()));
});
}
// 返回按钮
const backToStep1 = document.getElementById('back-to-step1');
if (backToStep1) {
backToStep1.addEventListener('click', function() {
document.getElementById('step-1').classList.add('active');
document.getElementById('step-1').classList.remove('completed');
document.getElementById('step-2').classList.remove('active');
document.getElementById('step-1-content').classList.add('active');
document.getElementById('step-2-content').classList.remove('active');
});
}
// 重发验证码
const resendBtn = document.getElementById('resend-btn');
const resendCountdown = document.getElementById('resend-countdown');
if (resendBtn) {
resendBtn.addEventListener('click', function() {
// 禁用按钮
resendBtn.style.display = 'none';
// 发送Ajax请求
const xhr = new XMLHttpRequest();
xhr.open('POST', '{{ url_for("auth.send_verification_code") }}', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
showNotification(response.message, 'success');
startResendCountdown();
} else {
showNotification(response.message, 'error');
resendBtn.style.display = 'block';
}
} catch (e) {
showNotification('{{ _("server_error") }}', 'error');
resendBtn.style.display = 'block';
}
} else if (xhr.readyState === 4) {
showNotification('{{ _("server_error") }}', 'error');
resendBtn.style.display = 'block';
}
};
xhr.send('email=' + encodeURIComponent(document.getElementById('email').value));
});
}
// 验证码倒计时
function startResendCountdown() {
let seconds = 60;
resendCountdown.textContent = `{{ _("resend_code_in") }} ${seconds} {{ _("seconds") }}`;
resendCountdown.style.display = 'block';
resendBtn.style.display = 'none';
const interval = setInterval(function() {
seconds--;
resendCountdown.textContent = `{{ _("resend_code_in") }} ${seconds} {{ _("seconds") }}`;
if (seconds <= 0) {
clearInterval(interval);
resendCountdown.style.display = 'none';
resendBtn.style.display = 'block';
}
}, 1000);
}
// 显示通知
function showNotification(message, type) {
// 检查是否已有通知
let notification = document.querySelector('.notification');
if (notification) {
document.body.removeChild(notification);
}
// 创建新通知
notification = document.createElement('div');
notification.className = `notification ${type}`;
const icon = type === 'success' ? '✓' : '✗';
notification.innerHTML = `
<div class="notification-icon">${icon}</div>
<div class="notification-content">
<p>${message}</p>
</div>
`;
document.body.appendChild(notification);
// 显示通知
setTimeout(() => {
notification.classList.add('show');
}, 10);
// 自动隐藏
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
try {
document.body.removeChild(notification);
} catch (e) {
// 忽略可能的错误
}
}, 300);
}, 3000);
}
// 如果是第二步,启动倒计时
{% if email_verified %}
startResendCountdown();
{% endif %}
});
</script>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ _('reset_password') }} - 高可用学习平台{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-form">
<h2>{{ _('reset_password') }}</h2>
<form method="post" action="{{ url_for('auth.reset_password', token=request.view_args['token']) }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password2.label(class="form-label") }}
{{ form.password2(class="form-control") }}
{% for error in form.password2.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}{{ _('reset_password') }} - 高可用学习平台{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-form">
<h2>{{ _('reset_password') }}</h2>
<form method="post" action="{{ url_for('auth.reset_password_request') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('auth.login') }}">{{ _('login') }}</a>
</div>
</form>
</div>
</div>
{% endblock %}

83
templates/base.html Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="{{ session.get('language', 'zh') }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}高可用学习平台{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
:root {
--primary-color: #4a89dc;
--primary-hover: #3b78c4;
--secondary-color: #5cb85c;
--text-color: #333;
--light-text: #666;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--border-color: #ddd;
--error-color: #e74c3c;
--success-color: #2ecc71;
}
body.theme-dark {
--primary-color: #5a9aed;
--primary-hover: #4a89dc;
--secondary-color: #6bc76b;
--text-color: #f1f1f1;
--light-text: #aaa;
--bg-color: #1a1a1a;
--card-bg: #2c2c2c;
--border-color: #444;
}
</style>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% if session.get('theme', 'light') == 'dark' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/dark.css') }}" id="theme-css">
{% else %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/light.css') }}" id="theme-css">
{% endif %}
{% block extra_css %}{% endblock %}
</head>
<body class="theme-{{ session.get('theme', 'light') }}">
{% block full_content %}
<div class="container">
{% if not hide_language_switch|default(false) %}
<div class="language-switch">
<a href="{{ url_for('main.set_language', lang='zh', next=request.path) }}">中文</a> |
<a href="{{ url_for('main.set_language', lang='en', next=request.path) }}">English</a>
</div>
{% endif %}
{% if not hide_theme_switch|default(false) %}
<div class="theme-switch">
{% if session.get('theme', 'light') == 'light' %}
<a href="{{ url_for('main.set_theme', theme='dark', next=request.path) }}">暗色模式</a>
{% else %}
<a href="{{ url_for('main.set_theme', theme='light', next=request.path) }}">亮色模式</a>
{% endif %}
</div>
{% endif %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flashes">
{% for message in messages %}
<div class="alert alert-info">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

65
templates/main/index.html Normal file
View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}{{ _('my_dashboard') }} - 高可用学习平台{% endblock %}
{% block content %}
<div class="dashboard-container">
<header class="dashboard-header">
<h1>{{ _('welcome') }}</h1>
<div class="user-info">
<span>{{ current_user.username }}</span>
<a href="{{ url_for('auth.logout') }}" class="btn btn-sm btn-outline-danger">{{ _('logout') }}</a>
</div>
</header>
<div class="services-grid">
<div class="service-card">
<h3>{{ _('network_drive') }}</h3>
<p>安全存储和共享文件</p>
<a href="https://pan.sq0715.com" class="btn btn-primary" target="_blank">访问网盘</a>
</div>
<div class="service-card">
<h3>{{ _('email_system') }}</h3>
<p>您的专属邮箱系统</p>
<a href="https://mail.sq0715.com" class="btn btn-primary" target="_blank">访问邮箱</a>
</div>
<div class="service-card">
<h3>{{ _('code_compiler') }}</h3>
<p>在线编译和运行代码</p>
<a href="#" class="btn btn-primary" target="_blank">访问编译器</a>
</div>
<div class="service-card">
<h3>{{ _('file_submission') }}</h3>
<p>提交和管理作业文件</p>
<a href="#" class="btn btn-primary" target="_blank">访问提交系统</a>
</div>
<div class="service-card">
<h3>{{ _('video_platform') }}</h3>
<p>观看和分享视频内容</p>
<a href="#" class="btn btn-primary" target="_blank">访问视频平台</a>
</div>
<div class="service-card">
<h3>{{ _('blog_space') }}</h3>
<p>发布和阅读博客文章</p>
<a href="#" class="btn btn-primary" target="_blank">访问博客</a>
</div>
<div class="service-card">
<h3>{{ _('ai_platform') }}</h3>
<p>AI助手和工具</p>
<a href="https://sqai.online" class="btn btn-primary" target="_blank">访问AI平台</a>
</div>
<div class="service-card">
<h3>{{ _('code_repository') }}</h3>
<p>代码版本控制</p>
<a href="https://git.sq0715.com" class="btn btn-primary" target="_blank">访问代码仓库</a>
</div>
</div>
</div>
{% endblock %}