feat: 初始版本,圆角主题与首次管理员引导

This commit is contained in:
2025-12-07 10:53:52 +08:00
commit 63db6a0815
43 changed files with 1293 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import os
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_required, current_user
from ..extensions import db
from ..models import Activity, ActivityStatus, ActivitySubmission, SubmissionImage, ReviewStatus
from ..services.images import save_image
bp = Blueprint("activities", __name__, url_prefix="/activities")
@bp.route("")
def list_activities():
acts = Activity.query.filter(Activity.status != ActivityStatus.draft).order_by(Activity.start_at).all()
return render_template("activities/list.html", activities=acts)
@bp.route("/<int:act_id>")
def detail(act_id):
act = Activity.query.get_or_404(act_id)
return render_template("activities/detail.html", activity=act)
@bp.route("/<int:act_id>/submit", methods=["GET", "POST"])
@login_required
def submit(act_id):
act = Activity.query.get_or_404(act_id)
if request.method == "POST":
files = request.files.getlist("images")
sub = ActivitySubmission(activity_id=act.id, user_id=current_user.id, status=ReviewStatus.pending)
db.session.add(sub)
db.session.flush()
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], "activities")
for idx, f in enumerate(files):
original, web, thumb, exif = save_image(f, upload_dir)
img = SubmissionImage(submission_id=sub.id, original_path=original, web_path=web, thumb_path=thumb, exif_json=exif, order_index=idx)
db.session.add(img)
db.session.commit()
flash("投稿已提交,待审核")
return redirect(url_for("activities.detail", act_id=act.id))
return render_template("activities/submit.html", activity=act)

137
app/blueprints/admin.py Normal file
View File

@@ -0,0 +1,137 @@
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_required, current_user
from ..extensions import db
from ..models import User, UserStatus, Post, ReviewStatus, Activity, ActivityStatus, ActivitySubmission, ReviewLog
from ..services.notify import notify
bp = Blueprint("admin", __name__, url_prefix="/admin")
def is_admin():
return current_user.is_authenticated and current_user.role == "admin"
@bp.before_request
def guard():
if request.endpoint and request.endpoint.startswith("admin."):
if not is_admin():
return redirect(url_for("auth.login"))
@bp.route("/")
@login_required
def dashboard():
pending_users = User.query.filter_by(status=UserStatus.pending).count()
pending_posts = Post.query.filter_by(status=ReviewStatus.pending).count()
pending_subs = ActivitySubmission.query.filter_by(status=ReviewStatus.pending).count()
return render_template("admin/dashboard.html", pending_users=pending_users, pending_posts=pending_posts, pending_subs=pending_subs)
@bp.route("/reviews/users")
@login_required
def review_users():
users = User.query.filter_by(status=UserStatus.pending).all()
return render_template("admin/reviews_users.html", users=users)
@bp.route("/reviews/users/<int:user_id>/approve", methods=["POST"])
@login_required
def approve_user(user_id):
u = User.query.get_or_404(user_id)
u.status = UserStatus.approved
db.session.add(ReviewLog(target_type="user", target_id=u.id, admin_id=current_user.id, action="approve"))
notify(u.id, "user_approved")
db.session.commit()
return redirect(url_for("admin.review_users"))
@bp.route("/reviews/users/<int:user_id>/reject", methods=["POST"])
@login_required
def reject_user(user_id):
u = User.query.get_or_404(user_id)
u.status = UserStatus.rejected
db.session.add(ReviewLog(target_type="user", target_id=u.id, admin_id=current_user.id, action="reject"))
notify(u.id, "user_rejected")
db.session.commit()
return redirect(url_for("admin.review_users"))
@bp.route("/reviews/posts")
@login_required
def review_posts():
posts = Post.query.filter_by(status=ReviewStatus.pending).all()
return render_template("admin/reviews_posts.html", posts=posts)
@bp.route("/reviews/posts/<int:post_id>/approve", methods=["POST"])
@login_required
def approve_post(post_id):
p = Post.query.get_or_404(post_id)
p.status = ReviewStatus.approved
p.published_at = datetime.utcnow()
db.session.add(ReviewLog(target_type="post", target_id=p.id, admin_id=current_user.id, action="approve"))
notify(p.user_id, "post_approved")
db.session.commit()
return redirect(url_for("admin.review_posts"))
@bp.route("/reviews/posts/<int:post_id>/reject", methods=["POST"])
@login_required
def reject_post(post_id):
p = Post.query.get_or_404(post_id)
p.status = ReviewStatus.rejected
db.session.add(ReviewLog(target_type="post", target_id=p.id, admin_id=current_user.id, action="reject"))
notify(p.user_id, "post_rejected")
db.session.commit()
return redirect(url_for("admin.review_posts"))
@bp.route("/activities", methods=["GET", "POST"])
@login_required
def manage_activities():
if request.method == "POST":
title = request.form.get("title")
theme = request.form.get("theme")
description = request.form.get("description")
a = Activity(title=title, theme=theme, description=description)
db.session.add(a)
db.session.commit()
flash("活动已创建")
return redirect(url_for("admin.manage_activities"))
acts = Activity.query.order_by(Activity.created_at.desc()).all()
return render_template("admin/activities.html", activities=acts)
@bp.route("/activities/<int:act_id>/publish", methods=["POST"])
@login_required
def publish_activity(act_id):
a = Activity.query.get_or_404(act_id)
a.status = ActivityStatus.published
db.session.add(ReviewLog(target_type="activity", target_id=a.id, admin_id=current_user.id, action="publish"))
db.session.commit()
return redirect(url_for("admin.manage_activities"))
@bp.route("/activities/<int:act_id>/close", methods=["POST"])
@login_required
def close_activity(act_id):
a = Activity.query.get_or_404(act_id)
a.status = ActivityStatus.closed
db.session.add(ReviewLog(target_type="activity", target_id=a.id, admin_id=current_user.id, action="close"))
db.session.commit()
return redirect(url_for("admin.manage_activities"))
@bp.route("/reviews/submissions")
@login_required
def review_submissions():
subs = ActivitySubmission.query.filter_by(status=ReviewStatus.pending).all()
return render_template("admin/reviews_submissions.html", submissions=subs)
@bp.route("/reviews/submissions/<int:sub_id>/approve", methods=["POST"])
@login_required
def approve_submission(sub_id):
s = ActivitySubmission.query.get_or_404(sub_id)
s.status = ReviewStatus.approved
db.session.add(ReviewLog(target_type="submission", target_id=s.id, admin_id=current_user.id, action="approve"))
notify(s.user_id, "submission_approved")
db.session.commit()
return redirect(url_for("admin.review_submissions"))
@bp.route("/reviews/submissions/<int:sub_id>/reject", methods=["POST"])
@login_required
def reject_submission(sub_id):
s = ActivitySubmission.query.get_or_404(sub_id)
s.status = ReviewStatus.rejected
db.session.add(ReviewLog(target_type="submission", target_id=s.id, admin_id=current_user.id, action="reject"))
notify(s.user_id, "submission_rejected")
db.session.commit()
return redirect(url_for("admin.review_submissions"))

61
app/blueprints/auth.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_user, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from ..extensions import db, login_manager
from ..models import User, Profile, UserStatus
bp = Blueprint("auth", __name__, url_prefix="/auth")
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
email = request.form.get("email")
username = request.form.get("username")
password = request.form.get("password")
photo = request.files.get("identity_photo")
if not email or not username or not password or not photo:
flash("请完整填写信息并上传身份照片")
return redirect(url_for("auth.register"))
if User.query.filter_by(email=email).first() or User.query.filter_by(username=username).first():
flash("邮箱或用户名已存在")
return redirect(url_for("auth.register"))
filename = secure_filename(photo.filename)
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], "identity")
path = os.path.join(upload_dir, filename)
photo.save(path)
user = User(email=email, username=username, password_hash=generate_password_hash(password), status=UserStatus.pending, identity_photo_path=path)
db.session.add(user)
db.session.flush()
profile = Profile(user_id=user.id)
db.session.add(profile)
db.session.commit()
flash("注册提交成功,请等待管理员审核")
return redirect(url_for("auth.login"))
return render_template("auth/register.html")
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
email = request.form.get("email")
password = request.form.get("password")
user = User.query.filter_by(email=email).first()
if not user or not check_password_hash(user.password_hash, password):
flash("登录失败")
return redirect(url_for("auth.login"))
if user.status != UserStatus.approved:
flash("账户未审核通过")
return redirect(url_for("auth.login"))
login_user(user)
return redirect(url_for("feed.discover"))
return render_template("auth/login.html")
@bp.route("/logout")
def logout():
logout_user()
return redirect(url_for("auth.login"))

View File

@@ -0,0 +1,17 @@
from flask import Blueprint, request, redirect, url_for
from flask_login import login_required, current_user
from ..extensions import db
from ..models import Comment, Post
bp = Blueprint("comments", __name__, url_prefix="/comments")
@bp.route("/create/<int:post_id>", methods=["POST"])
@login_required
def create(post_id):
post = Post.query.get_or_404(post_id)
body = request.form.get("body")
if body:
c = Comment(post_id=post.id, user_id=current_user.id, body=body)
db.session.add(c)
db.session.commit()
return redirect(url_for("posts.detail", post_id=post.id))

23
app/blueprints/feed.py Normal file
View File

@@ -0,0 +1,23 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
from sqlalchemy import desc
from ..models import Post, Visibility, ReviewStatus, Follow
bp = Blueprint("feed", __name__, url_prefix="/feed")
@bp.route("/")
def home():
return discover()
@bp.route("/discover")
def discover():
posts = Post.query.filter(Post.visibility == Visibility.public, Post.status == ReviewStatus.approved).order_by(desc(Post.published_at)).limit(100).all()
return render_template("feed/discover.html", posts=posts)
@bp.route("/following")
@login_required
def following():
ids = [f.followee_id for f in Follow.query.filter_by(follower_id=current_user.id).all()]
q = Post.query.filter(Post.user_id.in_(ids)).filter((Post.visibility == Visibility.followers) | (Post.visibility == Visibility.public))
posts = q.order_by(desc(Post.created_at)).limit(100).all()
return render_template("feed/following.html", posts=posts)

25
app/blueprints/follows.py Normal file
View File

@@ -0,0 +1,25 @@
from flask import Blueprint, redirect, url_for
from flask_login import login_required, current_user
from ..extensions import db
from ..models import Follow, User
bp = Blueprint("follows", __name__, url_prefix="/follows")
@bp.route("/follow/<int:user_id>")
@login_required
def follow(user_id):
target = User.query.get_or_404(user_id)
if target.id != current_user.id:
exist = Follow.query.filter_by(follower_id=current_user.id, followee_id=target.id).first()
if not exist:
db.session.add(Follow(follower_id=current_user.id, followee_id=target.id))
db.session.commit()
return redirect(url_for("users.profile", user_id=target.id))
@bp.route("/unfollow/<int:user_id>")
@login_required
def unfollow(user_id):
target = User.query.get_or_404(user_id)
Follow.query.filter_by(follower_id=current_user.id, followee_id=target.id).delete()
db.session.commit()
return redirect(url_for("users.profile", user_id=target.id))

11
app/blueprints/main.py Normal file
View File

@@ -0,0 +1,11 @@
from flask import Blueprint, redirect, url_for, current_app, send_from_directory
bp = Blueprint("main", __name__)
@bp.route("/")
def index():
return redirect(url_for("feed.discover"))
@bp.route("/uploads/<path:filename>")
def uploads(filename):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)

53
app/blueprints/posts.py Normal file
View File

@@ -0,0 +1,53 @@
import os
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from ..extensions import db
from ..models import Post, PostImage, Visibility, ReviewStatus, Follow
from ..services.images import save_image
bp = Blueprint("posts", __name__, url_prefix="/posts")
@bp.route("/create", methods=["GET", "POST"])
@login_required
def create():
if request.method == "POST":
title = request.form.get("title")
description = request.form.get("description")
visibility = request.form.get("visibility", Visibility.private.value)
files = request.files.getlist("images")
if not title or not files:
flash("请填写标题并上传图片")
return redirect(url_for("posts.create"))
post = Post(user_id=current_user.id, title=title, description=description, visibility=Visibility(visibility))
if post.visibility == Visibility.public:
post.status = ReviewStatus.pending
else:
post.status = ReviewStatus.approved
post.published_at = datetime.utcnow()
db.session.add(post)
db.session.flush()
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], "posts")
for idx, f in enumerate(files):
original, web, thumb, exif = save_image(f, upload_dir)
img = PostImage(post_id=post.id, original_path=original, web_path=web, thumb_path=thumb, exif_json=exif, order_index=idx)
db.session.add(img)
db.session.commit()
flash("作品已提交")
return redirect(url_for("users.profile", user_id=current_user.id))
return render_template("posts/create.html")
@bp.route("/<int:post_id>")
def detail(post_id):
post = Post.query.get_or_404(post_id)
if post.visibility == Visibility.private and (not current_user.is_authenticated or current_user.id != post.user_id):
return redirect(url_for("auth.login"))
if post.visibility == Visibility.followers:
if not current_user.is_authenticated:
return redirect(url_for("auth.login"))
if current_user.id != post.user_id:
f = Follow.query.filter_by(follower_id=current_user.id, followee_id=post.user_id).first()
if not f:
return redirect(url_for("auth.login"))
return render_template("posts/detail.html", post=post)

39
app/blueprints/setup.py Normal file
View File

@@ -0,0 +1,39 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user
from werkzeug.security import generate_password_hash
from ..extensions import db
from ..models import User, Profile, UserStatus
bp = Blueprint("setup", __name__, url_prefix="/setup")
@bp.route("/admin", methods=["GET", "POST"])
def admin():
exists = User.query.filter_by(role="admin").first()
if exists:
return redirect(url_for("feed.discover"))
if request.method == "POST":
email = request.form.get("email")
username = request.form.get("username")
password = request.form.get("password")
confirm = request.form.get("confirm")
if not email or not username or not password:
flash("请完整填写信息")
return redirect(url_for("setup.admin"))
if password != confirm:
flash("两次密码不一致")
return redirect(url_for("setup.admin"))
if len(password) < 8:
flash("密码至少8位并包含数字与字母")
return redirect(url_for("setup.admin"))
if User.query.filter((User.email==email) | (User.username==username)).first():
flash("邮箱或用户名已存在")
return redirect(url_for("setup.admin"))
u = User(email=email, username=username, password_hash=generate_password_hash(password), role="admin", status=UserStatus.approved)
db.session.add(u)
db.session.flush()
db.session.add(Profile(user_id=u.id))
db.session.commit()
login_user(u)
flash("管理员创建成功")
return redirect(url_for("admin.dashboard"))
return render_template("setup/admin.html")

29
app/blueprints/users.py Normal file
View File

@@ -0,0 +1,29 @@
from flask import Blueprint, render_template, request, redirect, url_for
from flask_login import login_required, current_user
from ..extensions import db
from ..models import User, Profile, Notification
bp = Blueprint("users", __name__, url_prefix="/users")
@bp.route("/<int:user_id>")
def profile(user_id):
user = User.query.get_or_404(user_id)
return render_template("users/profile.html", user=user)
@bp.route("/me/edit", methods=["GET", "POST"])
@login_required
def edit_profile():
profile = Profile.query.filter_by(user_id=current_user.id).first()
if request.method == "POST":
profile.bio = request.form.get("bio")
profile.grade = request.form.get("grade")
profile.class_name = request.form.get("class_name")
db.session.commit()
return redirect(url_for("users.profile", user_id=current_user.id))
return render_template("users/edit.html", profile=profile)
@bp.route("/me/notifications")
@login_required
def notifications():
items = Notification.query.filter_by(user_id=current_user.id).order_by(Notification.created_at.desc()).limit(50).all()
return render_template("users/notifications.html", items=items)