feat: 初始版本,圆角主题与首次管理员引导
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/.venv/
|
||||
/uploads/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
68
.trae/documents/UI圆润美化与首次访问管理员引导实施计划.md
Normal file
68
.trae/documents/UI圆润美化与首次访问管理员引导实施计划.md
Normal file
@@ -0,0 +1,68 @@
|
||||
## 目标
|
||||
- 全站视觉风格更圆润、现代、美观,统一色彩与控件样式
|
||||
- 首次访问强制进入“创建管理员”引导页,完成后正常使用网站
|
||||
|
||||
## 视觉与交互设计
|
||||
- 基调:浅色主题,品牌主色(如紫/蓝),高对比文本,柔和阴影
|
||||
- 圆角:统一使用大圆角(8–16px)用于卡片、输入框、按钮、图片
|
||||
- 卡片化:发现/关注/活动列表与作品详情采用卡片布局,留白更舒适
|
||||
- 图片网格:统一比例裁切(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. 手动验证:首次访问重定向 → 管理员创建 → 登录 → 浏览发现/关注/发布/活动/后台
|
||||
|
||||
---
|
||||
请确认是否按此方案执行,我将开始改造样式并实现首次访问管理员引导。
|
||||
125
.trae/documents/泸州高中摄影社论坛(Flask)实现计划.md
Normal file
125
.trae/documents/泸州高中摄影社论坛(Flask)实现计划.md
Normal 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`:id,email,username,password_hash,role,status(pending/approved/rejected),identity_photo_path,created_at
|
||||
- `profiles`:id,user_id,avatar_path,bio,grade,class_name,links,updated_at
|
||||
- `posts`:id,user_id,title,description,visibility(public/followers/private),status(pending/approved/rejected),created_at,published_at
|
||||
- `post_images`:id,post_id,original_path,web_path,thumb_path,exif_json,order_index
|
||||
- `comments`:id,post_id,user_id,body,status(active/removed),created_at
|
||||
- `follows`:follower_id,followee_id,created_at(唯一索引: follower_id+followee_id)
|
||||
- `activities`:id,title,theme,description,start_at,end_at,status(draft/published/closed),created_at
|
||||
- `activity_submissions`:id,activity_id,user_id,status(pending/approved/rejected),created_at
|
||||
- `submission_images`:id,submission_id,original_path,web_path,thumb_path,exif_json,order_index
|
||||
- `likes`(可选):id,post_id,user_id,created_at
|
||||
- `notifications`:id,user_id,type,payload_json,read_at,created_at
|
||||
- `review_logs`:id,target_type(user/post/submission),target_id,admin_id,action(approve/reject),reason,created_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
14
README.md
Normal 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
56
app/__init__.py
Normal 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
|
||||
37
app/blueprints/activities.py
Normal file
37
app/blueprints/activities.py
Normal 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
137
app/blueprints/admin.py
Normal 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
61
app/blueprints/auth.py
Normal 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"))
|
||||
17
app/blueprints/comments.py
Normal file
17
app/blueprints/comments.py
Normal 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
23
app/blueprints/feed.py
Normal 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
25
app/blueprints/follows.py
Normal 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
11
app/blueprints/main.py
Normal 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
53
app/blueprints/posts.py
Normal 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
39
app/blueprints/setup.py
Normal 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
29
app/blueprints/users.py
Normal 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
18
app/cli.py
Normal 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
9
app/config.py
Normal 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
9
app/extensions.py
Normal 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
129
app/models.py
Normal 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
28
app/services/images.py
Normal 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
6
app/services/notify.py
Normal 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
24
app/static/theme.css
Normal 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; }
|
||||
7
app/templates/activities/detail.html
Normal file
7
app/templates/activities/detail.html
Normal 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 %}
|
||||
10
app/templates/activities/list.html
Normal file
10
app/templates/activities/list.html
Normal 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 %}
|
||||
10
app/templates/activities/submit.html
Normal file
10
app/templates/activities/submit.html
Normal 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 %}
|
||||
31
app/templates/admin/activities.html
Normal file
31
app/templates/admin/activities.html
Normal 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 %}
|
||||
15
app/templates/admin/dashboard.html
Normal file
15
app/templates/admin/dashboard.html
Normal 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 %}
|
||||
24
app/templates/admin/reviews_posts.html
Normal file
24
app/templates/admin/reviews_posts.html
Normal 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 %}
|
||||
24
app/templates/admin/reviews_submissions.html
Normal file
24
app/templates/admin/reviews_submissions.html
Normal 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 %}
|
||||
24
app/templates/admin/reviews_users.html
Normal file
24
app/templates/admin/reviews_users.html
Normal 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 %}
|
||||
11
app/templates/auth/login.html
Normal file
11
app/templates/auth/login.html
Normal 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 %}
|
||||
13
app/templates/auth/register.html
Normal file
13
app/templates/auth/register.html
Normal 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
43
app/templates/base.html
Normal 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>
|
||||
20
app/templates/feed/discover.html
Normal file
20
app/templates/feed/discover.html
Normal 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 %}
|
||||
20
app/templates/feed/following.html
Normal file
20
app/templates/feed/following.html
Normal 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 %}
|
||||
19
app/templates/posts/create.html
Normal file
19
app/templates/posts/create.html
Normal 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 %}
|
||||
26
app/templates/posts/detail.html
Normal file
26
app/templates/posts/detail.html
Normal 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 %}
|
||||
13
app/templates/setup/admin.html
Normal file
13
app/templates/setup/admin.html
Normal 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 %}
|
||||
12
app/templates/users/edit.html
Normal file
12
app/templates/users/edit.html
Normal 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 %}
|
||||
10
app/templates/users/notifications.html
Normal file
10
app/templates/users/notifications.html
Normal 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 %}
|
||||
20
app/templates/users/profile.html
Normal file
20
app/templates/users/profile.html
Normal 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
8
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user