commit 63db6a0815068ddd910acd50a6676ebf81a8af79 Author: AnthonyDuan Date: Sun Dec 7 10:53:52 2025 +0800 feat: 初始版本,圆角主题与首次管理员引导 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f1e6da --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.venv/ +/uploads/ +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.db +.DS_Store +Thumbs.db diff --git a/.trae/documents/UI圆润美化与首次访问管理员引导实施计划.md b/.trae/documents/UI圆润美化与首次访问管理员引导实施计划.md new file mode 100644 index 0000000..2e6901d --- /dev/null +++ b/.trae/documents/UI圆润美化与首次访问管理员引导实施计划.md @@ -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. 手动验证:首次访问重定向 → 管理员创建 → 登录 → 浏览发现/关注/发布/活动/后台 + +--- +请确认是否按此方案执行,我将开始改造样式并实现首次访问管理员引导。 \ No newline at end of file diff --git a/.trae/documents/泸州高中摄影社论坛(Flask)实现计划.md b/.trae/documents/泸州高中摄影社论坛(Flask)实现计划.md new file mode 100644 index 0000000..f348cfd --- /dev/null +++ b/.trae/documents/泸州高中摄影社论坛(Flask)实现计划.md @@ -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/`,`PUT /api/users/`(资料) +- `POST /api/posts`,`PUT /api/posts/`,`DELETE /api/posts/`,`GET /api/posts/` +- `POST /api/posts//images`(追加图片),`DELETE /api/posts//images/` +- `POST /api/posts//comments`,`GET /api/posts//comments`,`DELETE /api/comments/` +- `POST /api/users//follow`,`DELETE /api/users//follow` +- `GET /api/feed/discover`,`GET /api/feed/following` +- `GET /api/activities`,`GET /api/activities/` +- `POST /api/activities//submit`(multipart,多图) +- 管理员: + - `GET /admin/reviews/users`,`POST /admin/reviews/users//approve|reject` + - `GET /admin/reviews/posts`,`POST /admin/reviews/posts//approve|reject` + - `POST /admin/activities`,`PUT /admin/activities/`,`POST /admin/activities//publish|close` + - `GET /admin/reviews/submissions`,`POST /admin/reviews/submissions//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. 通知与优化、测试覆盖与部署 + +--- +请确认是否按此方案开始实现,或指出需要调整的部分。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4734350 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# 泸州高中摄影社论坛 + +- 运行:`pip install -r requirements.txt`,`python run.py` +- 首次访问:将自动跳转至 `/setup/admin` 引导页创建管理员(无需命令行) +- 默认开发数据库:`SQLite`,上传目录:`uploads/` +- 入口:`http://127.0.0.1:5000/` + +## 主要功能 +- 注册上传身份照,管理员审核 +- 发布作品(多图),公开需审核 +- 评论、关注、发现与关注流 +- 活动创建与投稿审核 +- 管理后台与审核日志 +- 通知提醒审核结果 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..de2bbef --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/blueprints/activities.py b/app/blueprints/activities.py new file mode 100644 index 0000000..9421def --- /dev/null +++ b/app/blueprints/activities.py @@ -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("/") +def detail(act_id): + act = Activity.query.get_or_404(act_id) + return render_template("activities/detail.html", activity=act) + +@bp.route("//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) diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py new file mode 100644 index 0000000..4b268b6 --- /dev/null +++ b/app/blueprints/admin.py @@ -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//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//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//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//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//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//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//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//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")) diff --git a/app/blueprints/auth.py b/app/blueprints/auth.py new file mode 100644 index 0000000..8099014 --- /dev/null +++ b/app/blueprints/auth.py @@ -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")) diff --git a/app/blueprints/comments.py b/app/blueprints/comments.py new file mode 100644 index 0000000..2c6b0c3 --- /dev/null +++ b/app/blueprints/comments.py @@ -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/", 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)) diff --git a/app/blueprints/feed.py b/app/blueprints/feed.py new file mode 100644 index 0000000..633204c --- /dev/null +++ b/app/blueprints/feed.py @@ -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) diff --git a/app/blueprints/follows.py b/app/blueprints/follows.py new file mode 100644 index 0000000..3dd1448 --- /dev/null +++ b/app/blueprints/follows.py @@ -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/") +@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/") +@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)) diff --git a/app/blueprints/main.py b/app/blueprints/main.py new file mode 100644 index 0000000..33a691a --- /dev/null +++ b/app/blueprints/main.py @@ -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/") +def uploads(filename): + return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) diff --git a/app/blueprints/posts.py b/app/blueprints/posts.py new file mode 100644 index 0000000..61ac635 --- /dev/null +++ b/app/blueprints/posts.py @@ -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("/") +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) diff --git a/app/blueprints/setup.py b/app/blueprints/setup.py new file mode 100644 index 0000000..d1e8446 --- /dev/null +++ b/app/blueprints/setup.py @@ -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") diff --git a/app/blueprints/users.py b/app/blueprints/users.py new file mode 100644 index 0000000..7ee3ace --- /dev/null +++ b/app/blueprints/users.py @@ -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("/") +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) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..2852c91 --- /dev/null +++ b/app/cli.py @@ -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() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9488227 --- /dev/null +++ b/app/config.py @@ -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"} diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..7f9fbd2 --- /dev/null +++ b/app/extensions.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a5b4e87 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/services/images.py b/app/services/images.py new file mode 100644 index 0000000..d4ee746 --- /dev/null +++ b/app/services/images.py @@ -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) diff --git a/app/services/notify.py b/app/services/notify.py new file mode 100644 index 0000000..c317ba2 --- /dev/null +++ b/app/services/notify.py @@ -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) diff --git a/app/static/theme.css b/app/static/theme.css new file mode 100644 index 0000000..e84aff0 --- /dev/null +++ b/app/static/theme.css @@ -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; } diff --git a/app/templates/activities/detail.html b/app/templates/activities/detail.html new file mode 100644 index 0000000..e7d3136 --- /dev/null +++ b/app/templates/activities/detail.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block title %}活动详情{% endblock %} +{% block content %} +

{{ activity.title }}

+

{{ activity.description }}

+投稿 +{% endblock %} diff --git a/app/templates/activities/list.html b/app/templates/activities/list.html new file mode 100644 index 0000000..c4843cf --- /dev/null +++ b/app/templates/activities/list.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} +{% block title %}活动{% endblock %} +{% block content %} +

活动

+ +{% endblock %} diff --git a/app/templates/activities/submit.html b/app/templates/activities/submit.html new file mode 100644 index 0000000..34ec0f5 --- /dev/null +++ b/app/templates/activities/submit.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} +{% block title %}投稿{% endblock %} +{% block content %} +

向 {{ activity.title }} 投稿

+
+ +
+ +
+{% endblock %} diff --git a/app/templates/admin/activities.html b/app/templates/admin/activities.html new file mode 100644 index 0000000..8e31dcc --- /dev/null +++ b/app/templates/admin/activities.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block title %}活动管理{% endblock %} +{% block content %} +

活动管理

+
+ +
+
+
+ +
+ + + {% for a in activities %} + + + + + + {% endfor %} +
标题状态操作
{{ a.title }}{{ a.status.value }} +
+ + +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..9dcad17 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block title %}管理员{% endblock %} +{% block content %} +

管理员仪表盘

+
+
待审核注册:{{ pending_users }}
+
待审核作品:{{ pending_posts }}
+
待审核投稿:{{ pending_subs }}
+
+
+注册审核 +作品审核 +投稿审核 +活动管理 +{% endblock %} diff --git a/app/templates/admin/reviews_posts.html b/app/templates/admin/reviews_posts.html new file mode 100644 index 0000000..2e107bb --- /dev/null +++ b/app/templates/admin/reviews_posts.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}作品审核{% endblock %} +{% block content %} +

作品审核

+ + + {% for p in posts %} + + + + + + {% endfor %} +
标题作者操作
{{ p.title }}{{ p.user.username }} +
+ + +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/reviews_submissions.html b/app/templates/admin/reviews_submissions.html new file mode 100644 index 0000000..c0a877b --- /dev/null +++ b/app/templates/admin/reviews_submissions.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}投稿审核{% endblock %} +{% block content %} +

投稿审核

+ + + {% for s in submissions %} + + + + + + {% endfor %} +
活动用户操作
{{ s.activity.title }}{{ s.user_id }} +
+ + +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/reviews_users.html b/app/templates/admin/reviews_users.html new file mode 100644 index 0000000..565713d --- /dev/null +++ b/app/templates/admin/reviews_users.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}注册审核{% endblock %} +{% block content %} +

注册审核

+ + + {% for u in users %} + + + + + + {% endfor %} +
用户名邮箱操作
{{ u.username }}{{ u.email }} +
+ + +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..2932e25 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% block title %}登录{% endblock %} +{% block content %} +

登录

+
+ +
+
+ +
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..9096af1 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}注册{% endblock %} +{% block content %} +

注册

+
+ +
+
+
+
+ +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..8270e37 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,43 @@ + + + + + + {% block title %}摄影社论坛{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
{{ messages[0] }}
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/feed/discover.html b/app/templates/feed/discover.html new file mode 100644 index 0000000..f09ebd1 --- /dev/null +++ b/app/templates/feed/discover.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block title %}发现{% endblock %} +{% block content %} +

发现

+
+ {% for p in posts %} +
+ {% set first = p.images[0] if p.images %} + {% if first %} + + {{ p.title }} + + {% endif %} + +
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/feed/following.html b/app/templates/feed/following.html new file mode 100644 index 0000000..db024a9 --- /dev/null +++ b/app/templates/feed/following.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block title %}关注{% endblock %} +{% block content %} +

关注

+
+ {% for p in posts %} +
+ {% set first = p.images[0] if p.images %} + {% if first %} + + {{ p.title }} + + {% endif %} + +
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/posts/create.html b/app/templates/posts/create.html new file mode 100644 index 0000000..b440c39 --- /dev/null +++ b/app/templates/posts/create.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% block title %}发布作品{% endblock %} +{% block content %} +

发布作品

+
+ +
+
+
+ +
+
+ +
+{% endblock %} diff --git a/app/templates/posts/detail.html b/app/templates/posts/detail.html new file mode 100644 index 0000000..bfa8424 --- /dev/null +++ b/app/templates/posts/detail.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block title %}作品详情{% endblock %} +{% block content %} +
+
+

{{ post.title }}

+

{{ post.description }}

+ {{ post.visibility.value }} +
+
+
+ {% for img in post.images %} +
+ +
+ {% endfor %} +
+
+
+
+
+ +
+ +
+{% endblock %} diff --git a/app/templates/setup/admin.html b/app/templates/setup/admin.html new file mode 100644 index 0000000..f9a51b7 --- /dev/null +++ b/app/templates/setup/admin.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}创建管理员{% endblock %} +{% block content %} +

首次使用:创建管理员

+
+ +
+
+
+
+ +
+{% endblock %} diff --git a/app/templates/users/edit.html b/app/templates/users/edit.html new file mode 100644 index 0000000..8529b4a --- /dev/null +++ b/app/templates/users/edit.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}编辑资料{% endblock %} +{% block content %} +

编辑资料

+
+ +
+
+
+ +
+{% endblock %} diff --git a/app/templates/users/notifications.html b/app/templates/users/notifications.html new file mode 100644 index 0000000..890d9aa --- /dev/null +++ b/app/templates/users/notifications.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} +{% block title %}通知{% endblock %} +{% block content %} +

通知

+
    + {% for n in items %} +
  • {{ n.type }} {{ n.created_at }}
  • + {% endfor %} +
+{% endblock %} diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html new file mode 100644 index 0000000..f9d3b9a --- /dev/null +++ b/app/templates/users/profile.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block title %}主页{% endblock %} +{% block content %} +
+

{{ user.username }}

+ {% if current_user.is_authenticated and current_user.id != user.id %} + 关注 + {% endif %} +
+

{{ user.profile.bio }}

+
+
作品
+
+ {% for p in user.posts %} + + {% endfor %} +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a2b711c --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..488dae9 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True)