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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/.venv/
/uploads/
__pycache__/
.pytest_cache/
*.pyc
*.pyo
*.db
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,68 @@
## 目标
- 全站视觉风格更圆润、现代、美观,统一色彩与控件样式
- 首次访问强制进入“创建管理员”引导页,完成后正常使用网站
## 视觉与交互设计
- 基调:浅色主题,品牌主色(如紫/蓝),高对比文本,柔和阴影
- 圆角统一使用大圆角816px用于卡片、输入框、按钮、图片
- 卡片化:发现/关注/活动列表与作品详情采用卡片布局,留白更舒适
- 图片网格统一比例裁切aspect-ratio 4/3`object-fit: cover`,悬浮轻阴影
- 导航与页脚:更紧凑的导航条、醒目按钮、移动端更友好
- 表单:更大输入框、内边距与圆角、清晰错误提示
- 细节头像圆形、标签EXIF胶囊样式、按钮主次分明
## 模板改造Jinja
- `app/templates/base.html`
- 增加主题样式引入 `static/theme.css`
- 优化导航条结构与按钮样式,增加圆角、阴影
- 统一面包屑与主容器间距
- `feed/discover.html``feed/following.html`
- 使用卡片网格展示作品标题与缩略图
- 悬停动效与点击进入详情
- `posts/detail.html`
- 大图展示 + 侧边信息(作者、可见性、发布时间)
- EXIF 信息用胶囊标签行内展示
- 评论区采用圆角气泡样式
- `posts/create.html`
- 分组表单卡片,更清晰的可见性说明与提示
- `activities/*.html``admin/*.html`
- 统一卡片表格样式,按钮圆角与颜色体系
## 样式资源
- 新增 `app/static/theme.css`
- 定义 CSS 变量:主色、辅色、阴影、圆角、间距
- 重置 Bootstrap 部分组件圆角与阴影(按钮、输入框、卡片、徽章)
- 网格卡片与图片缩略通用类:`.card-grid`, `.photo-thumb`
- 可选Web 字体(如 Noto Sans SC若需本地部署后再切换至本地字体
## 首次访问管理员引导
- 新增蓝图 `setup`
- `GET/POST /setup/admin`展示与处理管理员创建表单email、username、password、确认密码
- 强密码校验(长度/复杂度、唯一性校验、CSRF
- 全局守卫:
- 在应用工厂注册 `before_request` 钩子,若数据库中不存在 `role='admin'` 的用户且请求路径不是 `/setup/admin`,则重定向至引导页
- 引导成功后跳转至首页
- 移除默认 CLI 管理员创建说明README 改为首次访问引导
## 安全与验证
- 表单CSRF、服务端校验邮箱格式、用户名长度、密码强度
- 异常:重复管理员创建拦截(只有首次可创建)
- 体验:错误信息以卡片内 alert 圆角提示
## 变更清单(文件级)
- 新增:`app/blueprints/setup.py`(管理员引导页)
- 修改:`app/__init__.py`(注册蓝图与首次访问守卫)
- 修改:`app/templates/base.html`(引入 `theme.css` 与导航优化)
- 修改:`app/templates/*`(发现、关注、发布、详情、活动、后台均套用卡片与网格样式)
- 新增:`app/static/theme.css`(全站主题与圆角风格)
- 修改:`README.md`(管理员创建流程说明)
## 实施顺序
1. 添加 `theme.css`,在 `base.html` 引入并替换核心页面为卡片/网格布局
2. 创建 `setup` 蓝图与管理员表单模板、校验逻辑
3. 在应用工厂加入首次访问守卫逻辑与蓝图注册
4. 更新 README移除默认 CLI 管理员创建指令
5. 手动验证:首次访问重定向 → 管理员创建 → 登录 → 浏览发现/关注/发布/活动/后台
---
请确认是否按此方案执行,我将开始改造样式并实现首次访问管理员引导。

View File

@@ -0,0 +1,125 @@
## 项目概述
- 框架Python Flask + Jinja2 服务端渲染REST API 供前端交互
- 角色:`未审核用户``普通用户``管理员`
- 审核点:注册身份审核、公开作品审核、活动投稿审核
## 技术栈与依赖
- 后端:`Flask``Flask-Login``Flask-WTF``Flask-SQLAlchemy``Flask-Migrate`
- 图片处理:`Pillow`
- 表单与校验:`WTForms`
- UI`Bootstrap`Jinja2 模板)
- 数据库:开发用 `SQLite`,部署用 `PostgreSQL`
- 可选:`Flask-Admin`(或自定义后台)、`Flask-Mail`(邮件通知)
## 数据库设计(核心表)
- `users`idemailusernamepassword_hashrolestatus(pending/approved/rejected)identity_photo_pathcreated_at
- `profiles`iduser_idavatar_pathbiogradeclass_namelinksupdated_at
- `posts`iduser_idtitledescriptionvisibility(public/followers/private)status(pending/approved/rejected)created_atpublished_at
- `post_images`idpost_idoriginal_pathweb_paththumb_pathexif_jsonorder_index
- `comments`idpost_iduser_idbodystatus(active/removed)created_at
- `follows`follower_idfollowee_idcreated_at唯一索引: follower_id+followee_id
- `activities`idtitlethemedescriptionstart_atend_atstatus(draft/published/closed)created_at
- `activity_submissions`idactivity_iduser_idstatus(pending/approved/rejected)created_at
- `submission_images`idsubmission_idoriginal_pathweb_paththumb_pathexif_jsonorder_index
- `likes`可选idpost_iduser_idcreated_at
- `notifications`iduser_idtypepayload_jsonread_atcreated_at
- `review_logs`idtarget_type(user/post/submission)target_idadmin_idaction(approve/reject)reasoncreated_at
## 目录结构
- `app/`:应用工厂(`create_app`)、配置、扩展注册
- `app/blueprints/``auth``users``posts``comments``follows``feed``activities``admin`
- `app/models/`SQLAlchemy 模型
- `app/services/`:图片处理、审核服务、通知服务
- `app/templates/`Jinja2 模板(含后台)
- `app/static/`CSS/JS/图片
- `uploads/``identity/``posts/``activities/`
- `migrations/`:数据库迁移
## 权限与审核流程
- 注册:用户提交基础信息 + 学生身份照片 → `status=pending` → 管理员审核通过后 `approved`,可登录和发帖
- 发帖:用户创建作品(多图)→ 若设置 `public`,则 `status=pending`,管理员审核通过后公开;`followers/private` 直接可见(仍可被管理员撤回)
- 活动投稿:在活动期内提交→管理员审核→通过后在活动展示页公开
- 管理员操作记录进入 `review_logs`
## 业务模块与页面
- 认证:注册、登录、退出、找回密码(可选邮件)
- 主页个人资料、TA的作品、关注/粉丝、活动投稿
- 作品:创建/编辑/删除、多图上传、EXIF展示、评论区
- 发现:全站公开作品流(按热度/最新),可筛选主题、活动
- 关注:显示所关注用户的最新作品(含非公开中 `followers` 可见)
- 活动:活动列表、详情、投稿入口、获奖/精选展示
- 管理后台:注册审核队列、公开作品审核、活动创建与审核、用户管理、审核日志
## API 路由草案
- `POST /api/auth/register`multipart含身份照
- `POST /api/auth/login``GET /api/auth/logout`
- `GET /api/users/<id>``PUT /api/users/<id>`(资料)
- `POST /api/posts``PUT /api/posts/<id>``DELETE /api/posts/<id>``GET /api/posts/<id>`
- `POST /api/posts/<id>/images`(追加图片),`DELETE /api/posts/<id>/images/<img_id>`
- `POST /api/posts/<id>/comments``GET /api/posts/<id>/comments``DELETE /api/comments/<id>`
- `POST /api/users/<id>/follow``DELETE /api/users/<id>/follow`
- `GET /api/feed/discover``GET /api/feed/following`
- `GET /api/activities``GET /api/activities/<id>`
- `POST /api/activities/<id>/submit`multipart多图
- 管理员:
- `GET /admin/reviews/users``POST /admin/reviews/users/<id>/approve|reject`
- `GET /admin/reviews/posts``POST /admin/reviews/posts/<id>/approve|reject`
- `POST /admin/activities``PUT /admin/activities/<id>``POST /admin/activities/<id>/publish|close`
- `GET /admin/reviews/submissions``POST /admin/reviews/submissions/<id>/approve|reject`
## 图片上传与处理
- 校验文件类型JPEG/PNG、大小限制、内容解码用 Pillow 防伪造)
- 生成:`thumb`(方形或短边)、`web`(最大边约 1600px、保留原图
- 提取EXIF相机、镜头、快门、光圈、ISO、焦距
- 存储:磁盘分目录;文件名 `uuid`;数据库保存路径与元数据
- 访问:统一 `send_from_directory` 或静态映射;考虑防盗链与权限检查(非公开资源鉴权)
## 活动模块设计
- 活动生命周期:`draft → published → closed`
- 字段:主题、时间窗、规则、允许每人投稿数量、是否匿名展示
- 审核:投稿队列、通过后进入活动展示;支持精选/获奖标记
## 关注与发现
- 发现流:`approved & public` 的作品,按 `score = w1*likes + w2*comments + w3*recency`
- 关注流:所关注用户的最新作品,按时间倒序;含 `followers` 可见内容
- 索引与缓存:热门榜单每日重算(可用简单缓存或定时任务)
## 通知与消息
- 事件:审核结果、评论提醒、关注提醒、活动邀请/通过结果
- 拉取:通知列表页与角标;邮件可选
## 管理后台
- 仪表盘:待审核计数、近期活动、违规内容处理
- 队列:注册、公开作品、活动投稿
- 用户管理:封禁/解除、角色变更、作品/评论移除
- 审核日志:可检索与导出
## 安全与合规
- 密码:`werkzeug.security` 哈希PBKDF2强密码政策
- 会话:`Flask-Login`保护关键路由CSRF 防护(`Flask-WTF`
- 上传:限制大小与类型,文件名随机化,路径隔离,权限控制
- 敏感信息:`SECRET_KEY`、数据库连接通过环境变量;不记录敏感日志
- 速率限制(可选):注册/登录/评论防刷
## 测试与验证
- 单元测试:模型、服务(图片处理、审核)
- 集成测试:注册→审核→发帖→公开→评论→关注→活动投稿全链路
- 工具:`pytest``Flask-Testing`(可选),`Flask-Migrate` 迁移验证
## 部署与环境
- 开发:`SQLite` + 内置服务器
- 生产:`Gunicorn + Nginx`Linux`Waitress`Windows`PostgreSQL`
- 静态与上传Nginx 映射,非公开资源走鉴权路由
- 初始脚本:创建管理员、迁移数据库、配置环境变量
## 里程碑与实施顺序
1. 项目骨架与配置、模型与迁移
2. 认证与注册审核链路
3. 作品与多图上传、EXIF、可见性
4. 评论与关注、发现与关注流
5. 活动模块与投稿审核
6. 管理后台与审核日志
7. 通知与优化、测试覆盖与部署
---
请确认是否按此方案开始实现,或指出需要调整的部分。

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# 泸州高中摄影社论坛
- 运行:`pip install -r requirements.txt``python run.py`
- 首次访问:将自动跳转至 `/setup/admin` 引导页创建管理员(无需命令行)
- 默认开发数据库:`SQLite`,上传目录:`uploads/`
- 入口:`http://127.0.0.1:5000/`
## 主要功能
- 注册上传身份照,管理员审核
- 发布作品(多图),公开需审核
- 评论、关注、发现与关注流
- 活动创建与投稿审核
- 管理后台与审核日志
- 通知提醒审核结果

56
app/__init__.py Normal file
View File

@@ -0,0 +1,56 @@
import os
from flask import Flask
from .extensions import db, migrate, login_manager, csrf
from .config import Config
from flask_wtf.csrf import generate_csrf
def create_app():
app = Flask(__name__, static_folder="static", template_folder="templates")
app.config.from_object(Config)
os.makedirs(app.config.get("UPLOAD_FOLDER"), exist_ok=True)
os.makedirs(os.path.join(app.config.get("UPLOAD_FOLDER"), "identity"), exist_ok=True)
os.makedirs(os.path.join(app.config.get("UPLOAD_FOLDER"), "posts"), exist_ok=True)
os.makedirs(os.path.join(app.config.get("UPLOAD_FOLDER"), "activities"), exist_ok=True)
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
csrf.init_app(app)
with app.app_context():
db.create_all()
@app.context_processor
def inject_csrf():
return dict(csrf_token=generate_csrf())
from .blueprints.auth import bp as auth_bp
from .blueprints.users import bp as users_bp
from .blueprints.posts import bp as posts_bp
from .blueprints.comments import bp as comments_bp
from .blueprints.follows import bp as follows_bp
from .blueprints.feed import bp as feed_bp
from .blueprints.activities import bp as activities_bp
from .blueprints.admin import bp as admin_bp
from .blueprints.main import bp as main_bp
from .blueprints.setup import bp as setup_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(setup_bp)
app.register_blueprint(users_bp)
app.register_blueprint(posts_bp)
app.register_blueprint(comments_bp)
app.register_blueprint(follows_bp)
app.register_blueprint(feed_bp)
app.register_blueprint(activities_bp)
app.register_blueprint(admin_bp)
from .cli import register_cli
register_cli(app)
@app.before_request
def ensure_admin_setup():
from .models import User
from flask import request, redirect, url_for
try:
has_admin = User.query.filter_by(role="admin").first() is not None
except Exception:
has_admin = True
if not has_admin:
if not (request.path.startswith("/setup/admin") or request.path.startswith("/static")):
return redirect(url_for("setup.admin"))
return app

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)

18
app/cli.py Normal file
View File

@@ -0,0 +1,18 @@
from werkzeug.security import generate_password_hash
from .extensions import db
from .models import User, Profile, UserStatus
def register_cli(app):
@app.cli.command("create-admin")
def create_admin():
email = "admin@example.com"
username = "admin"
password = "admin123"
exist = User.query.filter_by(email=email).first()
if exist:
return
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()

9
app/config.py Normal file
View File

@@ -0,0 +1,9 @@
import os
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///site.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.path.join(os.getcwd(), "uploads")
MAX_CONTENT_LENGTH = 32 * 1024 * 1024
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png"}

9
app/extensions.py Normal file
View File

@@ -0,0 +1,9 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_wtf import CSRFProtect
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()

129
app/models.py Normal file
View File

@@ -0,0 +1,129 @@
from datetime import datetime
from enum import Enum
from flask_login import UserMixin
from .extensions import db
class UserStatus(str, Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class Visibility(str, Enum):
public = "public"
followers = "followers"
private = "private"
class ReviewStatus(str, Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class ActivityStatus(str, Enum):
draft = "draft"
published = "published"
closed = "closed"
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False)
username = db.Column(db.String(64), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(32), default="user")
status = db.Column(db.Enum(UserStatus), default=UserStatus.pending, nullable=False)
identity_photo_path = db.Column(db.String(512))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
profile = db.relationship("Profile", backref="user", uselist=False)
class Profile(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
avatar_path = db.Column(db.String(512))
bio = db.Column(db.Text)
grade = db.Column(db.String(32))
class_name = db.Column(db.String(32))
links = db.Column(db.Text)
updated_at = db.Column(db.DateTime, default=datetime.utcnow)
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
title = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text)
visibility = db.Column(db.Enum(Visibility), default=Visibility.private, nullable=False)
status = db.Column(db.Enum(ReviewStatus), default=ReviewStatus.approved, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
published_at = db.Column(db.DateTime)
user = db.relationship("User", backref=db.backref("posts", lazy=True))
class PostImage(db.Model):
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False)
original_path = db.Column(db.String(512), nullable=False)
web_path = db.Column(db.String(512))
thumb_path = db.Column(db.String(512))
exif_json = db.Column(db.Text)
order_index = db.Column(db.Integer, default=0)
post = db.relationship("Post", backref=db.backref("images", lazy=True, cascade="all, delete-orphan"))
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(32), default="active")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Follow(db.Model):
follower_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
followee_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Activity(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
theme = db.Column(db.String(255))
description = db.Column(db.Text)
start_at = db.Column(db.DateTime)
end_at = db.Column(db.DateTime)
status = db.Column(db.Enum(ActivityStatus), default=ActivityStatus.draft, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class ActivitySubmission(db.Model):
id = db.Column(db.Integer, primary_key=True)
activity_id = db.Column(db.Integer, db.ForeignKey("activity.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
status = db.Column(db.Enum(ReviewStatus), default=ReviewStatus.pending, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
activity = db.relationship("Activity", backref=db.backref("submissions", lazy=True))
class SubmissionImage(db.Model):
id = db.Column(db.Integer, primary_key=True)
submission_id = db.Column(db.Integer, db.ForeignKey("activity_submission.id"), nullable=False)
original_path = db.Column(db.String(512), nullable=False)
web_path = db.Column(db.String(512))
thumb_path = db.Column(db.String(512))
exif_json = db.Column(db.Text)
order_index = db.Column(db.Integer, default=0)
class Like(db.Model):
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
type = db.Column(db.String(64), nullable=False)
payload_json = db.Column(db.Text)
read_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class ReviewLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
target_type = db.Column(db.String(32), nullable=False)
target_id = db.Column(db.Integer, nullable=False)
admin_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
action = db.Column(db.String(32), nullable=False)
reason = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

28
app/services/images.py Normal file
View File

@@ -0,0 +1,28 @@
import os
import uuid
import json
from PIL import Image, ExifTags
def save_image(file_storage, base_dir):
ext = os.path.splitext(file_storage.filename)[1].lower()
name = f"{uuid.uuid4().hex}{ext}"
original_path = os.path.join(base_dir, name)
file_storage.save(original_path)
web_path = os.path.join(base_dir, f"web_{name}")
thumb_path = os.path.join(base_dir, f"thumb_{name}")
with Image.open(original_path) as im:
im_web = im.copy()
im_web.thumbnail((1600, 1600))
im_web.save(web_path)
im_thumb = im.copy()
im_thumb.thumbnail((400, 400))
im_thumb.save(thumb_path)
exif = {}
try:
raw = im.getexif()
for k, v in raw.items():
tag = ExifTags.TAGS.get(k, str(k))
exif[tag] = str(v)
except Exception:
exif = {}
return original_path, web_path, thumb_path, json.dumps(exif)

6
app/services/notify.py Normal file
View File

@@ -0,0 +1,6 @@
from ..extensions import db
from ..models import Notification
def notify(user_id, type_, payload_json=None):
n = Notification(user_id=user_id, type=type_, payload_json=payload_json)
db.session.add(n)

24
app/static/theme.css Normal file
View File

@@ -0,0 +1,24 @@
:root {
--brand: #5b7cfa;
--brand-2: #7b5bfa;
--radius: 14px;
--radius-sm: 10px;
--shadow: 0 6px 20px rgba(0,0,0,0.06);
}
body { background: #f8fafc; }
.container { max-width: 1100px; }
.navbar { border-radius: var(--radius); box-shadow: var(--shadow); }
.card { border: 0; border-radius: var(--radius); box-shadow: var(--shadow); }
.btn { border-radius: var(--radius-sm); }
.form-control, .form-select, textarea { border-radius: var(--radius-sm); }
.badge, .alert { border-radius: var(--radius-sm); }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.photo-thumb { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: var(--radius); }
.exif-tag { display: inline-block; background: #eef2ff; color: #334155; padding: 4px 10px; margin: 4px 6px 0 0; border-radius: 999px; font-size: 12px; }
.comment-bubble { background: #ffffff; border-radius: var(--radius); padding: 12px; box-shadow: var(--shadow); }
.brand { color: var(--brand); }
.btn-brand { background: var(--brand); color: #fff; }
.btn-brand:hover { background: var(--brand-2); color: #fff; }

View File

@@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% block title %}活动详情{% endblock %}
{% block content %}
<h3>{{ activity.title }}</h3>
<p>{{ activity.description }}</p>
<a class="btn btn-primary" href="{{ url_for('activities.submit', act_id=activity.id) }}">投稿</a>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}活动{% endblock %}
{% block content %}
<h3>活动</h3>
<ul>
{% for a in activities %}
<li><a href="{{ url_for('activities.detail', act_id=a.id) }}">{{ a.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}投稿{% endblock %}
{% block content %}
<h3>向 {{ activity.title }} 投稿</h3>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">图片</label><input class="form-control" name="images" type="file" accept="image/*" multiple required></div>
<button class="btn btn-primary" type="submit">提交</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block title %}活动管理{% endblock %}
{% block content %}
<h3>活动管理</h3>
<form method="post" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-2"><input class="form-control" name="title" placeholder="标题" required></div>
<div class="mb-2"><input class="form-control" name="theme" placeholder="主题"></div>
<div class="mb-2"><textarea class="form-control" name="description" placeholder="描述"></textarea></div>
<button class="btn btn-primary" type="submit">创建</button>
</form>
<table class="table">
<tr><th>标题</th><th>状态</th><th>操作</th></tr>
{% for a in activities %}
<tr>
<td>{{ a.title }}</td>
<td>{{ a.status.value }}</td>
<td>
<form method="post" action="{{ url_for('admin.publish_activity', act_id=a.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-success">发布</button>
</form>
<form method="post" action="{{ url_for('admin.close_activity', act_id=a.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-warning">关闭</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}管理员{% endblock %}
{% block content %}
<h3>管理员仪表盘</h3>
<div class="row">
<div class="col">待审核注册:{{ pending_users }}</div>
<div class="col">待审核作品:{{ pending_posts }}</div>
<div class="col">待审核投稿:{{ pending_subs }}</div>
</div>
<hr>
<a class="btn btn-link" href="{{ url_for('admin.review_users') }}">注册审核</a>
<a class="btn btn-link" href="{{ url_for('admin.review_posts') }}">作品审核</a>
<a class="btn btn-link" href="{{ url_for('admin.review_submissions') }}">投稿审核</a>
<a class="btn btn-link" href="{{ url_for('admin.manage_activities') }}">活动管理</a>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}作品审核{% endblock %}
{% block content %}
<h3>作品审核</h3>
<table class="table">
<tr><th>标题</th><th>作者</th><th>操作</th></tr>
{% for p in posts %}
<tr>
<td>{{ p.title }}</td>
<td>{{ p.user.username }}</td>
<td>
<form method="post" action="{{ url_for('admin.approve_post', post_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-success">通过</button>
</form>
<form method="post" action="{{ url_for('admin.reject_post', post_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-danger">拒绝</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}投稿审核{% endblock %}
{% block content %}
<h3>投稿审核</h3>
<table class="table">
<tr><th>活动</th><th>用户</th><th>操作</th></tr>
{% for s in submissions %}
<tr>
<td>{{ s.activity.title }}</td>
<td>{{ s.user_id }}</td>
<td>
<form method="post" action="{{ url_for('admin.approve_submission', sub_id=s.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-success">通过</button>
</form>
<form method="post" action="{{ url_for('admin.reject_submission', sub_id=s.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-danger">拒绝</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}注册审核{% endblock %}
{% block content %}
<h3>注册审核</h3>
<table class="table">
<tr><th>用户名</th><th>邮箱</th><th>操作</th></tr>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>
<form method="post" action="{{ url_for('admin.approve_user', user_id=u.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-success">通过</button>
</form>
<form method="post" action="{{ url_for('admin.reject_user', user_id=u.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-danger">拒绝</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %}登录{% endblock %}
{% block content %}
<h3 class="mb-3">登录</h3>
<form class="card p-3" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">邮箱</label><input class="form-control" name="email" type="email" required></div>
<div class="mb-3"><label class="form-label">密码</label><input class="form-control" name="password" type="password" required></div>
<button class="btn btn-brand" type="submit">登录</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}注册{% endblock %}
{% block content %}
<h3 class="mb-3">注册</h3>
<form class="card p-3" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">邮箱</label><input class="form-control" name="email" type="email" required></div>
<div class="mb-3"><label class="form-label">用户名</label><input class="form-control" name="username" required></div>
<div class="mb-3"><label class="form-label">密码</label><input class="form-control" name="password" type="password" required></div>
<div class="mb-3"><label class="form-label">学生身份照片</label><input class="form-control" name="identity_photo" type="file" accept="image/*" required></div>
<button class="btn btn-brand" type="submit">提交</button>
</form>
{% endblock %}

43
app/templates/base.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}摄影社论坛{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='theme.css') }}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white mb-3 mt-2 px-3">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('feed.discover') }}">摄影社</a>
<div>
<a class="btn btn-link" href="{{ url_for('feed.discover') }}">发现</a>
{% if current_user.is_authenticated %}
<a class="btn btn-link" href="{{ url_for('feed.following') }}">关注</a>
<a class="btn btn-link" href="{{ url_for('users.profile', user_id=current_user.id) }}">我的主页</a>
<a class="btn btn-link" href="{{ url_for('users.notifications') }}">通知</a>
<a class="btn btn-link" href="{{ url_for('posts.create') }}">发布作品</a>
<a class="btn btn-link" href="{{ url_for('activities.list_activities') }}">活动</a>
{% if current_user.role == 'admin' %}
<a class="btn btn-link" href="{{ url_for('admin.dashboard') }}">管理员</a>
{% endif %}
<a class="btn btn-link" href="{{ url_for('auth.logout') }}">退出</a>
{% else %}
<a class="btn btn-link" href="{{ url_for('auth.login') }}">登录</a>
<a class="btn btn-brand" href="{{ url_for('auth.register') }}">注册</a>
{% endif %}
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-info">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}发现{% endblock %}
{% block content %}
<h3 class="mb-3">发现</h3>
<div class="card-grid">
{% for p in posts %}
<div class="card">
{% set first = p.images[0] if p.images %}
{% if first %}
<a href="{{ url_for('posts.detail', post_id=p.id) }}">
<img class="photo-thumb" src="{{ url_for('main.uploads', filename=first.web_path.split('uploads\\')[-1]) }}" alt="{{ p.title }}" />
</a>
{% endif %}
<div class="p-3">
<a class="text-decoration-none" href="{{ url_for('posts.detail', post_id=p.id) }}">{{ p.title }}</a>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}关注{% endblock %}
{% block content %}
<h3 class="mb-3">关注</h3>
<div class="card-grid">
{% for p in posts %}
<div class="card">
{% set first = p.images[0] if p.images %}
{% if first %}
<a href="{{ url_for('posts.detail', post_id=p.id) }}">
<img class="photo-thumb" src="{{ url_for('main.uploads', filename=first.web_path.split('uploads\\')[-1]) }}" alt="{{ p.title }}" />
</a>
{% endif %}
<div class="p-3">
<a class="text-decoration-none" href="{{ url_for('posts.detail', post_id=p.id) }}">{{ p.title }}</a>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block title %}发布作品{% endblock %}
{% block content %}
<h3 class="mb-3">发布作品</h3>
<form class="card p-3" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">标题</label><input class="form-control" name="title" required></div>
<div class="mb-3"><label class="form-label">介绍</label><textarea class="form-control" name="description"></textarea></div>
<div class="mb-3"><label class="form-label">可见性</label>
<select class="form-select" name="visibility">
<option value="public">公开(需审核)</option>
<option value="followers">仅关注者</option>
<option value="private">仅自己</option>
</select>
</div>
<div class="mb-3"><label class="form-label">图片</label><input class="form-control" name="images" type="file" accept="image/*" multiple required></div>
<button class="btn btn-brand" type="submit">提交</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}作品详情{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="p-3">
<h3 class="mb-2">{{ post.title }}</h3>
<p class="text-secondary">{{ post.description }}</p>
<span class="badge bg-light text-dark">{{ post.visibility.value }}</span>
</div>
<div class="p-3">
<div class="row">
{% for img in post.images %}
<div class="col-md-4 mb-2">
<img src="{{ url_for('main.uploads', filename=img.web_path.split('uploads\\')[-1]) }}" class="photo-thumb" />
</div>
{% endfor %}
</div>
</div>
</div>
<hr>
<form class="card p-3" method="post" action="{{ url_for('comments.create', post_id=post.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><textarea class="form-control" name="body" placeholder="发表评论"></textarea></div>
<button class="btn btn-brand" type="submit">评论</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}创建管理员{% endblock %}
{% block content %}
<h3 class="mb-3">首次使用:创建管理员</h3>
<form class="card p-3" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">邮箱</label><input class="form-control" name="email" type="email" required></div>
<div class="mb-3"><label class="form-label">用户名</label><input class="form-control" name="username" required></div>
<div class="mb-3"><label class="form-label">密码</label><input class="form-control" name="password" type="password" required></div>
<div class="mb-3"><label class="form-label">确认密码</label><input class="form-control" name="confirm" type="password" required></div>
<button class="btn btn-brand" type="submit">创建管理员</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}编辑资料{% endblock %}
{% block content %}
<h3>编辑资料</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"><label class="form-label">简介</label><textarea class="form-control" name="bio"></textarea></div>
<div class="mb-3"><label class="form-label">年级</label><input class="form-control" name="grade"></div>
<div class="mb-3"><label class="form-label">班级</label><input class="form-control" name="class_name"></div>
<button class="btn btn-primary" type="submit">保存</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}通知{% endblock %}
{% block content %}
<h3>通知</h3>
<ul>
{% for n in items %}
<li>{{ n.type }} {{ n.created_at }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}主页{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h3>{{ user.username }}</h3>
{% if current_user.is_authenticated and current_user.id != user.id %}
<a class="btn btn-outline-primary" href="{{ url_for('follows.follow', user_id=user.id) }}">关注</a>
{% endif %}
</div>
<p>{{ user.profile.bio }}</p>
<hr>
<h5>作品</h5>
<div class="row">
{% for p in user.posts %}
<div class="col-md-4">
<a href="{{ url_for('posts.detail', post_id=p.id) }}">{{ p.title }}</a>
</div>
{% endfor %}
</div>
{% endblock %}

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask==3.0.0
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
WTForms==3.1.2
Pillow==10.4.0
python-dotenv==1.0.1

6
run.py Normal file
View File

@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)