爆炸級的整體優化
This commit is contained in:
parent
9f20c8dbba
commit
4bb0b1cfa8
app
models
routes
services
static/css
templates
@ -4,19 +4,22 @@ from datetime import datetime
|
||||
|
||||
class Comment(db.Model):
|
||||
"""留言模型"""
|
||||
__tablename__ = 'comment'
|
||||
|
||||
# 基本欄位
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
content = db.Column(db.Text, nullable=False, comment='留言內容')
|
||||
|
||||
# 時間相關欄位
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='創建時間')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新時間')
|
||||
|
||||
# 外鍵關聯
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, comment='留言者ID')
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False, comment='文章ID')
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'), comment='父留言ID,用於回覆功能')
|
||||
|
||||
# 關聯關係
|
||||
author = db.relationship('User', backref=db.backref('comments', lazy=True))
|
||||
post = db.relationship('Post', backref=db.backref('comments', lazy=True, cascade='all, delete-orphan'))
|
||||
replies = db.relationship(
|
||||
'Comment',
|
||||
backref=db.backref('parent', remote_side=[id]),
|
||||
@ -24,5 +27,23 @@ class Comment(db.Model):
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
@property
|
||||
def reply_count(self):
|
||||
"""獲取回覆數量"""
|
||||
return self.replies.count()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Comment {self.id}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式(用於API回應)"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'content': self.content,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'user_id': self.user_id,
|
||||
'post_id': self.post_id,
|
||||
'parent_id': self.parent_id,
|
||||
'reply_count': self.reply_count
|
||||
}
|
||||
|
@ -1,16 +1,42 @@
|
||||
# app/models/like.py
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Like(db.Model):
|
||||
"""按讚模型"""
|
||||
__tablename__ = 'likes'
|
||||
|
||||
# 基本欄位
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='按讚時間')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'post_id', name='_user_post_uc'),)
|
||||
# 外鍵關聯
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, comment='用戶ID')
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False, comment='文章ID')
|
||||
|
||||
# 確保每個用戶只能對同一篇文章按讚一次
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'post_id', name='unique_user_post_like'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Like {self.id}>'
|
||||
return f'<Like {self.id}: User {self.user_id} -> Post {self.post_id}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式(用於API回應)"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'user_id': self.user_id,
|
||||
'post_id': self.post_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_user_likes(cls, user_id):
|
||||
"""獲取用戶的所有按讚記錄"""
|
||||
return cls.query.filter_by(user_id=user_id).all()
|
||||
|
||||
@classmethod
|
||||
def get_post_likes(cls, post_id):
|
||||
"""獲取文章的所有按讚記錄"""
|
||||
return cls.query.filter_by(post_id=post_id).all()
|
||||
|
@ -3,15 +3,23 @@ from datetime import datetime
|
||||
|
||||
|
||||
class Post(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
"""文章模型"""
|
||||
__tablename__ = 'post'
|
||||
|
||||
# 關聯
|
||||
author = db.relationship('User', backref=db.backref('posts', lazy=True))
|
||||
# 基本欄位
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(100), nullable=False, comment='標題')
|
||||
content = db.Column(db.Text, nullable=False, comment='內容')
|
||||
|
||||
# 時間相關欄位
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='創建時間')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新時間')
|
||||
|
||||
# 外鍵
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, comment='作者ID')
|
||||
|
||||
# 關聯關係
|
||||
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
likes = db.relationship('Like', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
@ -19,8 +27,13 @@ class Post(db.Model):
|
||||
"""獲取按讚數量"""
|
||||
return self.likes.count()
|
||||
|
||||
@property
|
||||
def comments_count(self) -> int:
|
||||
"""獲取留言數量"""
|
||||
return self.comments.count()
|
||||
|
||||
def is_liked_by(self, user):
|
||||
"""檢查用戶是否已按讚"""
|
||||
"""檢查用戶是否已按讚此文章"""
|
||||
if not user:
|
||||
return False
|
||||
return bool(self.likes.filter_by(user_id=user.id).first())
|
||||
|
@ -2,52 +2,108 @@ 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
|
||||
from .like import Like
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(id):
|
||||
"""載入用戶,用於 Flask-Login"""
|
||||
return User.query.get(int(id))
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, index=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, index=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128))
|
||||
avatar_path = db.Column(db.String(200))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
"""用戶模型"""
|
||||
__tablename__ = 'user'
|
||||
|
||||
def like_post(self, post):
|
||||
"""按讚文章"""
|
||||
if not self.is_liking(post):
|
||||
# 基本欄位
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, index=True, nullable=False, comment='用戶名')
|
||||
email = db.Column(db.String(120), unique=True, index=True, nullable=False, comment='電子郵件')
|
||||
password_hash = db.Column(db.String(128), comment='密碼雜湊')
|
||||
avatar_path = db.Column(db.String(200), nullable=True, comment='頭像路徑')
|
||||
|
||||
# 時間相關欄位
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='創建時間')
|
||||
last_login = db.Column(db.DateTime, default=datetime.utcnow, comment='最後登入時間')
|
||||
|
||||
# 狀態欄位
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否啟用')
|
||||
is_admin = db.Column(db.Boolean, default=False, comment='是否為管理員')
|
||||
|
||||
# 關聯關係
|
||||
posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
|
||||
comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
|
||||
likes = db.relationship('Like', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""
|
||||
設置密碼
|
||||
|
||||
Args:
|
||||
password: 原始密碼
|
||||
"""
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""
|
||||
驗證密碼
|
||||
|
||||
Args:
|
||||
password: 待驗證的密碼
|
||||
|
||||
Returns:
|
||||
bool: 密碼是否正確
|
||||
"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def like_post(self, post) -> None:
|
||||
"""
|
||||
對文章按讚
|
||||
|
||||
Args:
|
||||
post: 文章實例
|
||||
"""
|
||||
if not self.has_liked_post(post):
|
||||
from app.models.like import Like
|
||||
like = Like(user_id=self.id, post_id=post.id)
|
||||
db.session.add(like)
|
||||
|
||||
def unlike_post(self, post):
|
||||
"""取消按讚"""
|
||||
like = Like.query.filter_by(
|
||||
user_id=self.id,
|
||||
post_id=post.id
|
||||
).first()
|
||||
def unlike_post(self, post) -> None:
|
||||
"""
|
||||
取消文章按讚
|
||||
|
||||
Args:
|
||||
post: 文章實例
|
||||
"""
|
||||
from app.models.like import Like
|
||||
like = Like.query.filter_by(user_id=self.id, post_id=post.id).first()
|
||||
if like:
|
||||
db.session.delete(like)
|
||||
|
||||
def is_liking(self, post):
|
||||
"""檢查是否已按讚"""
|
||||
return Like.query.filter_by(
|
||||
user_id=self.id,
|
||||
post_id=post.id
|
||||
).first() is not None
|
||||
def has_liked_post(self, post) -> bool:
|
||||
"""
|
||||
檢查是否已對文章按讚
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
Args:
|
||||
post: 文章實例
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
Returns:
|
||||
bool: 是否已按讚
|
||||
"""
|
||||
from app.models.like import Like
|
||||
return Like.query.filter_by(user_id=self.id, post_id=post.id).first() is not None
|
||||
|
||||
def __repr__(self):
|
||||
@property
|
||||
def posts_count(self) -> int:
|
||||
"""獲取發文總數"""
|
||||
return self.posts.count()
|
||||
|
||||
@property
|
||||
def received_likes_count(self) -> int:
|
||||
"""獲取收到的總讚數"""
|
||||
from app.models.like import Like
|
||||
from app.models.post import Post
|
||||
return Like.query.join(Post).filter(Post.user_id == self.id).count()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""模型的字符串表示"""
|
||||
return f'<User {self.username}>'
|
||||
|
@ -3,35 +3,47 @@ from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.services import UserService
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""處理用戶登入"""
|
||||
"""
|
||||
用戶登入處理
|
||||
GET: 顯示登入頁面
|
||||
POST: 處理登入請求
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
remember = request.form.get('remember', False)
|
||||
# 獲取表單數據
|
||||
credentials = {
|
||||
'email': request.form.get('email'),
|
||||
'password': request.form.get('password'),
|
||||
'remember': bool(request.form.get('remember'))
|
||||
}
|
||||
|
||||
user = UserService.get_user_by_email(email)
|
||||
if user is None or not user.check_password(password):
|
||||
# 驗證用戶
|
||||
user = UserService.get_user_by_email(credentials['email'])
|
||||
if not user or not user.check_password(credentials['password']):
|
||||
flash('電子郵件或密碼錯誤', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# 更新最後登入時間
|
||||
UserService.update_last_login(user.id)
|
||||
login_user(user, remember=remember)
|
||||
# 更新登入時間
|
||||
success, error = UserService.update_last_login(user.id)
|
||||
if not success:
|
||||
current_app.logger.error(f"Failed to update last login time: {error}")
|
||||
|
||||
# 登入用戶
|
||||
login_user(user, remember=credentials['remember'])
|
||||
flash('登入成功!', 'success')
|
||||
|
||||
# 處理重定向
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or urlparse(next_page).netloc != '':
|
||||
next_page = url_for('main.index')
|
||||
|
||||
flash('登入成功!', 'success')
|
||||
return redirect(next_page)
|
||||
|
||||
return render_template('auth/login.html', title='登入')
|
||||
@ -39,21 +51,35 @@ def login():
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""處理用戶註冊"""
|
||||
"""
|
||||
用戶註冊處理
|
||||
GET: 顯示註冊頁面
|
||||
POST: 處理註冊請求
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
# 獲取註冊資料
|
||||
user_data = {
|
||||
'email': request.form.get('email'),
|
||||
'username': request.form.get('username'),
|
||||
'password': request.form.get('password'),
|
||||
'confirm_password': request.form.get('confirm_password')
|
||||
}
|
||||
|
||||
if password != confirm_password:
|
||||
# 驗證密碼
|
||||
if user_data['password'] != user_data['confirm_password']:
|
||||
flash('密碼不一致', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
user, error = UserService.create_user(username, email, password)
|
||||
# 創建用戶
|
||||
user, error = UserService.create_user(
|
||||
username=user_data['username'],
|
||||
email=user_data['email'],
|
||||
password=user_data['password']
|
||||
)
|
||||
|
||||
if error:
|
||||
flash(error, 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
@ -76,48 +102,59 @@ def logout():
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""處理用戶個人資料更新"""
|
||||
"""
|
||||
用戶個人資料管理
|
||||
GET: 顯示個人資料頁面
|
||||
POST: 處理個人資料更新
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'update_profile':
|
||||
# 處理基本資料更新
|
||||
# 處理頭像更新
|
||||
if 'avatar' in request.files:
|
||||
file = request.files['avatar']
|
||||
if file.filename:
|
||||
success, error = UserService.update_avatar(
|
||||
user_id=current_user.id,
|
||||
file=file,
|
||||
app=current_app
|
||||
)
|
||||
file=file
|
||||
) # 移除 app 參數
|
||||
if not success:
|
||||
flash(f'頭像更新失敗: {error}', 'danger')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# 更新其他資料
|
||||
# 處理基本資料更新
|
||||
profile_data = {
|
||||
'username': request.form.get('username'),
|
||||
'email': request.form.get('email')
|
||||
}
|
||||
|
||||
success, error = UserService.update_profile(
|
||||
user_id=current_user.id,
|
||||
username=request.form.get('username'),
|
||||
email=request.form.get('email')
|
||||
**profile_data
|
||||
)
|
||||
|
||||
if success:
|
||||
flash('個人資料已更新', 'success')
|
||||
else:
|
||||
flash(f'更新失敗: {error}', 'danger')
|
||||
flash('個人資料已更新' if success else f'更新失敗: {error}',
|
||||
'success' if success else 'danger')
|
||||
|
||||
|
||||
elif action == 'update_password':
|
||||
# 處理密碼更新
|
||||
password_data = {
|
||||
'current_password': request.form.get('current_password'),
|
||||
'new_password': request.form.get('new_password')
|
||||
}
|
||||
# 驗證輸入
|
||||
if not all(password_data.values()):
|
||||
flash('請填寫所有密碼欄位', 'danger')
|
||||
return redirect(url_for('auth.profile'))
|
||||
success, error = UserService.update_password(
|
||||
user_id=current_user.id,
|
||||
current_password=request.form.get('current_password'),
|
||||
new_password=request.form.get('new_password')
|
||||
current_password=password_data['current_password'],
|
||||
new_password=password_data['new_password']
|
||||
)
|
||||
|
||||
if success:
|
||||
flash('密碼已更新', 'success')
|
||||
else:
|
||||
flash(f'更新失敗: {error}', 'danger')
|
||||
|
||||
flash('密碼已更新' if success else f'更新失敗: {error}',
|
||||
'success' if success else 'danger')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
return render_template('auth/profile.html', title='個人資料')
|
||||
|
@ -1,60 +1,44 @@
|
||||
# 標準庫導入
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask_login import current_user
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app import db
|
||||
from app.models import User, Post
|
||||
from app.services import UserService
|
||||
|
||||
# 第三方套件導入
|
||||
from flask import render_template, Blueprint, request
|
||||
|
||||
# 本地應用導入
|
||||
from app.models.user import User
|
||||
from app.models.post import Post
|
||||
|
||||
# 常數配置
|
||||
POSTS_PER_PAGE = 5
|
||||
MEMBERS_PER_PAGE = 16
|
||||
DATE_FORMAT = '%Y-%m-%d %H:%M'
|
||||
|
||||
# 創建主要藍圖
|
||||
main_bp = Blueprint('main', __name__, url_prefix='/')
|
||||
|
||||
|
||||
def get_latest_posts(limit=POSTS_PER_PAGE):
|
||||
def get_active_users(limit: int = 12) -> list:
|
||||
"""
|
||||
獲取最新的文章
|
||||
:param limit: 限制返回的文章數量
|
||||
:return: 文章列表
|
||||
獲取活躍用戶列表
|
||||
|
||||
Args:
|
||||
limit: 返回用戶數量限制,預設12人
|
||||
|
||||
Returns:
|
||||
活躍用戶列表
|
||||
"""
|
||||
return Post.query.order_by(Post.created_at.desc()).limit(limit).all()
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
return User.query.filter(
|
||||
User.last_login >= thirty_days_ago
|
||||
).order_by(
|
||||
User.last_login.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
|
||||
def get_user_statistics():
|
||||
def get_site_statistics() -> dict:
|
||||
"""
|
||||
獲取用戶統計資料
|
||||
:return: dict 包含總用戶數和本月新增用戶數
|
||||
"""
|
||||
total_users = User.query.count()
|
||||
|
||||
# 計算本月新增用戶
|
||||
first_day_of_month = datetime.utcnow().replace(
|
||||
day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
new_users_this_month = User.query.filter(
|
||||
User.created_at >= first_day_of_month
|
||||
).count()
|
||||
|
||||
return {
|
||||
'total': total_users,
|
||||
'new_this_month': new_users_this_month
|
||||
}
|
||||
|
||||
|
||||
def get_last_update_time():
|
||||
"""
|
||||
獲取最後更新時間
|
||||
:return: str 格式化的最後更新時間
|
||||
獲取網站統計資訊
|
||||
|
||||
Returns:
|
||||
包含網站統計資訊的字典
|
||||
"""
|
||||
# 獲取最後更新時間
|
||||
latest_user = User.query.order_by(User.created_at.desc()).first()
|
||||
latest_post = Post.query.order_by(Post.created_at.desc()).first()
|
||||
|
||||
# 計算最後更新時間
|
||||
last_update = None
|
||||
if latest_user and latest_post:
|
||||
last_update = max(latest_user.created_at, latest_post.created_at)
|
||||
@ -63,53 +47,64 @@ def get_last_update_time():
|
||||
elif latest_post:
|
||||
last_update = latest_post.created_at
|
||||
|
||||
return last_update.strftime(DATE_FORMAT) if last_update else '無資料'
|
||||
return last_update.strftime('%Y-%m-%d %H:%M') if last_update else '無資料'
|
||||
|
||||
|
||||
# 路由定義
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""首頁視圖函數"""
|
||||
# 獲取統計資料
|
||||
user_stats = get_user_statistics()
|
||||
"""首頁視圖"""
|
||||
# 獲取用戶統計
|
||||
user_stats = UserService.get_user_statistics()
|
||||
|
||||
# 獲取最新文章
|
||||
latest_posts = Post.query.options(
|
||||
joinedload(Post.author)
|
||||
).order_by(Post.created_at.desc()).limit(10).all()
|
||||
|
||||
# 準備模板數據
|
||||
template_data = {
|
||||
'title': '首頁',
|
||||
'latest_posts': get_latest_posts(),
|
||||
'latest_posts': latest_posts,
|
||||
'total_users': user_stats['total'],
|
||||
'total_posts': Post.query.count(),
|
||||
'new_users_this_month': user_stats['new_this_month'],
|
||||
'last_update': get_last_update_time()
|
||||
'last_update': get_site_statistics(),
|
||||
'active_users': get_active_users()
|
||||
}
|
||||
|
||||
# 如果用戶已登入,添加用戶統計資訊
|
||||
if current_user.is_authenticated:
|
||||
user_activity_stats = UserService.get_user_stats(current_user.id)
|
||||
template_data['user_stats'] = {
|
||||
'posts_count': user_activity_stats['posts_count'],
|
||||
'received_likes': user_activity_stats['received_likes']
|
||||
}
|
||||
|
||||
return render_template('main/index.html', **template_data)
|
||||
|
||||
|
||||
@main_bp.route('/about')
|
||||
def about():
|
||||
"""關於頁面視圖函數"""
|
||||
return render_template('main/about.html', title='關於我們')
|
||||
|
||||
|
||||
@main_bp.route('/members')
|
||||
def members():
|
||||
"""會員列表視圖函數"""
|
||||
# 獲取當前頁碼
|
||||
"""會員列表視圖"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 16 # 每頁顯示的會員數量
|
||||
|
||||
# 查詢會員資料
|
||||
pagination = User.query.order_by(User.created_at.desc()).paginate(
|
||||
pagination = User.query.order_by(
|
||||
User.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=MEMBERS_PER_PAGE,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# 準備模板數據
|
||||
template_data = {
|
||||
'title': '會員列表',
|
||||
'users': pagination.items,
|
||||
'pagination': pagination
|
||||
}
|
||||
return render_template('main/members.html',
|
||||
title='會員列表',
|
||||
users=pagination.items,
|
||||
pagination=pagination)
|
||||
|
||||
return render_template('main/members.html', **template_data)
|
||||
|
||||
@main_bp.route('/about')
|
||||
def about():
|
||||
"""關於頁面視圖"""
|
||||
return render_template('main/about.html',
|
||||
title='關於我們')
|
||||
|
@ -1,21 +1,37 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, url_for,
|
||||
flash, request, jsonify, current_app
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from app.services import PostService, CommentService, LikeService
|
||||
|
||||
|
||||
post_bp = Blueprint('post', __name__, url_prefix='/posts')
|
||||
|
||||
# 配置常量
|
||||
POSTS_PER_PAGE = 10
|
||||
|
||||
|
||||
@post_bp.route('/')
|
||||
def index():
|
||||
"""文章列表"""
|
||||
"""
|
||||
文章列表頁面
|
||||
支援搜索和分頁功能
|
||||
"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search_query = request.args.get('q')
|
||||
|
||||
# 根據是否有搜索關鍵字決定查詢方式
|
||||
if search_query:
|
||||
pagination = PostService.search_posts(search_query, page=page)
|
||||
pagination = PostService.search_posts(
|
||||
query=search_query,
|
||||
page=page,
|
||||
per_page=POSTS_PER_PAGE
|
||||
)
|
||||
else:
|
||||
pagination = PostService.get_posts_page(page=page)
|
||||
pagination = PostService.get_posts_page(
|
||||
page=page,
|
||||
per_page=POSTS_PER_PAGE
|
||||
)
|
||||
|
||||
return render_template('posts/index.html',
|
||||
title='文章列表',
|
||||
@ -26,15 +42,20 @@ def index():
|
||||
@post_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
"""創建文章"""
|
||||
"""
|
||||
創建新文章
|
||||
GET: 顯示創建文章表單
|
||||
POST: 處理文章創建請求
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
post_data = {
|
||||
'title': request.form.get('title', '').strip(),
|
||||
'content': request.form.get('content', '').strip()
|
||||
}
|
||||
|
||||
post, error = PostService.create_post(
|
||||
user_id=current_user.id,
|
||||
title=title,
|
||||
content=content
|
||||
**post_data
|
||||
)
|
||||
|
||||
if error:
|
||||
@ -49,7 +70,12 @@ def create():
|
||||
|
||||
@post_bp.route('/<int:id>')
|
||||
def show(id):
|
||||
"""顯示文章詳情"""
|
||||
"""
|
||||
顯示文章詳情
|
||||
|
||||
Args:
|
||||
id: 文章ID
|
||||
"""
|
||||
post = PostService.get_post_by_id(id)
|
||||
if not post:
|
||||
flash('文章不存在', 'danger')
|
||||
@ -63,20 +89,28 @@ def show(id):
|
||||
@post_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(id):
|
||||
"""編輯文章"""
|
||||
"""
|
||||
編輯文章
|
||||
GET: 顯示編輯表單
|
||||
POST: 處理文章更新請求
|
||||
|
||||
Args:
|
||||
id: 文章ID
|
||||
"""
|
||||
post = PostService.get_post_by_id(id)
|
||||
if not post or post.user_id != current_user.id:
|
||||
flash('無權限編輯此文章', 'danger')
|
||||
return redirect(url_for('post.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
post_data = {
|
||||
'title': request.form.get('title', '').strip(),
|
||||
'content': request.form.get('content', '').strip()
|
||||
}
|
||||
|
||||
success, error = PostService.update_post(
|
||||
post_id=id,
|
||||
title=title,
|
||||
content=content
|
||||
**post_data
|
||||
)
|
||||
|
||||
if not success:
|
||||
@ -94,7 +128,12 @@ def edit(id):
|
||||
@post_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete(id):
|
||||
"""刪除文章"""
|
||||
"""
|
||||
刪除文章
|
||||
|
||||
Args:
|
||||
id: 文章ID
|
||||
"""
|
||||
post = PostService.get_post_by_id(id)
|
||||
if not post or post.user_id != current_user.id:
|
||||
flash('無權限刪除此文章', 'danger')
|
||||
@ -112,7 +151,15 @@ def delete(id):
|
||||
@post_bp.route('/<int:post_id>/like', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_like(post_id):
|
||||
"""切換按讚狀態"""
|
||||
"""
|
||||
切換文章按讚狀態
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
JSON響應,包含操作結果和最新按讚數
|
||||
"""
|
||||
success, is_liked, count = LikeService.toggle_like(
|
||||
user_id=current_user.id,
|
||||
post_id=post_id
|
||||
@ -124,27 +171,35 @@ def toggle_like(post_id):
|
||||
'liked': is_liked,
|
||||
'count': count
|
||||
})
|
||||
return jsonify({'success': False, 'message': '操作失敗'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '操作失敗'
|
||||
}), 500
|
||||
|
||||
|
||||
@post_bp.route('/<int:post_id>/comments', methods=['POST'])
|
||||
@login_required
|
||||
def create_comment(post_id):
|
||||
"""創建留言"""
|
||||
content = request.form.get('content', '').strip()
|
||||
parent_id = request.form.get('parent_id', type=int)
|
||||
"""
|
||||
創建文章評論
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
"""
|
||||
comment_data = {
|
||||
'content': request.form.get('content', '').strip(),
|
||||
'parent_id': request.form.get('parent_id', type=int)
|
||||
}
|
||||
|
||||
success, error = CommentService.create_comment(
|
||||
user_id=current_user.id,
|
||||
post_id=post_id,
|
||||
content=content,
|
||||
parent_id=parent_id
|
||||
**comment_data
|
||||
)
|
||||
|
||||
if success:
|
||||
flash('留言成功', 'success')
|
||||
else:
|
||||
flash(f'留言失敗: {error}', 'danger')
|
||||
flash('留言成功' if success else f'留言失敗: {error}',
|
||||
'success' if success else 'danger')
|
||||
|
||||
return redirect(url_for('post.show', id=post_id))
|
||||
|
||||
@ -152,15 +207,34 @@ def create_comment(post_id):
|
||||
@post_bp.route('/comments/<int:comment_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_comment(comment_id):
|
||||
"""刪除留言"""
|
||||
"""
|
||||
刪除評論
|
||||
|
||||
Args:
|
||||
comment_id: 評論ID
|
||||
|
||||
Returns:
|
||||
JSON響應,包含操作結果
|
||||
"""
|
||||
comment = CommentService.get_comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return jsonify({'success': False, 'message': '留言不存在'}), 404
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '留言不存在'
|
||||
}), 404
|
||||
|
||||
if comment.user_id != current_user.id:
|
||||
return jsonify({'success': False, 'message': '無權限刪除此留言'}), 403
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '無權限刪除此留言'
|
||||
}), 403
|
||||
|
||||
success, error = CommentService.delete_comment(comment_id)
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'message': error}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': error
|
||||
}), 500
|
||||
|
@ -1,148 +1,181 @@
|
||||
# 標準庫導入
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 第三方套件導入
|
||||
from flask import render_template, Blueprint, flash, redirect, url_for, request
|
||||
from flask import Blueprint, render_template, flash, redirect, url_for, request, current_app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
# 本地應用導入
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models import User
|
||||
|
||||
|
||||
# 系統配置常量
|
||||
class SystemConfig:
|
||||
"""系統配置常量"""
|
||||
VERSION = '1.0.0'
|
||||
LAST_UPDATE = '2024-10-27'
|
||||
ACTIVE_DAYS_THRESHOLD = 30
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
# 常數配置
|
||||
SYSTEM_VERSION = '1.0.0'
|
||||
LAST_UPDATE = '2024-10-27'
|
||||
ACTIVE_DAYS_THRESHOLD = 30
|
||||
|
||||
# 創建設定藍圖
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
|
||||
# 工具函數
|
||||
def get_user_counts():
|
||||
"""
|
||||
獲取用戶統計數據
|
||||
:return: tuple(int, int) 總用戶數和活躍用戶數
|
||||
"""
|
||||
try:
|
||||
# 計算總用戶數
|
||||
total_users = User.query.count()
|
||||
|
||||
# 計算活躍用戶數(最近30天內有登入的用戶)
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=ACTIVE_DAYS_THRESHOLD)
|
||||
active_users = User.query.filter(User.last_login >= thirty_days_ago).count()
|
||||
|
||||
return total_users, active_users
|
||||
except Exception as e:
|
||||
return 0, 0
|
||||
|
||||
|
||||
def get_system_stats():
|
||||
def get_system_statistics() -> dict:
|
||||
"""
|
||||
獲取系統統計數據
|
||||
:return: dict 系統統計資訊
|
||||
"""
|
||||
total_users, active_users = get_user_counts()
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'active_users': active_users,
|
||||
'system_version': SYSTEM_VERSION,
|
||||
'last_update': LAST_UPDATE
|
||||
}
|
||||
|
||||
|
||||
def handle_profile_update(form_data):
|
||||
"""
|
||||
處理個人資料更新
|
||||
:param form_data: 表單數據
|
||||
:return: tuple(bool, str) 操作是否成功及訊息
|
||||
Returns:
|
||||
dict: 包含系統統計資訊的字典
|
||||
"""
|
||||
try:
|
||||
current_user.username = form_data.get('username', current_user.username)
|
||||
current_user.email = form_data.get('email', current_user.email)
|
||||
db.session.commit()
|
||||
return True, '個人資料已更新'
|
||||
# 計算時間閾值
|
||||
active_threshold = datetime.utcnow() - timedelta(days=SystemConfig.ACTIVE_DAYS_THRESHOLD)
|
||||
|
||||
# 獲取用戶統計
|
||||
total_users = User.query.count()
|
||||
active_users = User.query.filter(User.last_login >= active_threshold).count()
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'active_users': active_users,
|
||||
'system_version': SystemConfig.VERSION,
|
||||
'last_update': SystemConfig.LAST_UPDATE,
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f'更新失敗: {str(e)}'
|
||||
current_app.logger.error(f"Error getting system statistics: {str(e)}")
|
||||
return {
|
||||
'total_users': 0,
|
||||
'active_users': 0,
|
||||
'system_version': SystemConfig.VERSION,
|
||||
'last_update': SystemConfig.LAST_UPDATE,
|
||||
}
|
||||
|
||||
|
||||
def handle_password_update(form_data):
|
||||
"""
|
||||
處理密碼更新
|
||||
:param form_data: 表單數據
|
||||
:return: tuple(bool, str) 操作是否成功及訊息
|
||||
"""
|
||||
current_password = form_data.get('current_password')
|
||||
new_password = form_data.get('new_password')
|
||||
confirm_password = form_data.get('confirm_password')
|
||||
class ProfileManager:
|
||||
"""個人資料管理類"""
|
||||
|
||||
# 驗證密碼
|
||||
if not current_user.check_password(current_password):
|
||||
return False, '目前密碼不正確'
|
||||
@staticmethod
|
||||
def update_profile(form_data: dict) -> tuple:
|
||||
"""
|
||||
更新個人資料
|
||||
|
||||
if new_password != confirm_password:
|
||||
return False, '新密碼與確認密碼不符'
|
||||
Args:
|
||||
form_data: 表單數據
|
||||
|
||||
try:
|
||||
current_user.set_password(new_password)
|
||||
db.session.commit()
|
||||
return True, '密碼已更新'
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f'更新失敗: {str(e)}'
|
||||
Returns:
|
||||
tuple: (是否成功, 提示訊息)
|
||||
"""
|
||||
try:
|
||||
current_user.username = form_data.get('username', current_user.username)
|
||||
current_user.email = form_data.get('email', current_user.email)
|
||||
db.session.commit()
|
||||
return True, '個人資料已更新'
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f'更新失敗: {str(e)}'
|
||||
|
||||
@staticmethod
|
||||
def update_password(form_data: dict) -> tuple:
|
||||
"""
|
||||
更新密碼
|
||||
|
||||
Args:
|
||||
form_data: 表單數據
|
||||
|
||||
Returns:
|
||||
tuple: (是否成功, 提示訊息)
|
||||
"""
|
||||
current_password = form_data.get('current_password')
|
||||
new_password = form_data.get('new_password')
|
||||
confirm_password = form_data.get('confirm_password')
|
||||
|
||||
# 驗證密碼
|
||||
if not current_user.check_password(current_password):
|
||||
return False, '目前密碼不正確'
|
||||
|
||||
if new_password != confirm_password:
|
||||
return False, '新密碼與確認密碼不符'
|
||||
|
||||
try:
|
||||
current_user.set_password(new_password)
|
||||
db.session.commit()
|
||||
return True, '密碼已更新'
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f'更新失敗: {str(e)}'
|
||||
|
||||
|
||||
def handle_settings_action(action, form_data):
|
||||
"""
|
||||
處理設定頁面的不同操作
|
||||
:param action: 操作類型
|
||||
:param form_data: 表單數據
|
||||
:return: bool 操作是否成功
|
||||
"""
|
||||
handlers = {
|
||||
'update_profile': handle_profile_update,
|
||||
'update_password': handle_password_update
|
||||
}
|
||||
|
||||
handler = handlers.get(action)
|
||||
if not handler:
|
||||
flash('無效的操作', 'danger')
|
||||
return False
|
||||
|
||||
success, message = handler(form_data)
|
||||
flash(message, 'success' if success else 'danger')
|
||||
return success
|
||||
|
||||
|
||||
# 視圖函數
|
||||
@settings_bp.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def index():
|
||||
"""設定頁面視圖函數"""
|
||||
"""
|
||||
設定頁面視圖
|
||||
GET: 顯示設定頁面
|
||||
POST: 處理設定更新請求
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
handle_settings_action(action, request.form)
|
||||
|
||||
# 根據操作類型處理不同的設定更新
|
||||
if action == 'update_profile':
|
||||
success, message = ProfileManager.update_profile(request.form)
|
||||
elif action == 'update_password':
|
||||
success, message = ProfileManager.update_password(request.form)
|
||||
else:
|
||||
success, message = False, '無效的操作'
|
||||
|
||||
# 設置提示訊息
|
||||
flash(message, 'success' if success else 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
# 準備頁面數據
|
||||
template_data = {
|
||||
'title': '設定',
|
||||
'stats': get_system_stats(),
|
||||
'stats': get_system_statistics(),
|
||||
}
|
||||
|
||||
return render_template('pages/settings.html', **template_data)
|
||||
|
||||
|
||||
# 錯誤處理
|
||||
@settings_bp.errorhandler(Exception)
|
||||
def handle_error(error):
|
||||
"""
|
||||
統一錯誤處理
|
||||
:param error: 錯誤實例
|
||||
:return: redirect
|
||||
|
||||
Args:
|
||||
error: 錯誤實例
|
||||
|
||||
Returns:
|
||||
重定向到設定頁面
|
||||
"""
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Settings error: {str(error)}")
|
||||
flash(f'發生錯誤: {str(error)}', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
|
||||
# 系統維護相關路由
|
||||
@settings_bp.route('/maintenance', methods=['POST'])
|
||||
@login_required
|
||||
def maintenance():
|
||||
"""系統維護操作(需要管理員權限)"""
|
||||
if not current_user.is_admin:
|
||||
flash('無權限執行此操作', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
action = request.form.get('action')
|
||||
|
||||
try:
|
||||
if action == 'clear_inactive_users':
|
||||
# 清理不活躍用戶
|
||||
threshold = datetime.utcnow() - timedelta(days=180) # 半年未登入
|
||||
inactive_users = User.query.filter(User.last_login < threshold).all()
|
||||
for user in inactive_users:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f'已清理 {len(inactive_users)} 個不活躍用戶', 'success')
|
||||
else:
|
||||
flash('無效的維護操作', 'danger')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Maintenance error: {str(e)}")
|
||||
flash(f'維護操作失敗: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('settings.index'))
|
||||
|
@ -1,12 +1,58 @@
|
||||
from typing import Tuple, Optional
|
||||
from app import db
|
||||
|
||||
|
||||
class BaseService:
|
||||
"""
|
||||
基礎服務類
|
||||
提供共用的資料庫操作方法
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def commit():
|
||||
def commit() -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
提交資料庫變更
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
db.session.commit()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def save_to_db(model: db.Model) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
保存模型實例到資料庫
|
||||
|
||||
Args:
|
||||
model: 資料庫模型實例
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
db.session.add(model)
|
||||
return BaseService.commit()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def delete_from_db(model: db.Model) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
從資料庫刪除模型實例
|
||||
|
||||
Args:
|
||||
model: 資料庫模型實例
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
db.session.delete(model)
|
||||
return BaseService.commit()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
@ -1,55 +1,241 @@
|
||||
from typing import Tuple, Optional, List, Dict
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import Comment
|
||||
from .base_service import BaseService
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
|
||||
class CommentService(BaseService):
|
||||
"""留言服務類"""
|
||||
|
||||
# 配置常量
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_REPLY_DEPTH = 3 # 最大回覆深度
|
||||
|
||||
@staticmethod
|
||||
def create_comment(user_id: int, post_id: int, content: str, parent_id: Optional[int] = None) -> Tuple[bool, Optional[str]]:
|
||||
def create_comment(user_id: int, post_id: int, content: str,
|
||||
parent_id: Optional[int] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
創建新留言
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
post_id: 文章ID
|
||||
content: 留言內容
|
||||
parent_id: 父留言ID(用於回覆)
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
# 驗證輸入
|
||||
if not content.strip():
|
||||
return False, "留言內容不能為空"
|
||||
|
||||
# 檢查回覆深度
|
||||
if parent_id:
|
||||
depth = CommentService.get_comment_depth(parent_id)
|
||||
if depth >= CommentService.MAX_REPLY_DEPTH:
|
||||
return False, f"最多只能回覆 {CommentService.MAX_REPLY_DEPTH} 層"
|
||||
|
||||
# 創建留言
|
||||
comment = Comment(
|
||||
user_id=user_id,
|
||||
post_id=post_id,
|
||||
content=content,
|
||||
parent_id=parent_id
|
||||
)
|
||||
db.session.add(comment)
|
||||
return CommentService.commit()
|
||||
|
||||
return CommentService.save_to_db(comment)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error creating comment: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_comment_by_id(comment_id: int) -> Optional[Comment]:
|
||||
"""
|
||||
通過ID獲取留言
|
||||
|
||||
Args:
|
||||
comment_id: 留言ID
|
||||
|
||||
Returns:
|
||||
Optional[Comment]: 留言實例或None
|
||||
"""
|
||||
return Comment.query.get(comment_id)
|
||||
try:
|
||||
return Comment.query.get(comment_id)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting comment: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def delete_comment(comment_id: int) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
刪除留言
|
||||
|
||||
Args:
|
||||
comment_id: 留言ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
comment = Comment.query.get(comment_id)
|
||||
if not comment:
|
||||
return False, "留言不存在"
|
||||
|
||||
db.session.delete(comment)
|
||||
return CommentService.commit()
|
||||
return CommentService.delete_from_db(comment)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error deleting comment: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@classmethod
|
||||
def get_post_comments(cls, post_id: int, page: int = 1,
|
||||
per_page: int = None) -> Tuple[List[Comment], int]:
|
||||
"""
|
||||
獲取文章的留言列表
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
Tuple[List[Comment], int]: (留言列表, 總頁數)
|
||||
"""
|
||||
try:
|
||||
per_page = per_page or cls.DEFAULT_PAGE_SIZE
|
||||
pagination = Comment.query.filter_by(
|
||||
post_id=post_id,
|
||||
parent_id=None # 只獲取頂層留言
|
||||
).order_by(
|
||||
Comment.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
return pagination.items, pagination.pages
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting post comments: {str(e)}")
|
||||
return [], 0
|
||||
|
||||
@staticmethod
|
||||
def get_comment_depth(comment_id: int) -> int:
|
||||
"""
|
||||
獲取留言的深度
|
||||
|
||||
Args:
|
||||
comment_id: 留言ID
|
||||
|
||||
Returns:
|
||||
int: 留言深度(0表示頂層留言)
|
||||
"""
|
||||
try:
|
||||
depth = 0
|
||||
comment = Comment.query.get(comment_id)
|
||||
|
||||
while comment and comment.parent_id:
|
||||
depth += 1
|
||||
comment = comment.parent
|
||||
|
||||
return depth
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting comment depth: {str(e)}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_user_comments(user_id: int, page: int = 1,
|
||||
per_page: int = 20) -> Tuple[List[Comment], int]:
|
||||
"""
|
||||
獲取用戶的所有留言
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
Tuple[List[Comment], int]: (留言列表, 總頁數)
|
||||
"""
|
||||
try:
|
||||
pagination = Comment.query.filter_by(
|
||||
user_id=user_id
|
||||
).order_by(
|
||||
Comment.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
return pagination.items, pagination.pages
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting user comments: {str(e)}")
|
||||
return [], 0
|
||||
|
||||
@staticmethod
|
||||
def update_comment(comment_id: int, content: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新留言內容
|
||||
|
||||
Args:
|
||||
comment_id: 留言ID
|
||||
content: 新的留言內容
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return False, "留言內容不能為空"
|
||||
|
||||
comment = Comment.query.get(comment_id)
|
||||
if not comment:
|
||||
return False, "留言不存在"
|
||||
|
||||
comment.content = content
|
||||
comment.updated_at = datetime.utcnow()
|
||||
|
||||
return CommentService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating comment: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_post_comments(post_id: int, page: int = 1, per_page: int = 20) -> Tuple[List[Comment], int]:
|
||||
def get_comment_statistics(post_id: int) -> Dict:
|
||||
"""
|
||||
獲取文章的留言列表
|
||||
獲取文章留言統計資訊
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
Dict: 統計資訊字典
|
||||
"""
|
||||
pagination = Comment.query.filter_by(post_id=post_id)\
|
||||
.order_by(Comment.created_at.desc())\
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
return pagination.items, pagination.pages
|
||||
try:
|
||||
total_comments = Comment.query.filter_by(post_id=post_id).count()
|
||||
total_replies = Comment.query.filter(
|
||||
Comment.post_id == post_id,
|
||||
Comment.parent_id.isnot(None)
|
||||
).count()
|
||||
|
||||
return {
|
||||
'total_comments': total_comments,
|
||||
'total_replies': total_replies,
|
||||
'root_comments': total_comments - total_replies
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting comment statistics: {str(e)}")
|
||||
return {
|
||||
'total_comments': 0,
|
||||
'total_replies': 0,
|
||||
'root_comments': 0
|
||||
}
|
||||
|
@ -1,94 +1,162 @@
|
||||
from typing import Tuple, List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from flask import current_app
|
||||
from sqlalchemy import func
|
||||
from app import db
|
||||
from app.models import Like, Post
|
||||
from .base_service import BaseService
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class LikeService(BaseService):
|
||||
"""按讚服務類"""
|
||||
|
||||
@staticmethod
|
||||
def toggle_like(user_id: int, post_id: int) -> Tuple[bool, bool, int]:
|
||||
"""
|
||||
切換文章的按讚狀態
|
||||
:param user_id: 用戶ID
|
||||
:param post_id: 文章ID
|
||||
:return: Tuple[是否成功, 當前是否為按讚狀態, 當前按讚數]
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bool, int]: (是否成功, 當前是否為按讚狀態, 當前按讚數)
|
||||
"""
|
||||
try:
|
||||
# 檢查文章是否存在
|
||||
post = Post.query.get(post_id)
|
||||
if not post:
|
||||
return False, False, 0
|
||||
|
||||
# 檢查是否已經按讚
|
||||
existing_like = Like.query.filter_by(
|
||||
post_id=post_id,
|
||||
user_id=user_id
|
||||
).first()
|
||||
|
||||
post = Post.query.get(post_id)
|
||||
if not post:
|
||||
return False, False, 0
|
||||
|
||||
if existing_like:
|
||||
# 如果已按讚,則取消
|
||||
# 取消按讚
|
||||
db.session.delete(existing_like)
|
||||
db.session.commit()
|
||||
return True, False, post.like_count
|
||||
else:
|
||||
# 如果未按讚,則添加
|
||||
# 新增按讚
|
||||
new_like = Like(post_id=post_id, user_id=user_id)
|
||||
db.session.add(new_like)
|
||||
db.session.commit()
|
||||
return True, True, post.like_count
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error toggling like: {str(e)}")
|
||||
return False, False, 0
|
||||
|
||||
@staticmethod
|
||||
def is_post_liked_by_user(post_id: int, user_id: int) -> bool:
|
||||
"""
|
||||
檢查用戶是否已對文章按讚
|
||||
:param post_id: 文章ID
|
||||
:param user_id: 用戶ID
|
||||
:return: 是否已按讚
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
bool: 是否已按讚
|
||||
"""
|
||||
return Like.query.filter_by(
|
||||
post_id=post_id,
|
||||
user_id=user_id
|
||||
).first() is not None
|
||||
try:
|
||||
return Like.query.filter_by(
|
||||
post_id=post_id,
|
||||
user_id=user_id
|
||||
).first() is not None
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error checking like status: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_post_likes_count(post_id: int) -> int:
|
||||
"""
|
||||
獲取文章的按讚數量
|
||||
:param post_id: 文章ID
|
||||
:return: 按讚數量
|
||||
"""
|
||||
return Like.query.filter_by(post_id=post_id).count()
|
||||
|
||||
@staticmethod
|
||||
def get_user_liked_posts(user_id: int, page: int = 1, per_page: int = 10) -> Tuple[list, int]:
|
||||
def get_user_liked_posts(user_id: int, page: int = 1,
|
||||
per_page: int = 10) -> Tuple[List[Post], int]:
|
||||
"""
|
||||
獲取用戶按讚的文章列表
|
||||
:param user_id: 用戶ID
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: Tuple[文章列表, 總頁數]
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
Tuple[List[Post], int]: (文章列表, 總頁數)
|
||||
"""
|
||||
pagination = Post.query.join(Like).filter(
|
||||
Like.user_id == user_id
|
||||
).order_by(Like.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
return pagination.items, pagination.pages
|
||||
try:
|
||||
pagination = Post.query.join(
|
||||
Like
|
||||
).filter(
|
||||
Like.user_id == user_id
|
||||
).order_by(
|
||||
Like.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
return pagination.items, pagination.pages
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting liked posts: {str(e)}")
|
||||
return [], 0
|
||||
|
||||
@staticmethod
|
||||
def get_post_likers(post_id: int, page: int = 1, per_page: int = 20) -> list:
|
||||
def get_post_likes_statistics(post_id: int) -> Dict:
|
||||
"""
|
||||
獲取文章的按讚用戶列表
|
||||
:param post_id: 文章ID
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: 用戶列表
|
||||
獲取文章按讚統計資訊
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
Dict: 統計資訊字典
|
||||
"""
|
||||
from app.models import User
|
||||
return User.query.join(Like).filter(
|
||||
Like.post_id == post_id
|
||||
).order_by(Like.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
try:
|
||||
total_likes = Like.query.filter_by(post_id=post_id).count()
|
||||
recent_likes = Like.query.filter(
|
||||
Like.post_id == post_id,
|
||||
Like.created_at >= datetime.utcnow() - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
return {
|
||||
'total_likes': total_likes,
|
||||
'recent_likes': recent_likes,
|
||||
'trend': recent_likes / total_likes if total_likes > 0 else 0
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting like statistics: {str(e)}")
|
||||
return {
|
||||
'total_likes': 0,
|
||||
'recent_likes': 0,
|
||||
'trend': 0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_trending_posts(days: int = 7, limit: int = 10) -> List[Post]:
|
||||
"""
|
||||
獲取趨勢文章(根據最近按讚數)
|
||||
|
||||
Args:
|
||||
days: 統計天數
|
||||
limit: 返回數量
|
||||
|
||||
Returns:
|
||||
List[Post]: 趨勢文章列表
|
||||
"""
|
||||
try:
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
return Post.query.join(
|
||||
Like
|
||||
).filter(
|
||||
Like.created_at >= since
|
||||
).group_by(
|
||||
Post.id
|
||||
).order_by(
|
||||
func.count(Like.id).desc()
|
||||
).limit(limit).all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting trending posts: {str(e)}")
|
||||
return []
|
||||
|
@ -1,72 +1,110 @@
|
||||
from typing import Tuple, Optional, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import or_, func
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import Post
|
||||
from .base_service import BaseService
|
||||
from typing import Tuple, Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
|
||||
|
||||
class PostService(BaseService):
|
||||
"""文章服務類"""
|
||||
|
||||
# 配置常量
|
||||
DEFAULT_PAGE_SIZE = 10
|
||||
EXCERPT_LENGTH = 200
|
||||
|
||||
@staticmethod
|
||||
def create_post(user_id: int, title: str, content: str) -> Tuple[Optional[Post], Optional[str]]:
|
||||
"""
|
||||
創建新文章
|
||||
:param user_id: 用戶ID
|
||||
:param title: 文章標題
|
||||
:param content: 文章內容
|
||||
:return: (Post object, error message)
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
title: 文章標題
|
||||
content: 文章內容
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Post], Optional[str]]: (文章實例, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
# 驗證輸入
|
||||
if not title.strip():
|
||||
return None, "標題不能為空"
|
||||
if not content.strip():
|
||||
return None, "內容不能為空"
|
||||
|
||||
# 創建文章
|
||||
post = Post(
|
||||
title=title,
|
||||
content=content,
|
||||
user_id=user_id
|
||||
)
|
||||
db.session.add(post)
|
||||
success, error = PostService.commit()
|
||||
|
||||
if success:
|
||||
return post, None
|
||||
return None, error
|
||||
success, error = PostService.save_to_db(post)
|
||||
return (post, None) if success else (None, error)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error creating post: {str(e)}")
|
||||
return None, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_post_by_id(post_id: int) -> Optional[Post]:
|
||||
"""
|
||||
通過ID獲取文章
|
||||
:param post_id: 文章ID
|
||||
:return: Post object or None
|
||||
"""
|
||||
return Post.query.get(post_id)
|
||||
|
||||
@staticmethod
|
||||
def get_posts_page(page: int = 1, per_page: int = 10) -> Any:
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
Optional[Post]: 文章實例或None
|
||||
"""
|
||||
try:
|
||||
return Post.query.get(post_id)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting post: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_posts_page(cls, page: int = 1, per_page: int = None) -> Any:
|
||||
"""
|
||||
獲取分頁的文章列表
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: Pagination object
|
||||
|
||||
Args:
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
分頁對象
|
||||
"""
|
||||
return Post.query.order_by(Post.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
try:
|
||||
per_page = per_page or cls.DEFAULT_PAGE_SIZE
|
||||
return Post.query.order_by(
|
||||
Post.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting posts page: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def update_post(post_id: int, title: str, content: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新文章
|
||||
:param post_id: 文章ID
|
||||
:param title: 新標題
|
||||
:param content: 新內容
|
||||
:return: (success boolean, error message)
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
title: 新標題
|
||||
content: 新內容
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
# 驗證輸入
|
||||
if not title.strip():
|
||||
return False, "標題不能為空"
|
||||
if not content.strip():
|
||||
@ -76,57 +114,142 @@ class PostService(BaseService):
|
||||
if not post:
|
||||
return False, "文章不存在"
|
||||
|
||||
# 更新文章
|
||||
post.title = title
|
||||
post.content = content
|
||||
post.updated_at = datetime.utcnow()
|
||||
|
||||
return PostService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating post: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def delete_post(post_id: int) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
刪除文章
|
||||
:param post_id: 文章ID
|
||||
:return: (success boolean, error message)
|
||||
|
||||
Args:
|
||||
post_id: 文章ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
post = Post.query.get(post_id)
|
||||
if not post:
|
||||
return False, "文章不存在"
|
||||
|
||||
db.session.delete(post)
|
||||
return PostService.commit()
|
||||
return PostService.delete_from_db(post)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error deleting post: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_user_posts(user_id: int, page: int = 1, per_page: int = 10) -> Any:
|
||||
@classmethod
|
||||
def get_user_posts(cls, user_id: int, page: int = 1, per_page: int = None) -> Any:
|
||||
"""
|
||||
獲取指定用戶的文章列表
|
||||
:param user_id: 用戶ID
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: Pagination object
|
||||
"""
|
||||
return Post.query.filter_by(user_id=user_id) \
|
||||
.order_by(Post.created_at.desc()) \
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
@staticmethod
|
||||
def search_posts(query: str, page: int = 1, per_page: int = 10) -> Any:
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
分頁對象
|
||||
"""
|
||||
try:
|
||||
per_page = per_page or cls.DEFAULT_PAGE_SIZE
|
||||
return Post.query.filter_by(
|
||||
user_id=user_id
|
||||
).order_by(
|
||||
Post.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting user posts: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def search_posts(cls, query: str, page: int = 1, per_page: int = None) -> Any:
|
||||
"""
|
||||
搜索文章
|
||||
:param query: 搜索關鍵字
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: Pagination object
|
||||
|
||||
Args:
|
||||
query: 搜索關鍵字
|
||||
page: 頁碼
|
||||
per_page: 每頁數量
|
||||
|
||||
Returns:
|
||||
分頁對象
|
||||
"""
|
||||
return Post.query.filter(
|
||||
or_(
|
||||
Post.title.ilike(f'%{query}%'),
|
||||
Post.content.ilike(f'%{query}%')
|
||||
try:
|
||||
per_page = per_page or cls.DEFAULT_PAGE_SIZE
|
||||
return Post.query.filter(
|
||||
or_(
|
||||
Post.title.ilike(f'%{query}%'),
|
||||
Post.content.ilike(f'%{query}%')
|
||||
)
|
||||
).order_by(
|
||||
Post.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
).order_by(Post.created_at.desc()) \
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching posts: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_latest_posts(limit: int = 5) -> List[Post]:
|
||||
"""
|
||||
獲取最新文章
|
||||
|
||||
Args:
|
||||
limit: 限制數量
|
||||
|
||||
Returns:
|
||||
List[Post]: 文章列表
|
||||
"""
|
||||
try:
|
||||
return Post.query.order_by(
|
||||
Post.created_at.desc()
|
||||
).limit(limit).all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting latest posts: {str(e)}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_trending_posts(days: int = 7, limit: int = 10) -> List[Post]:
|
||||
"""
|
||||
獲取趨勢文章(根據最近按讚數)
|
||||
|
||||
Args:
|
||||
days: 統計天數
|
||||
limit: 返回數量
|
||||
|
||||
Returns:
|
||||
List[Post]: 趨勢文章列表
|
||||
"""
|
||||
try:
|
||||
from app.models import Like # 僅在需要時導入 Like
|
||||
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
return Post.query.join(
|
||||
Like
|
||||
).filter(
|
||||
Post.created_at >= since
|
||||
).group_by(
|
||||
Post.id
|
||||
).order_by(
|
||||
func.count(Like.id).desc()
|
||||
).limit(limit).all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting trending posts: {str(e)}")
|
||||
return []
|
||||
|
@ -1,125 +1,174 @@
|
||||
import os
|
||||
from typing import Tuple, Optional, Dict, List
|
||||
from datetime import datetime, timedelta
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image
|
||||
from datetime import datetime, timedelta
|
||||
from app import db
|
||||
from flask import current_app
|
||||
from app.models import User
|
||||
from .base_service import BaseService
|
||||
from typing import Tuple, Optional, Any
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
"""用戶服務類"""
|
||||
|
||||
# 配置常量
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
AVATAR_SIZE = (300, 300)
|
||||
AVATAR_SIZE = (300, 300) # 頭像尺寸
|
||||
AVATAR_QUALITY = 85 # 圖片品質
|
||||
|
||||
@staticmethod
|
||||
def create_user(username: str, email: str, password: str) -> Tuple[Optional[User], Optional[str]]:
|
||||
"""
|
||||
創建新用戶
|
||||
:param username: 用戶名
|
||||
:param email: 電子郵件
|
||||
:param password: 密碼
|
||||
:return: (User object, error message)
|
||||
|
||||
Args:
|
||||
username: 用戶名
|
||||
email: 電子郵件
|
||||
password: 密碼
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[User], Optional[str]]: (用戶實例, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
# 檢查用戶名是否已存在
|
||||
# 檢查用戶名和郵箱是否已存在
|
||||
if User.query.filter_by(username=username).first():
|
||||
return None, "用戶名已被使用"
|
||||
|
||||
# 檢查郵箱是否已存在
|
||||
if User.query.filter_by(email=email).first():
|
||||
return None, "郵箱已被註冊"
|
||||
|
||||
# 創建新用戶
|
||||
user = User(
|
||||
username=username,
|
||||
email=email
|
||||
)
|
||||
user = User(username=username, email=email)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
success, error = UserService.commit()
|
||||
success, error = UserService.save_to_db(user)
|
||||
return (user, None) if success else (None, error)
|
||||
|
||||
if success:
|
||||
return user, None
|
||||
return None, error
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error creating user: {str(e)}")
|
||||
return None, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(user_id: int) -> Optional[User]:
|
||||
"""
|
||||
通過ID獲取用戶
|
||||
:param user_id: 用戶ID
|
||||
:return: User object or None
|
||||
"""
|
||||
"""根據ID獲取用戶"""
|
||||
return User.query.get(user_id)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_email(email: str) -> Optional[User]:
|
||||
"""
|
||||
通過郵箱獲取用戶
|
||||
:param email: 郵箱
|
||||
:return: User object or None
|
||||
"""
|
||||
"""根據郵箱獲取用戶"""
|
||||
return User.query.filter_by(email=email).first()
|
||||
|
||||
@staticmethod
|
||||
def update_profile(user_id: int, username: str = None, email: str = None) -> Tuple[bool, Optional[str]]:
|
||||
def update_profile(user_id: int, username: Optional[str] = None,
|
||||
email: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新用戶資料
|
||||
:param user_id: 用戶ID
|
||||
:param username: 新用戶名
|
||||
:param email: 新郵箱
|
||||
:return: (success, error message)
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
username: 新用戶名
|
||||
email: 新郵箱
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
user = UserService.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "用戶不存在"
|
||||
|
||||
if username:
|
||||
# 檢查新用戶名是否已被使用
|
||||
existing_user = User.query.filter_by(username=username).first()
|
||||
if existing_user and existing_user.id != user_id:
|
||||
return False, "用戶名已被使用"
|
||||
user.username = username
|
||||
|
||||
if email:
|
||||
# 檢查新郵箱是否已被註冊
|
||||
existing_user = User.query.filter_by(email=email).first()
|
||||
if existing_user and existing_user.id != user_id:
|
||||
return False, "郵箱已被註冊"
|
||||
user.email = email
|
||||
|
||||
return UserService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating profile: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def allowed_file(filename: str) -> bool:
|
||||
def update_password(user_id: int, current_password: str, new_password: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
檢查文件是否允許上傳
|
||||
:param filename: 文件名
|
||||
:return: bool
|
||||
更新用戶密碼
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
current_password: 當前密碼
|
||||
new_password: 新密碼
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in UserService.ALLOWED_EXTENSIONS
|
||||
try:
|
||||
# 獲取用戶
|
||||
user = UserService.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "用戶不存在"
|
||||
|
||||
# 驗證當前密碼
|
||||
if not user.check_password(current_password):
|
||||
return False, "當前密碼不正確"
|
||||
|
||||
# 驗證新密碼
|
||||
if len(new_password) < 6:
|
||||
return False, "新密碼長度不能小於6位"
|
||||
|
||||
# 更新密碼
|
||||
user.set_password(new_password)
|
||||
return UserService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating password: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def process_avatar(file, user_id: int, app) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
def update_last_login(user_id: int) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新用戶最後登入時間
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
user = UserService.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "用戶不存在"
|
||||
|
||||
user.last_login = datetime.utcnow()
|
||||
return UserService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating last login: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@classmethod
|
||||
def process_avatar(cls, file, user_id: int) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
處理頭像上傳
|
||||
:param file: 上傳的文件
|
||||
:param user_id: 用戶ID
|
||||
:param app: Flask app對象
|
||||
:return: (success, file_path, error_message)
|
||||
|
||||
Args:
|
||||
file: 上傳的文件
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str], Optional[str]]: (是否成功, 文件路徑, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
if not file:
|
||||
return False, None, "未選擇文件"
|
||||
|
||||
if not UserService.allowed_file(file.filename):
|
||||
if not cls.allowed_file(file.filename):
|
||||
return False, None, "不支持的文件格式"
|
||||
|
||||
# 生成安全的文件名
|
||||
@ -127,15 +176,13 @@ class UserService(BaseService):
|
||||
filename = secure_filename(f"avatar_{user_id}_{timestamp}.jpg")
|
||||
|
||||
# 確保上傳目錄存在
|
||||
upload_dir = os.path.join(app.static_folder, 'uploads', 'avatars')
|
||||
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'avatars')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# 處理圖片
|
||||
image = Image.open(file)
|
||||
|
||||
# 將圖片轉換為RGB模式(處理PNG等格式)
|
||||
if image.mode in ('RGBA', 'P'):
|
||||
image = image.convert('RGB')
|
||||
|
||||
@ -144,123 +191,162 @@ class UserService(BaseService):
|
||||
size = min(width, height)
|
||||
left = (width - size) // 2
|
||||
top = (height - size) // 2
|
||||
right = left + size
|
||||
bottom = top + size
|
||||
image = image.crop((left, top, right, bottom))
|
||||
image = image.crop((left, top, left + size, top + size))
|
||||
|
||||
# 調整大小
|
||||
image = image.resize(UserService.AVATAR_SIZE, Image.Resampling.LANCZOS)
|
||||
image = image.resize(cls.AVATAR_SIZE, Image.Resampling.LANCZOS)
|
||||
|
||||
# 保存圖片
|
||||
image.save(filepath, 'JPEG', quality=85)
|
||||
image.save(filepath, 'JPEG', quality=cls.AVATAR_QUALITY)
|
||||
|
||||
# 返回相對路徑
|
||||
return True, f"uploads/avatars/{filename}", None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error processing avatar: {str(e)}")
|
||||
return False, None, str(e)
|
||||
|
||||
@classmethod
|
||||
def allowed_file(cls, filename: str) -> bool:
|
||||
"""檢查文件是否為允許的格式"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in cls.ALLOWED_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def update_avatar(user_id: int, file, app) -> Tuple[bool, Optional[str]]:
|
||||
def update_avatar(user_id: int, file) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新用戶頭像
|
||||
:param user_id: 用戶ID
|
||||
:param file: 上傳的文件
|
||||
:param app: Flask app對象
|
||||
:return: (success, error_message)
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
file: 上傳的文件
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否成功, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
# 處理新頭像
|
||||
success, file_path, error = UserService.process_avatar(file, user_id, app)
|
||||
if not success:
|
||||
return False, error
|
||||
|
||||
# 更新數據庫
|
||||
user = User.query.get(user_id)
|
||||
user = UserService.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "用戶不存在"
|
||||
|
||||
# 處理新頭像
|
||||
success, file_path, error = UserService.process_avatar(file, user_id)
|
||||
if not success:
|
||||
return False, error
|
||||
|
||||
# 刪除舊頭像
|
||||
if user.avatar_path:
|
||||
old_avatar = os.path.join(app.static_folder, user.avatar_path)
|
||||
old_avatar = os.path.join(current_app.static_folder, user.avatar_path)
|
||||
if os.path.exists(old_avatar):
|
||||
os.remove(old_avatar)
|
||||
|
||||
# 更新頭像路徑
|
||||
user.avatar_path = file_path
|
||||
db.session.commit()
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def update_password(user_id: int, current_password: str, new_password: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
更新密碼
|
||||
:param user_id: 用戶ID
|
||||
:param current_password: 當前密碼
|
||||
:param new_password: 新密碼
|
||||
:return: (success, error_message)
|
||||
"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return False, "用戶不存在"
|
||||
|
||||
if not user.check_password(current_password):
|
||||
return False, "當前密碼不正確"
|
||||
|
||||
if len(new_password) < 6:
|
||||
return False, "新密碼長度不能小於6位"
|
||||
|
||||
user.set_password(new_password)
|
||||
return UserService.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating avatar: {str(e)}")
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def update_last_login(user_id: int) -> None:
|
||||
@classmethod
|
||||
def process_avatar(cls, file, user_id: int) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
更新最後登入時間
|
||||
:param user_id: 用戶ID
|
||||
處理頭像上傳
|
||||
|
||||
Args:
|
||||
file: 上傳的文件
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str], Optional[str]]: (是否成功, 文件路徑, 錯誤訊息)
|
||||
"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if user:
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
if not file:
|
||||
return False, None, "未選擇文件"
|
||||
|
||||
if not cls.allowed_file(file.filename):
|
||||
return False, None, "不支持的文件格式"
|
||||
|
||||
# 生成安全的文件名
|
||||
timestamp = int(datetime.utcnow().timestamp())
|
||||
filename = secure_filename(f"avatar_{user_id}_{timestamp}.jpg")
|
||||
|
||||
# 確保上傳目錄存在
|
||||
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'avatars')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# 處理圖片
|
||||
image = Image.open(file)
|
||||
if image.mode in ('RGBA', 'P'):
|
||||
image = image.convert('RGB')
|
||||
|
||||
# 裁剪為正方形
|
||||
width, height = image.size
|
||||
size = min(width, height)
|
||||
left = (width - size) // 2
|
||||
top = (height - size) // 2
|
||||
image = image.crop((left, top, left + size, top + size))
|
||||
|
||||
# 調整大小
|
||||
image = image.resize(cls.AVATAR_SIZE, Image.Resampling.LANCZOS)
|
||||
|
||||
# 保存圖片
|
||||
image.save(filepath, 'JPEG', quality=cls.AVATAR_QUALITY)
|
||||
|
||||
return True, f"uploads/avatars/{filename}", None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error processing avatar: {str(e)}")
|
||||
return False, None, str(e)
|
||||
|
||||
@staticmethod
|
||||
def get_users_page(page: int = 1, per_page: int = 16) -> Any:
|
||||
def get_user_stats(user_id: int) -> Dict:
|
||||
"""
|
||||
獲取分頁的用戶列表
|
||||
:param page: 頁碼
|
||||
:param per_page: 每頁數量
|
||||
:return: Pagination object
|
||||
獲取指定用戶的統計資訊
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
Dict: 用戶統計資訊
|
||||
"""
|
||||
return User.query.order_by(User.created_at.desc()) \
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
try:
|
||||
user = UserService.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return {'posts_count': 0, 'received_likes': 0}
|
||||
|
||||
return {
|
||||
'posts_count': user.posts_count,
|
||||
'received_likes': user.received_likes_count
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting user stats: {str(e)}")
|
||||
return {'posts_count': 0, 'received_likes': 0}
|
||||
|
||||
@staticmethod
|
||||
def get_active_users_count(days: int = 30) -> int:
|
||||
def get_user_statistics() -> Dict:
|
||||
"""
|
||||
獲取活躍用戶數量
|
||||
:param days: 天數
|
||||
:return: 活躍用戶數量
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
return User.query.filter(User.last_login >= since).count()
|
||||
獲取用戶統計資料
|
||||
|
||||
@staticmethod
|
||||
def get_new_users_count(days: int = 30) -> int:
|
||||
Returns:
|
||||
Dict: 統計數據字典
|
||||
"""
|
||||
獲取新增用戶數量
|
||||
:param days: 天數
|
||||
:return: 新增用戶數量
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
return User.query.filter(User.created_at >= since).count()
|
||||
try:
|
||||
# 計算本月新增用戶
|
||||
first_day_of_month = datetime.utcnow().replace(
|
||||
day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
return {
|
||||
'total': User.query.count(),
|
||||
'new_this_month': User.query.filter(
|
||||
User.created_at >= first_day_of_month
|
||||
).count(),
|
||||
'active': User.query.filter(
|
||||
User.last_login >= datetime.utcnow() - timedelta(days=30)
|
||||
).count()
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting user statistics: {str(e)}")
|
||||
return {'total': 0, 'new_this_month': 0, 'active': 0}
|
||||
|
@ -56,7 +56,7 @@ main {
|
||||
border: none;
|
||||
border-radius: var(--card-border-radius);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.05);
|
||||
transition: transform var(--transition-speed) ease,
|
||||
transition: transform var(--transition-speed) ease,
|
||||
box-shadow var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
@ -235,16 +235,16 @@ main {
|
||||
.display-4 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
@ -254,7 +254,7 @@ main {
|
||||
main {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
|
||||
.display-4 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@ -271,4 +271,4 @@ main {
|
||||
|
||||
.border-primary {
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- 歡迎區塊 - 僅登入用戶可見 -->
|
||||
<!-- 用戶歡迎卡片 -->
|
||||
<div class="card bg-primary text-white shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
@ -11,8 +10,7 @@
|
||||
{% if current_user.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=current_user.avatar_path) }}"
|
||||
class="rounded-circle border border-2 border-white"
|
||||
style="width: 80px; height: 80px; object-fit: cover;"
|
||||
alt="{{ current_user.username }}的頭像">
|
||||
style="width: 80px; height: 80px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="avatar-circle bg-white text-primary d-flex align-items-center justify-content-center"
|
||||
style="width: 80px; height: 80px; border-radius: 50%; font-size: 2rem;">
|
||||
@ -22,27 +20,143 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4 class="mb-1">歡迎回來,{{ current_user.username }}!</h4>
|
||||
<p class="mb-0 opacity-75">
|
||||
<i class="bi bi-clock"></i>
|
||||
上次登入時間:{{
|
||||
<div class="small">
|
||||
<i class="bi bi-clock"></i> 上次登入時間:{{
|
||||
current_user.last_login.strftime('%Y-%m-%d %H:%M') if current_user.last_login else '首次登入'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-person-gear"></i> 管理個人資料
|
||||
</a>
|
||||
<a href="{{ url_for('post.create') }}" class="btn btn-outline-light btn-sm ms-2">
|
||||
<i class="bi bi-plus-lg"></i> 發布文章
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light">
|
||||
<i class="bi bi-person-gear"></i> 管理個人資料
|
||||
</a>
|
||||
<div class="col-md-3 text-end">
|
||||
<div class="border-start border-white-50 ps-3">
|
||||
<div class="mb-2">
|
||||
<div class="small text-white-50">我的文章</div>
|
||||
<div class="h4 mb-0">{{ user_stats.posts_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-white-50">收到的讚</div>
|
||||
<div class="h4 mb-0">{{ user_stats.received_likes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 主要內容區 -->
|
||||
<div class="row g-4">
|
||||
<!-- 左側主要內容 -->
|
||||
<!-- 左側主要內容區 -->
|
||||
<div class="col-lg-8">
|
||||
<!-- 熱門文章輪播 -->
|
||||
{% if latest_posts %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-star-fill text-warning"></i> 精選文章
|
||||
</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" data-bs-target="#featuredPosts"
|
||||
data-bs-slide="prev">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-target="#featuredPosts"
|
||||
data-bs-slide="next">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="featuredPosts" class="carousel slide" data-bs-ride="carousel">
|
||||
<div class="carousel-inner">
|
||||
{% for post in latest_posts[:3] %}
|
||||
<div class="carousel-item {% if loop.first %}active{% endif %}">
|
||||
<div class="p-4" style="height: 400px; overflow: hidden;">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<!-- 作者資訊 -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
{% if post.author.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=post.author.avatar_path) }}"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="avatar-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 40px; height: 40px; border-radius: 50%; font-size: 1.2rem;">
|
||||
{{ post.author.username[0].upper() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="fw-medium">{{ post.author.username }}</div>
|
||||
<div class="small text-muted">
|
||||
{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章內容 -->
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<h4 class="card-title mb-3">
|
||||
<a href="{{ url_for('post.show', id=post.id) }}"
|
||||
class="text-decoration-none text-dark">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h4>
|
||||
<p class="card-text text-muted"
|
||||
style="display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden;">
|
||||
{{ post.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 文章統計 -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-3 pt-3 border-top">
|
||||
<div class="d-flex align-items-center text-muted">
|
||||
<div class="me-3">
|
||||
<i class="bi bi-heart-fill text-danger"></i>
|
||||
<span class="ms-1">{{ post.like_count }}</span>
|
||||
</div>
|
||||
<div class="me-3">
|
||||
<i class="bi bi-chat-fill"></i>
|
||||
<span class="ms-1">{{ post.comments_count }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-eye-fill"></i>
|
||||
<span class="ms-1">{{
|
||||
post.view_count if post.view_count else 0
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('post.show', id=post.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
閱讀更多 <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="carousel-indicators" style="bottom: 0;">
|
||||
{% for post in latest_posts[:3] %}
|
||||
<button type="button"
|
||||
data-bs-target="#featuredPosts"
|
||||
data-bs-slide-to="{{ loop.index0 }}"
|
||||
class="{% if loop.first %}active{% endif %}"
|
||||
style="background-color: #0d6efd;"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 最新文章列表 -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">最新文章</h5>
|
||||
@ -50,53 +164,84 @@
|
||||
查看全部 <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{% if latest_posts %}
|
||||
{% for post in latest_posts %}
|
||||
<div class="card mb-3 border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if post.author.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=post.author.avatar_path) }}"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 32px; height: 32px; object-fit: cover;"
|
||||
alt="{{ post.author.username }}的頭像">
|
||||
{% else %}
|
||||
<div class="avatar-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; border-radius: 50%; font-size: 1rem;">
|
||||
{{ post.author.username[0].upper() }}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
{% if post.author.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=post.author.avatar_path) }}"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 32px; height: 32px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="avatar-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; border-radius: 50%; font-size: 1rem;">
|
||||
{{ post.author.username[0].upper() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="fw-medium">{{ post.author.username }}</div>
|
||||
<small class="text-muted">{{
|
||||
post.created_at.strftime('%Y-%m-%d %H:%M')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small">
|
||||
<span class="fw-medium">{{ post.author.username }}</span>
|
||||
<span class="text-muted">• {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
<div class="d-flex align-items-center text-muted small">
|
||||
<div class="me-3">
|
||||
<i class="bi bi-heart-fill text-danger"></i>
|
||||
{{ post.like_count }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-chat-fill"></i>
|
||||
{{ post.comments_count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="card-title mb-2">
|
||||
<a href="{{ url_for('post.show', id=post.id) }}"
|
||||
class="text-decoration-none text-dark">
|
||||
|
||||
<h6 class="card-title">
|
||||
<a href="{{ url_for('post.show', id=post.id) }}" class="text-decoration-none text-dark">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h6>
|
||||
<p class="card-text small text-muted mb-0">
|
||||
{{ post.content[:100] }}{% if post.content|length > 100 %}...{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
|
||||
{% endif %}
|
||||
<p class="card-text small text-muted">
|
||||
{{ post.content[:150] }}{% if post.content|length > 150 %}...{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('post.show', id=post.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
閱讀更多
|
||||
</a>
|
||||
{% if current_user == post.author %}
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('post.edit', id=post.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil"></i> 編輯
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal" data-post-id="{{ post.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<hr class="my-3">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">目前還沒有文章</p>
|
||||
<i class="bi bi-journal-text display-4 text-muted"></i>
|
||||
<p class="mt-3 mb-0">目前還沒有文章</p>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('post.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg"></i> 發布第一篇文章
|
||||
</a>
|
||||
</div>
|
||||
<a href="{{ url_for('post.create') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-plus-lg"></i> 發布第一篇文章
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -109,42 +254,81 @@
|
||||
<!-- 系統資訊卡片 -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">系統資訊</h5>
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-info-circle-fill text-primary"></i> 系統資訊
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="bi bi-people"></i> 總會員數</span>
|
||||
<span class="fw-bold">{{ total_users }}</span>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-people"></i> 總會員數
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill">{{ total_users }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="bi bi-person-add"></i> 本月新增</span>
|
||||
<span class="fw-bold">{{ new_users_this_month }}</span>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-person-plus"></i> 本月新增
|
||||
</div>
|
||||
<span class="badge bg-success rounded-pill">{{ new_users_this_month }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="bi bi-file-text"></i> 總文章數</span>
|
||||
<span class="fw-bold">{{ total_posts }}</span>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-file-text"></i> 總文章數
|
||||
</div>
|
||||
<span class="badge bg-info rounded-pill">{{ total_posts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="bi bi-clock-history"></i> 最後更新</span>
|
||||
<span>{{ last_update }}</span>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-clock-history"></i> 最後更新
|
||||
</div>
|
||||
<small class="text-muted">{{ last_update }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活躍用戶卡片 -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-star-fill text-warning"></i> 活躍用戶
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for user in active_users[:12] %}
|
||||
<a href="#" class="text-decoration-none" data-bs-toggle="tooltip" title="{{ user.username }}">
|
||||
{% if user.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=user.avatar_path) }}"
|
||||
class="rounded-circle border"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="avatar-circle bg-primary text-white d-flex align-items-center justify-content-center"
|
||||
style="width: 40px; height: 40px; border-radius: 50%; font-size: 1.2rem;">
|
||||
{{ user.username[0].upper() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速連結卡片 -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">快速連結</h5>
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-link-45deg text-primary"></i> 快速連結
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
@ -154,7 +338,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('post.index') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-file-text"></i> 所有文章
|
||||
<i class="bi bi-journal-text"></i> 所有文章
|
||||
</a>
|
||||
<a href="{{ url_for('main.members') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-people"></i> 會員列表
|
||||
@ -173,4 +357,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 刪除文章確認對話框 -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">確認刪除</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">確定要刪除這篇文章嗎?此操作無法復原。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<form id="deleteForm" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">確定刪除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 初始化工具提示
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
|
||||
// 處理刪除文章
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const postId = button.getAttribute('data-post-id');
|
||||
const form = document.getElementById('deleteForm');
|
||||
form.action = `/posts/${postId}/delete`;
|
||||
});
|
||||
|
||||
// 初始化輪播
|
||||
var carousel = new bootstrap.Carousel(document.getElementById('featuredPosts'), {
|
||||
interval: 5000,
|
||||
wrap: true,
|
||||
touch: true
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -75,7 +75,7 @@
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">
|
||||
留言區
|
||||
<span class="badge bg-secondary">{{ post.comment_count }}</span>
|
||||
<span class="badge bg-secondary">{{ post.comments_count }}</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
Loading…
Reference in New Issue
Block a user