Complete project files including setup.sh

This commit is contained in:
2026-01-11 14:17:26 +08:00
commit 0bbe394cb5
29 changed files with 3060 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
*.db
*.sqlite3
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
.DS_Store
.env
uploads/
!uploads/.keep
admin_workflow_test_*.webp
*.log

136
README.md Normal file
View File

@@ -0,0 +1,136 @@
# 泸州高中摄影社论坛 📷
一个基于 Flask 的摄影社交平台,类似 Twitter/X专为泸州高中摄影社设计。
## ✨ 主要功能
- **用户注册与审核** - 注册时需上传学生证照片,管理员审核通过后才能使用
- **照片分享** - 发布摄影作品(照片+描述),支持评论互动
- **帖子审核** - 所有帖子需经管理员审核后才能公开展示
- **社交功能** - 关注用户,查看粉丝列表和关注列表
- **管理面板** - 管理员可审核用户注册和帖子发布
- **现代化UI** - 深色主题,渐变色,流畅动画
## 🚀 快速开始
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 运行应用
```bash
python app.py
```
应用将在 http://localhost:5000 启动。
### 3. 管理员设置
- 默认管理员账号:`admin`
- 默认密码:`adminlg`
- 首次登录后系统会要求修改密码。
- 登录后可在管理面板创建其他管理员。
## 📂 项目结构
```
Luntan/
├── app.py # Flask 应用主文件
├── models.py # 数据库模型
├── config.py # 配置文件
├── create_admin.py # 创建管理员脚本
├── requirements.txt # 依赖列表
├── routes/ # 路由蓝图
│ ├── auth.py # 认证路由
│ ├── posts.py # 帖子路由
│ ├── users.py # 用户路由
│ └── admin.py # 管理员路由
├── templates/ # HTML 模板
│ ├── base.html # 基础模板
│ ├── index.html # 首页
│ ├── register.html # 注册页
│ ├── login.html # 登录页
│ ├── profile.html # 个人主页
│ ├── create_post.html # 发帖页
│ ├── post_detail.html # 帖子详情
│ ├── admin/ # 管理员页面
│ └── errors/ # 错误页面
├── static/ # 静态文件
│ ├── css/
│ │ └── style.css # 样式文件
│ └── js/
│ └── main.js # JavaScript 文件
└── uploads/ # 上传文件目录
├── student_ids/ # 学生证照片
└── posts/ # 帖子图片
```
## 📖 使用说明
### 用户注册流程
1. 访问注册页面
2. 填写用户名、邮箱、密码
3. 上传学生证照片
4. 提交后等待管理员审核
5. 审核通过后即可登录
### 发布作品流程
1. 登录已审核账号
2. 点击"发帖"
3. 上传照片并添加描述
4. 提交后等待管理员审核
5. 审核通过后作品公开展示
### 管理员审核流程
1. 使用管理员账号登录
2. 访问管理面板
3. 审核待处理的用户注册和帖子
4. 批准或拒绝申请
## 🔧 配置
`config.py` 中可以配置:
- **SECRET_KEY** - 应用密钥(生产环境请更换)
- **DATABASE_URL** - 数据库连接地址
- **UPLOAD_FOLDER** - 文件上传目录
- **MAX_CONTENT_LENGTH** - 最大上传文件大小(默认 16MB
- **POSTS_PER_PAGE** - 每页显示的帖子数量
## 🎨 技术栈
- **后端**: Flask 3.0 + SQLAlchemy
- **前端**: HTML5 + CSS3 + JavaScript
- **数据库**: SQLite开发
- **认证**: Flask-Login
- **文件上传**: Werkzeug
## 🌟 特色设计
- **深色主题** - 护眼的暗色系设计
- **渐变色** - 现代化的配色方案
- **微动效** - 流畅的过渡动画和交互效果
- **响应式布局** - 适配各种屏幕尺寸
- **拖拽上传** - 支持拖拽上传图片
- **实时预览** - 上传前即可预览图片
## 📝 注意事项
- 首次运行前必须先创建管理员账号
- 上传的文件存储在 `uploads/` 目录
- 生产环境部署时请更换 SECRET_KEY
- 建议生产环境使用 PostgreSQL 或 MySQL
- 建议使用 nginx + gunicorn 部署
## 📄 许可证
MIT License
---
**泸州高中摄影社** - 分享精彩瞬间 📸

82
app.py Normal file
View File

@@ -0,0 +1,82 @@
import os
from flask import Flask, render_template
from flask_login import LoginManager
from config import Config
from models import db, User
# 初始化Flask扩展
login_manager = LoginManager()
def create_app(config_class=Config):
"""应用工厂函数"""
app = Flask(__name__)
app.config.from_object(config_class)
# 初始化扩展
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = '请先登录'
# 初始化配置
config_class.init_app(app)
# 注册蓝图
from routes.auth import auth_bp
from routes.posts import posts_bp
from routes.users import users_bp
from routes.new_admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(posts_bp)
app.register_blueprint(users_bp)
app.register_blueprint(admin_bp)
# 创建数据库表
with app.app_context():
db.create_all()
# 创建默认管理员账号(如果不存在)
admin = User.query.filter_by(username='admin').first()
if not admin:
admin = User(
username='admin',
email='admin@luzhou-photo.com',
is_approved=True,
is_admin=True,
password_changed=False # 首次登录需修改密码
)
admin.set_password('adminlg')
db.session.add(admin)
db.session.commit()
print("✅ 默认管理员账号已创建: admin / adminlg")
# 错误处理
@app.errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
# 路由:提供上传文件访问
@app.route('/uploads/<path:filename>')
def uploaded_file(filename):
from flask import send_from_directory
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
return app
@login_manager.user_loader
def load_user(user_id):
"""加载用户"""
return User.query.get(int(user_id))
if __name__ == '__main__':
app = create_app()
app.run(debug=True, host='0.0.0.0', port=5000)

33
config.py Normal file
View File

@@ -0,0 +1,33 @@
import os
from datetime import timedelta
class Config:
"""应用配置类"""
# 基础配置
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 数据库配置
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(BASE_DIR, 'forum.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 文件上传配置
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB 最大文件大小
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 会话配置
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# 分页配置
POSTS_PER_PAGE = 20
@staticmethod
def init_app(app):
"""初始化应用配置"""
# 确保上传目录存在
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'student_ids'), exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'posts'), exist_ok=True)

150
models.py Normal file
View File

@@ -0,0 +1,150 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
db = SQLAlchemy()
class User(UserMixin, db.Model):
"""用户模型"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
# 学生身份验证
student_id_photo = db.Column(db.String(255)) # 学生证照片路径
is_approved = db.Column(db.Boolean, default=False) # 是否通过审核
# 权限
is_admin = db.Column(db.Boolean, default=False)
password_changed = db.Column(db.Boolean, default=True) # 管理员首次登录需修改密码
# 个人信息
bio = db.Column(db.Text)
avatar = db.Column(db.String(255))
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
# 关系
posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
# 关注关系
following = db.relationship(
'Follow',
foreign_keys='Follow.follower_id',
backref='follower',
lazy='dynamic',
cascade='all, delete-orphan'
)
followers = db.relationship(
'Follow',
foreign_keys='Follow.following_id',
backref='following',
lazy='dynamic',
cascade='all, delete-orphan'
)
def set_password(self, password):
"""设置密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def is_following(self, user):
"""检查是否关注了某用户"""
return self.following.filter_by(following_id=user.id).first() is not None
def follow(self, user):
"""关注用户"""
if not self.is_following(user):
follow = Follow(follower_id=self.id, following_id=user.id)
db.session.add(follow)
def unfollow(self, user):
"""取消关注"""
follow = self.following.filter_by(following_id=user.id).first()
if follow:
db.session.delete(follow)
def get_follower_count(self):
"""获取粉丝数"""
return self.followers.count()
def get_following_count(self):
"""获取关注数"""
return self.following.count()
def __repr__(self):
return f'<User {self.username}>'
class Post(db.Model):
"""帖子模型"""
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# 内容
image_path = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text)
# 审核状态
is_approved = db.Column(db.Boolean, default=False, index=True)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
# 关系
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
def get_comment_count(self):
"""获取评论数"""
return self.comments.count()
def __repr__(self):
return f'<Post {self.id}>'
class Comment(db.Model):
"""评论模型"""
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# 内容
content = db.Column(db.Text, nullable=False)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
def __repr__(self):
return f'<Comment {self.id}>'
class Follow(db.Model):
"""关注关系模型"""
__tablename__ = 'follows'
id = db.Column(db.Integer, primary_key=True)
follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
following_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 确保同一用户不能重复关注
__table_args__ = (
db.UniqueConstraint('follower_id', 'following_id', name='unique_follow'),
)
def __repr__(self):
return f'<Follow {self.follower_id} -> {self.following_id}>'

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
Pillow==10.1.0
Werkzeug==3.0.1
WTForms==3.1.1
python-dotenv==1.0.0

162
routes/auth.py Normal file
View File

@@ -0,0 +1,162 @@
import os
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.utils import secure_filename
from models import db, User
from config import Config
auth_bp = Blueprint('auth', __name__)
def allowed_file(filename):
"""检查文件扩展名是否允许"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
"""用户注册"""
if current_user.is_authenticated:
return redirect(url_for('posts.index'))
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
student_id_photo = request.files.get('student_id_photo')
# 验证输入
if not all([username, email, password, confirm_password, student_id_photo]):
flash('请填写所有字段并上传学生证照片', 'error')
return render_template('register.html')
if password != confirm_password:
flash('两次输入的密码不一致', 'error')
return render_template('register.html')
if len(password) < 6:
flash('密码长度至少为6位', 'error')
return render_template('register.html')
# 检查用户名和邮箱是否已存在
if User.query.filter_by(username=username).first():
flash('用户名已被注册', 'error')
return render_template('register.html')
if User.query.filter_by(email=email).first():
flash('邮箱已被注册', 'error')
return render_template('register.html')
# 保存学生证照片
if student_id_photo and allowed_file(student_id_photo.filename):
filename = secure_filename(f"{username}_{student_id_photo.filename}")
filepath = os.path.join(Config.UPLOAD_FOLDER, 'student_ids', filename)
student_id_photo.save(filepath)
# 创建用户
user = User(
username=username,
email=email,
student_id_photo=f'student_ids/{filename}',
is_approved=False
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('注册成功!请等待管理员审核您的学生身份', 'success')
return redirect(url_for('auth.login'))
else:
flash('请上传有效的图片文件(支持 PNG, JPG, JPEG, GIF, WEBP', 'error')
return render_template('register.html')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""用户登录"""
if current_user.is_authenticated:
return redirect(url_for('posts.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember', False)
if not all([username, password]):
flash('请填写用户名和密码', 'error')
return render_template('login.html')
user = User.query.filter_by(username=username).first()
if user is None or not user.check_password(password):
flash('用户名或密码错误', 'error')
return render_template('login.html')
if not user.is_approved:
flash('您的账号正在等待管理员审核', 'warning')
return render_template('login.html')
login_user(user, remember=remember)
# 检查管理员是否需要修改密码
if user.is_admin and not user.password_changed:
flash('首次登录,请修改密码', 'warning')
return redirect(url_for('auth.change_password'))
flash(f'欢迎回来,{user.username}', 'success')
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('posts.index'))
return render_template('login.html')
@auth_bp.route('/logout')
def logout():
"""用户登出"""
logout_user()
flash('您已成功退出登录', 'info')
return redirect(url_for('posts.index'))
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
"""修改密码"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not all([current_password, new_password, confirm_password]):
flash('请填写所有字段', 'error')
return render_template('change_password.html')
# 验证当前密码
if not current_user.check_password(current_password):
flash('当前密码错误', 'error')
return render_template('change_password.html')
# 验证新密码
if new_password != confirm_password:
flash('两次输入的新密码不一致', 'error')
return render_template('change_password.html')
if len(new_password) < 6:
flash('密码长度至少为6位', 'error')
return render_template('change_password.html')
# 更新密码
current_user.set_password(new_password)
current_user.password_changed = True
db.session.commit()
flash('密码修改成功', 'success')
return redirect(url_for('posts.index'))
return render_template('change_password.html')

178
routes/new_admin.py Normal file
View File

@@ -0,0 +1,178 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import current_user
from functools import wraps
from models import db, User, Post
# 使用 new_admin 以避免任何命名冲突或缓存问题
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""管理员权限装饰器 - 直接定义在此文件中以避免导入问题"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
if not current_user.is_admin:
flash('需要管理员权限', 'error')
return redirect(url_for('posts.index'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@admin_required
def dashboard():
"""管理员仪表板"""
pending_users = User.query.filter_by(is_approved=False).count()
pending_posts = Post.query.filter_by(is_approved=False).count()
return render_template('admin/dashboard.html',
pending_users=pending_users,
pending_posts=pending_posts)
@admin_bp.route('/users')
@admin_required
def users():
"""待审核用户列表"""
pending_users = User.query.filter_by(is_approved=False).order_by(User.created_at.desc()).all()
approved_users = User.query.filter_by(is_approved=True).order_by(User.created_at.desc()).limit(20).all()
return render_template('admin/users.html',
pending_users=pending_users,
approved_users=approved_users)
@admin_bp.route('/users/<int:user_id>/approve', methods=['POST'])
@admin_required
def approve_user(user_id):
"""批准用户注册"""
user = User.query.get_or_404(user_id)
if user.is_approved:
flash('该用户已经通过审核', 'info')
return redirect(url_for('admin.users'))
user.is_approved = True
db.session.commit()
flash(f'已批准用户 {user.username} 的注册', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/users/<int:user_id>/reject', methods=['POST'])
@admin_required
def reject_user(user_id):
"""拒绝用户注册"""
user = User.query.get_or_404(user_id)
if user.is_approved:
flash('该用户已经通过审核,无法拒绝', 'error')
return redirect(url_for('admin.users'))
# 删除用户及其相关数据
db.session.delete(user)
db.session.commit()
flash(f'已拒绝用户 {user.username} 的注册', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/posts')
@admin_required
def posts():
"""待审核帖子列表"""
pending_posts = Post.query.filter_by(is_approved=False).order_by(Post.created_at.desc()).all()
approved_posts = Post.query.filter_by(is_approved=True).order_by(Post.created_at.desc()).limit(20).all()
return render_template('admin/posts.html',
pending_posts=pending_posts,
approved_posts=approved_posts)
@admin_bp.route('/posts/<int:post_id>/approve', methods=['POST'])
@admin_required
def approve_post(post_id):
"""批准帖子发布"""
post = Post.query.get_or_404(post_id)
if post.is_approved:
flash('该帖子已经通过审核', 'info')
return redirect(url_for('admin.posts'))
post.is_approved = True
db.session.commit()
flash('已批准该帖子发布', 'success')
return redirect(url_for('admin.posts'))
@admin_bp.route('/posts/<int:post_id>/reject', methods=['POST'])
@admin_required
def reject_post(post_id):
"""拒绝帖子发布"""
post = Post.query.get_or_404(post_id)
if post.is_approved:
flash('该帖子已经通过审核,无法拒绝', 'error')
return redirect(url_for('admin.posts'))
# 删除帖子
db.session.delete(post)
db.session.commit()
flash('已拒绝该帖子发布', 'success')
return redirect(url_for('admin.posts'))
@admin_bp.route('/create-admin', methods=['GET', 'POST'])
@admin_required
def create_admin():
"""创建新管理员"""
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if not all([username, email, password, confirm_password]):
flash('请填写所有字段', 'error')
return render_template('admin/create_admin.html')
if password != confirm_password:
flash('两次输入的密码不一致', 'error')
return render_template('admin/create_admin.html')
if len(password) < 6:
flash('密码长度至少为6位', 'error')
return render_template('admin/create_admin.html')
# 检查用户名和邮箱是否已存在
if User.query.filter_by(username=username).first():
flash('用户名已被使用', 'error')
return render_template('admin/create_admin.html')
if User.query.filter_by(email=email).first():
flash('邮箱已被使用', 'error')
return render_template('admin/create_admin.html')
# 创建新管理员
new_admin = User(
username=username,
email=email,
is_approved=True,
is_admin=True,
password_changed=True # 新建管理员默认认为已知晓密码,或者后续再改
)
new_admin.set_password(password)
db.session.add(new_admin)
db.session.commit()
flash(f'成功创建管理员账号: {username}', 'success')
return redirect(url_for('admin.dashboard'))
return render_template('admin/create_admin.html')

126
routes/posts.py Normal file
View File

@@ -0,0 +1,126 @@
import os
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 models import db, Post, Comment
from config import Config
posts_bp = Blueprint('posts', __name__)
def allowed_file(filename):
"""检查文件扩展名是否允许"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@posts_bp.route('/')
def index():
"""首页 - 显示已审核的帖子流"""
page = request.args.get('page', 1, type=int)
# 只显示已审核的帖子
pagination = Post.query.filter_by(is_approved=True)\
.order_by(Post.created_at.desc())\
.paginate(page=page, per_page=current_app.config['POSTS_PER_PAGE'], error_out=False)
posts = pagination.items
return render_template('index.html', posts=posts, pagination=pagination)
@posts_bp.route('/post/create', methods=['GET', 'POST'])
@login_required
def create_post():
"""创建帖子"""
if not current_user.is_approved:
flash('您的账号尚未通过审核,无法发帖', 'error')
return redirect(url_for('posts.index'))
if request.method == 'POST':
description = request.form.get('description')
image = request.files.get('image')
if not image:
flash('请上传照片', 'error')
return render_template('create_post.html')
if not allowed_file(image.filename):
flash('请上传有效的图片文件(支持 PNG, JPG, JPEG, GIF, WEBP', 'error')
return render_template('create_post.html')
# 保存图片
filename = secure_filename(f"{current_user.username}_{image.filename}")
# 添加时间戳避免重名
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
filename = f"{timestamp}_{filename}"
filepath = os.path.join(Config.UPLOAD_FOLDER, 'posts', filename)
image.save(filepath)
# 创建帖子
post = Post(
user_id=current_user.id,
image_path=f'posts/{filename}',
description=description,
is_approved=False # 需要审核
)
db.session.add(post)
db.session.commit()
flash('帖子已提交,等待管理员审核', 'success')
return redirect(url_for('posts.index'))
return render_template('create_post.html')
@posts_bp.route('/post/<int:post_id>')
def post_detail(post_id):
"""帖子详情页"""
post = Post.query.get_or_404(post_id)
# 如果帖子未审核,只有作者和管理员可以查看
if not post.is_approved:
if not current_user.is_authenticated or \
(current_user.id != post.user_id and not current_user.is_admin):
flash('该帖子正在审核中', 'warning')
return redirect(url_for('posts.index'))
# 获取评论
comments = post.comments.order_by(Comment.created_at.desc()).all()
return render_template('post_detail.html', post=post, comments=comments)
@posts_bp.route('/post/<int:post_id>/comment', methods=['POST'])
@login_required
def add_comment(post_id):
"""添加评论"""
if not current_user.is_approved:
flash('您的账号尚未通过审核,无法评论', 'error')
return redirect(url_for('posts.post_detail', post_id=post_id))
post = Post.query.get_or_404(post_id)
if not post.is_approved:
flash('该帖子正在审核中,无法评论', 'error')
return redirect(url_for('posts.index'))
content = request.form.get('content')
if not content or not content.strip():
flash('评论内容不能为空', 'error')
return redirect(url_for('posts.post_detail', post_id=post_id))
comment = Comment(
post_id=post_id,
user_id=current_user.id,
content=content.strip()
)
db.session.add(comment)
db.session.commit()
flash('评论发表成功', 'success')
return redirect(url_for('posts.post_detail', post_id=post_id))

93
routes/users.py Normal file
View File

@@ -0,0 +1,93 @@
from flask import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_required, current_user
from models import db, User, Follow, Post
users_bp = Blueprint('users', __name__)
@users_bp.route('/user/<username>')
def profile(username):
"""用户个人主页"""
user = User.query.filter_by(username=username).first_or_404()
# 只显示已审核的帖子(除非是自己或管理员)
if current_user.is_authenticated and (current_user.id == user.id or current_user.is_admin):
posts = user.posts.order_by(Post.created_at.desc()).all()
else:
posts = user.posts.filter_by(is_approved=True).order_by(Post.created_at.desc()).all()
follower_count = user.get_follower_count()
following_count = user.get_following_count()
is_following = False
if current_user.is_authenticated:
is_following = current_user.is_following(user)
return render_template('profile.html',
user=user,
posts=posts,
follower_count=follower_count,
following_count=following_count,
is_following=is_following)
@users_bp.route('/user/<username>/follow', methods=['POST'])
@login_required
def follow(username):
"""关注用户"""
if not current_user.is_approved:
flash('您的账号尚未通过审核,无法关注', 'error')
return redirect(url_for('users.profile', username=username))
user = User.query.filter_by(username=username).first_or_404()
if user.id == current_user.id:
flash('不能关注自己', 'error')
return redirect(url_for('users.profile', username=username))
if current_user.is_following(user):
flash('您已经关注了该用户', 'info')
return redirect(url_for('users.profile', username=username))
current_user.follow(user)
db.session.commit()
flash(f'成功关注 {user.username}', 'success')
return redirect(url_for('users.profile', username=username))
@users_bp.route('/user/<username>/unfollow', methods=['POST'])
@login_required
def unfollow(username):
"""取消关注"""
user = User.query.filter_by(username=username).first_or_404()
if user.id == current_user.id:
flash('不能取消关注自己', 'error')
return redirect(url_for('users.profile', username=username))
if not current_user.is_following(user):
flash('您还没有关注该用户', 'info')
return redirect(url_for('users.profile', username=username))
current_user.unfollow(user)
db.session.commit()
flash(f'已取消关注 {user.username}', 'success')
return redirect(url_for('users.profile', username=username))
@users_bp.route('/user/<username>/followers')
def followers(username):
"""粉丝列表"""
user = User.query.filter_by(username=username).first_or_404()
followers = [follow.follower for follow in user.followers.all()]
return render_template('followers.html', user=user, followers=followers)
@users_bp.route('/user/<username>/following')
def following(username):
"""关注列表"""
user = User.query.filter_by(username=username).first_or_404()
following = [follow.following for follow in user.following.all()]
return render_template('following.html', user=user, following=following)

140
setup.sh Normal file
View File

@@ -0,0 +1,140 @@
#!/bin/bash
# 泸州高中摄影社论坛 - 一键部署脚本 (Ubuntu)
# 用法: sudo ./setup.sh
set -e
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 检查是否以root运行
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}请使用 sudo 运行此脚本${NC}"
exit 1
fi
echo -e "${GREEN}=== 开始部署流程 ===${NC}"
# 1. 更新系统并安装依赖
echo -e "${YELLOW}1. 更新系统并安装必要的软件包...${NC}"
apt-get update
# 安装 Python3, pip, venv, nginx
apt-get install -y python3 python3-pip python3-venv nginx git
# 2. 设置项目目录
echo -e "${YELLOW}2. 设置项目环境...${NC}"
# 获取当前脚本所在目录作为项目根目录
PROJECT_DIR=$(pwd)
VENV_DIR="$PROJECT_DIR/venv"
# 创建必要的上传目录
mkdir -p "$PROJECT_DIR/uploads/posts"
mkdir -p "$PROJECT_DIR/uploads/student_ids"
# 设置权限 (假设运行 Nginx 的也是当前用户或 www-data这里简单设置为当前文件夹所有者)
# 获取当前非root用户
REAL_USER=${SUDO_USER:-$(whoami)}
chown -R $REAL_USER:$REAL_USER "$PROJECT_DIR"
# 给 uploads 目录写入权限
chmod -R 755 "$PROJECT_DIR/uploads"
# 3. 创建虚拟环境并安装依赖
echo -e "${YELLOW}3. 配置 Python 虚拟环境...${NC}"
if [ ! -d "$VENV_DIR" ]; then
python3 -m venv "$VENV_DIR"
fi
# 激活虚拟环境并安装依赖
source "$VENV_DIR/bin/activate"
pip install --upgrade pip
# 确保安装了 gunicorn
pip install gunicorn
if [ -f "requirements.txt" ]; then
pip install -r requirements.txt
else
echo -e "${RED}错误: 未找到 requirements.txt${NC}"
exit 1
fi
# 4. 配置 Systemd 服务 (Gunicorn)
echo -e "${YELLOW}4. 配置 Gunicorn 服务...${NC}"
SERVICE_NAME="luntan"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
cat > $SERVICE_FILE <<EOF
[Unit]
Description=Gunicorn instance to serve Luntan Forum
After=network.target
[Service]
User=$REAL_USER
Group=www-data
WorkingDirectory=$PROJECT_DIR
Environment="PATH=$VENV_DIR/bin"
ExecStart=$VENV_DIR/bin/gunicorn --workers 3 --bind unix:luntan.sock -m 007 "app:create_app()"
[Install]
WantedBy=multi-user.target
EOF
# 启动并启用服务
systemctl daemon-reload
systemctl start $SERVICE_NAME
systemctl enable $SERVICE_NAME
# 5. 配置 Nginx
echo -e "${YELLOW}5. 配置 Nginx 反向代理...${NC}"
NGINX_CONF="/etc/nginx/sites-available/$SERVICE_NAME"
NGINX_LINK="/etc/nginx/sites-enabled/$SERVICE_NAME"
cat > $NGINX_CONF <<EOF
server {
listen 80;
server_name _; # 请在部署后修改为您的域名
location / {
include proxy_params;
proxy_pass http://unix:$PROJECT_DIR/luntan.sock;
}
location /static {
alias $PROJECT_DIR/static;
}
location /uploads {
alias $PROJECT_DIR/uploads;
}
client_max_body_size 16M;
}
EOF
# 链接配置
if [ -e "$NGINX_LINK" ]; then
rm "$NGINX_LINK"
fi
ln -s "$NGINX_CONF" "$NGINX_LINK"
# 移除默认配置(如果有)
if [ -e "/etc/nginx/sites-enabled/default" ]; then
rm "/etc/nginx/sites-enabled/default"
fi
# 测试并重启 Nginx
nginx -t
systemctl restart nginx
# 6. 初始化数据库
echo -e "${YELLOW}6. 初始化数据库...${NC}"
# 我们需要在虚拟环境中运行一个简单的脚本来触发 create_all
# 注意app.py 启动时会自动创建,所以只要服务启动了,数据库就会创建。
# 但为了保险,我们手动触发一次
PYTHON_SCRIPT="from app import create_app; from models import db; app = create_app(); app.app_context().push(); db.create_all(); print('Database initialized.')"
$VENV_DIR/bin/python -c "$PYTHON_SCRIPT"
echo -e "${GREEN}=== 部署完成! ===${NC}"
echo -e "您的应用现在应该可以通过服务器 IP 访问了。"
echo -e "默认管理员: admin / adminlg"

965
static/css/style.css Normal file
View File

@@ -0,0 +1,965 @@
/* ========== 全局变量和重置 ========== */
:root {
--primary-color: #6366f1;
--primary-dark: #4f46e5;
--secondary-color: #8b5cf6;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--bg-dark: #0f172a;
--bg-darker: #020617;
--bg-card: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border-color: #334155;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, var(--bg-darker) 0%, var(--bg-dark) 100%);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
/* ========== 导航栏 ========== */
.navbar {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 1000;
box-shadow: var(--shadow);
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
max-width: 1200px;
margin: 0 auto;
}
.nav-brand a {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
text-decoration: none;
transition: all 0.3s ease;
}
.nav-brand a:hover {
color: var(--secondary-color);
transform: scale(1.05);
}
.nav-menu {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
}
.nav-link:hover {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.1);
}
.nav-link.admin {
color: var(--warning-color);
}
.nav-user {
color: var(--primary-color);
font-weight: 600;
}
/* ========== 容器和布局 ========== */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.main-content {
min-height: calc(100vh - 200px);
padding: 2rem 0;
}
/* ========== Flash 消息 ========== */
.flash-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 2000;
max-width: 400px;
}
.flash {
padding: 1rem 1.5rem;
margin-bottom: 1rem;
border-radius: 0.75rem;
box-shadow: var(--shadow-lg);
display: flex;
justify-content: space-between;
align-items: center;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.flash-success { background: var(--success-color); color: white; }
.flash-error { background: var(--danger-color); color: white; }
.flash-warning { background: var(--warning-color); color: white; }
.flash-info { background: var(--primary-color); color: white; }
.flash-close {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
margin-left: 1rem;
}
/* ========== 页面标题 ========== */
.page-header {
text-align: center;
margin-bottom: 3rem;
}
.page-header h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
}
/* ========== 帖子网格 ========== */
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.post-card {
background: var(--bg-card);
border-radius: 1rem;
overflow: hidden;
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.post-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.post-header {
padding: 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
text-decoration: none;
}
.username {
color: var(--text-primary);
font-weight: 600;
text-decoration: none;
transition: color 0.3s ease;
}
.username:hover {
color: var(--primary-color);
}
.post-time {
color: var(--text-secondary);
font-size: 0.875rem;
display: block;
}
.post-image-link {
display: block;
position: relative;
overflow: hidden;
}
.post-image {
width: 100%;
height: 300px;
object-fit: cover;
transition: transform 0.3s ease;
}
.post-image-link:hover .post-image {
transform: scale(1.05);
}
.post-description {
padding: 1rem;
color: var(--text-secondary);
line-height: 1.5;
}
.post-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-count {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
}
.comment-count:hover {
color: var(--primary-color);
}
/* ========== 表单样式 ========== */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 200px);
padding: 2rem;
}
.auth-card, .create-post-card {
background: var(--bg-card);
border-radius: 1.5rem;
padding: 2.5rem;
box-shadow: var(--shadow-lg);
max-width: 500px;
width: 100%;
}
.create-post-container .create-post-card {
max-width: 800px;
margin: 0 auto;
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.auth-header p {
color: var(--text-secondary);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
}
.required {
color: var(--danger-color);
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-dark);
border: 2px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.form-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
/* ========== 文件上传 ========== */
.file-upload-area {
border: 2px dashed var(--border-color);
border-radius: 0.75rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.file-upload-area:hover {
border-color: var(--primary-color);
background: rgba(99, 102, 241, 0.05);
}
.file-upload-area.large {
padding: 3rem;
}
.file-input {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
.upload-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.file-hint {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.image-preview {
max-width: 100%;
max-height: 300px;
border-radius: 0.5rem;
margin-top: 1rem;
}
.image-preview.large {
max-height: 500px;
}
/* ========== 按钮 ========== */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
font-size: 1rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.btn-secondary {
background: var(--bg-dark);
color: var(--text-primary);
border: 2px solid var(--border-color);
}
.btn-secondary:hover {
border-color: var(--primary-color);
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-block {
width: 100%;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
/* ========== 信息框 ========== */
.info-box {
background: rgba(99, 102, 241, 0.1);
border-left: 4px solid var(--primary-color);
padding: 1rem;
margin: 1.5rem 0;
border-radius: 0.5rem;
}
/* ========== 空状态 ========== */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
/* ========== 分页 ========== */
.pagination {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
}
.page-link {
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.3s ease;
}
.page-link:hover {
background: var(--primary-color);
border-color: var(--primary-color);
}
.page-link.active {
background: var(--primary-color);
border-color: var(--primary-color);
}
/* ========== 帖子详情 ========== */
.post-detail-card {
background: var(--bg-card);
border-radius: 1.5rem;
overflow: hidden;
box-shadow: var(--shadow-lg);
max-width: 800px;
margin: 0 auto;
}
.post-detail-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.user-info-large {
display: flex;
align-items: center;
gap: 1rem;
text-decoration: none;
}
.user-avatar-large {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
color: white;
}
.username-large {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.post-detail-image img {
width: 100%;
height: auto;
display: block;
}
.post-detail-description {
padding: 1.5rem;
font-size: 1.1rem;
line-height: 1.8;
border-bottom: 1px solid var(--border-color);
}
/* ========== 评论 ========== */
.comments-section {
padding: 1.5rem;
}
.comments-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.comment-form {
margin-bottom: 2rem;
}
.comment-input {
width: 100%;
padding: 1rem;
background: var(--bg-dark);
border: 2px solid var(--border-color);
border-radius: 0.75rem;
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 1rem;
resize: vertical;
min-height: 100px;
}
.comment-input:focus {
outline: none;
border-color: var(--primary-color);
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.comment-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: var(--bg-dark);
border-radius: 0.75rem;
}
.comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
text-decoration: none;
flex-shrink: 0;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-username {
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
}
.comment-username:hover {
color: var(--primary-color);
}
.comment-time {
color: var(--text-secondary);
font-size: 0.875rem;
}
.comment-text {
color: var(--text-secondary);
line-height: 1.6;
}
.empty-comments {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* ========== 个人主页 ========== */
.profile-header {
background: var(--bg-card);
border-radius: 1.5rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-lg);
display: flex;
gap: 2rem;
align-items: center;
}
.profile-avatar-large {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.profile-info {
flex: 1;
}
.profile-username {
font-size: 2rem;
margin-bottom: 1rem;
}
.profile-stats {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.stat-item {
text-align: center;
}
.stat-item a {
text-decoration: none;
color: inherit;
transition: color 0.3s ease;
}
.stat-item a:hover {
color: var(--primary-color);
}
.stat-number {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.profile-bio {
color: var(--text-secondary);
margin-bottom: 1rem;
}
/* ========== 徽章 ========== */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.badge-success {
background: var(--success-color);
color: white;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
.badge-overlay {
position: absolute;
top: 1rem;
right: 1rem;
}
/* ========== 管理面板 ========== */
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: var(--shadow);
}
.stat-icon {
font-size: 2.5rem;
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.quick-link-card {
background: var(--bg-card);
border-radius: 1rem;
padding: 2rem;
text-align: center;
text-decoration: none;
color: var(--text-primary);
transition: all 0.3s ease;
}
.quick-link-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.quick-link-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.admin-section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: var(--primary-color);
}
.admin-list, .users-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.admin-item {
background: var(--bg-card);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.admin-item-header {
margin-bottom: 1rem;
}
.user-email {
color: var(--text-secondary);
font-size: 0.875rem;
}
.user-time {
color: var(--text-secondary);
font-size: 0.875rem;
}
.student-id-preview {
margin-bottom: 1rem;
}
.student-id-image {
max-width: 100%;
max-height: 400px;
border-radius: 0.5rem;
margin-top: 0.5rem;
}
.admin-actions {
display: flex;
gap: 1rem;
}
.admin-posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.admin-post-card {
background: var(--bg-card);
border-radius: 1rem;
overflow: hidden;
box-shadow: var(--shadow);
}
.admin-post-image {
width: 100%;
height: 250px;
object-fit: cover;
}
.admin-post-info {
padding: 1rem;
}
.admin-post-author {
margin-bottom: 0.5rem;
}
.admin-post-author a {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
}
.admin-post-description {
color: var(--text-secondary);
margin-bottom: 1rem;
}
.user-list-item {
background: var(--bg-card);
border-radius: 0.75rem;
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.user-list-info {
flex: 1;
}
.user-stats {
color: var(--text-secondary);
font-size: 0.875rem;
}
/* ========== 页脚 ========== */
.footer {
background: var(--bg-card);
border-top: 1px solid var(--border-color);
padding: 2rem;
text-align: center;
color: var(--text-secondary);
margin-top: 4rem;
}
/* ========== 响应式 ========== */
@media (max-width: 768px) {
.nav-menu {
flex-wrap: wrap;
gap: 0.5rem;
}
.posts-grid {
grid-template-columns: 1fr;
}
.profile-header {
flex-direction: column;
text-align: center;
}
.profile-stats {
justify-content: center;
}
.admin-posts-grid {
grid-template-columns: 1fr;
}
.page-header h1 {
font-size: 2rem;
}
}

99
static/js/main.js Normal file
View File

@@ -0,0 +1,99 @@
// 图片预览功能
document.addEventListener('DOMContentLoaded', function () {
// 注册页面的学生证照片预览
const studentIdInput = document.getElementById('student_id_photo');
if (studentIdInput) {
studentIdInput.addEventListener('change', function (e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
const preview = document.getElementById('imagePreview');
if (preview) {
preview.src = e.target.result;
preview.style.display = 'block';
document.querySelector('#fileUploadArea .file-upload-text').style.display = 'none';
}
};
reader.readAsDataURL(file);
}
});
}
// 发帖页面的图片预览
const postImageInput = document.getElementById('image');
if (postImageInput) {
postImageInput.addEventListener('change', function (e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
const preview = document.getElementById('postImagePreview');
if (preview) {
preview.src = e.target.result;
preview.style.display = 'block';
document.querySelector('#postImageUploadArea .file-upload-text').style.display = 'none';
}
};
reader.readAsDataURL(file);
}
});
}
// Flash消息自动关闭
const flashMessages = document.querySelectorAll('.flash');
flashMessages.forEach(function (flash) {
setTimeout(function () {
flash.style.animation = 'slideOut 0.3s ease';
setTimeout(function () {
flash.remove();
}, 300);
}, 5000);
});
// 拖拽上传功能
const uploadAreas = document.querySelectorAll('.file-upload-area');
uploadAreas.forEach(function (area) {
area.addEventListener('dragover', function (e) {
e.preventDefault();
this.style.borderColor = 'var(--primary-color)';
this.style.background = 'rgba(99, 102, 241, 0.1)';
});
area.addEventListener('dragleave', function (e) {
e.preventDefault();
this.style.borderColor = 'var(--border-color)';
this.style.background = 'transparent';
});
area.addEventListener('drop', function (e) {
e.preventDefault();
this.style.borderColor = 'var(--border-color)';
this.style.background = 'transparent';
const fileInput = this.querySelector('.file-input');
if (fileInput && e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
// 触发change事件
const event = new Event('change');
fileInput.dispatchEvent(event);
}
});
});
});
// slideOut 动画
const style = document.createElement('style');
style.innerHTML = `
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}创建管理员 - 管理面板{% endblock %}
{% block content %}
<div class="container admin-container">
<div class="page-header">
<h1>👤 创建新管理员</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">返回面板</a>
</div>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<form method="POST" class="auth-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required placeholder="请输入用户名" class="form-control">
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required placeholder="请输入邮箱" class="form-control">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="至少6位" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码"
class="form-control">
</div>
<div class="info-box">
<p><strong>提示:</strong>新管理员将拥有完整的管理权限,包括审核用户、帖子和创建其他管理员。</p>
</div>
<button type="submit" class="btn btn-primary btn-block">创建管理员</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}管理面板 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container admin-container">
<div class="page-header">
<h1>🛡️ 管理面板</h1>
</div>
<div class="admin-stats">
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-info">
<div class="stat-number">{{ pending_users }}</div>
<div class="stat-label">待审核用户</div>
</div>
<a href="{{ url_for('admin.users') }}" class="stat-link">查看</a>
</div>
<div class="stat-card">
<div class="stat-icon">📝</div>
<div class="stat-info">
<div class="stat-number">{{ pending_posts }}</div>
<div class="stat-label">待审核帖子</div>
</div>
<a href="{{ url_for('admin.posts') }}" class="stat-link">查看</a>
</div>
</div>
<div class="admin-quick-links">
<h2>快速链接</h2>
<div class="quick-links-grid">
<a href="{{ url_for('admin.users') }}" class="quick-link-card">
<span class="quick-link-icon">👤</span>
<span class="quick-link-text">用户审核</span>
</a>
<a href="{{ url_for('admin.posts') }}" class="quick-link-card">
<span class="quick-link-icon">📸</span>
<span class="quick-link-text">帖子审核</span>
</a>
<a href="{{ url_for('admin.create_admin') }}" class="quick-link-card">
<span class="quick-link-icon"></span>
<span class="quick-link-text">创建管理员</span>
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}帖子审核 - 管理面板{% endblock %}
{% block content %}
<div class="container admin-container">
<div class="page-header">
<h1>📝 帖子审核</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">返回面板</a>
</div>
<!-- Pending Posts -->
<section class="admin-section">
<h2 class="section-title">待审核帖子 ({{ pending_posts|length }})</h2>
{% if pending_posts %}
<div class="admin-posts-grid">
{% for post in pending_posts %}
<div class="admin-post-card">
<img src="{{ url_for('uploaded_file', filename=post.image_path) }}" alt="Post image"
class="admin-post-image">
<div class="admin-post-info">
<div class="admin-post-author">
<a href="{{ url_for('users.profile', username=post.author.username) }}">
{{ post.author.username }}
</a>
<span class="post-time">{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% if post.description %}
<div class="admin-post-description">
{{ post.description }}
</div>
{% endif %}
<div class="admin-actions">
<form method="POST" action="{{ url_for('admin.approve_post', post_id=post.id) }}"
style="display: inline;">
<button type="submit" class="btn btn-success">✓ 批准</button>
</form>
<form method="POST" action="{{ url_for('admin.reject_post', post_id=post.id) }}"
style="display: inline;" onsubmit="return confirm('确定要拒绝该帖子吗?');">
<button type="submit" class="btn btn-danger">✗ 拒绝</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>暂无待审核帖子</p>
</div>
{% endif %}
</section>
<!-- Recently Approved Posts -->
<section class="admin-section">
<h2 class="section-title">最近批准的帖子</h2>
{% if approved_posts %}
<div class="posts-grid">
{% for post in approved_posts %}
<div class="post-card">
<a href="{{ url_for('posts.post_detail', post_id=post.id) }}" class="post-image-link">
<img src="{{ url_for('uploaded_file', filename=post.image_path) }}" alt="Post image"
class="post-image" loading="lazy">
</a>
<div class="post-footer">
<span>{{ post.author.username }}</span>
<span class="badge badge-success">已批准</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>暂无已批准帖子</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}用户审核 - 管理面板{% endblock %}
{% block content %}
<div class="container admin-container">
<div class="page-header">
<h1>👥 用户审核</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">返回面板</a>
</div>
<!-- Pending Users -->
<section class="admin-section">
<h2 class="section-title">待审核用户 ({{ pending_users|length }})</h2>
{% if pending_users %}
<div class="admin-list">
{% for user in pending_users %}
<div class="admin-item">
<div class="admin-item-header">
<div class="user-info">
<div class="user-avatar">{{ user.username[0].upper() }}</div>
<div>
<div class="username">{{ user.username }}</div>
<div class="user-email">{{ user.email }}</div>
<div class="user-time">注册于 {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
</div>
</div>
<!-- Student ID Photo -->
<div class="student-id-preview">
<p><strong>学生证照片:</strong></p>
<img src="{{ url_for('uploaded_file', filename=user.student_id_photo) }}" alt="Student ID"
class="student-id-image">
</div>
<!-- Actions -->
<div class="admin-actions">
<form method="POST" action="{{ url_for('admin.approve_user', user_id=user.id) }}"
style="display: inline;">
<button type="submit" class="btn btn-success">✓ 批准</button>
</form>
<form method="POST" action="{{ url_for('admin.reject_user', user_id=user.id) }}"
style="display: inline;" onsubmit="return confirm('确定要拒绝该用户的注册吗?');">
<button type="submit" class="btn btn-danger">✗ 拒绝</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>暂无待审核用户</p>
</div>
{% endif %}
</section>
<!-- Recently Approved Users -->
<section class="admin-section">
<h2 class="section-title">最近批准的用户</h2>
{% if approved_users %}
<div class="users-list">
{% for user in approved_users %}
<div class="user-list-item">
<div class="user-avatar">{{ user.username[0].upper() }}</div>
<div class="user-list-info">
<a href="{{ url_for('users.profile', username=user.username) }}" class="username">
{{ user.username }}
</a>
<p class="user-stats">{{ user.email }} · 加入于 {{ user.created_at.strftime('%Y-%m-%d') }}</p>
</div>
<span class="badge badge-success">已批准</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>暂无已批准用户</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}

67
templates/base.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}泸州高中摄影社论坛{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar">
<div class="container">
<div class="nav-brand">
<a href="{{ url_for('posts.index') }}">📷 泸州高中摄影社</a>
</div>
<div class="nav-menu">
{% if current_user.is_authenticated %}
<a href="{{ url_for('posts.index') }}" class="nav-link">首页</a>
<a href="{{ url_for('posts.create_post') }}" class="nav-link">发帖</a>
<a href="{{ url_for('users.profile', username=current_user.username) }}" class="nav-link">我的主页</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="nav-link admin">管理面板</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="nav-link">退出</a>
<span class="nav-user">@{{ current_user.username }}</span>
{% else %}
<a href="{{ url_for('posts.index') }}" class="nav-link">首页</a>
<a href="{{ url_for('auth.login') }}" class="nav-link">登录</a>
<a href="{{ url_for('auth.register') }}" class="nav-link btn-primary">注册</a>
{% endif %}
</div>
</div>
</nav>
<!-- Flash 消息 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
{{ message }}
<button class="flash-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- 主内容 -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="container">
<p>&copy; 2026 泸州高中摄影社 | 分享精彩瞬间</p>
</div>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}修改密码 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>🔐 修改密码</h1>
{% if current_user.is_admin and not current_user.password_changed %}
<p style="color: var(--warning-color);">首次登录,请修改默认密码以确保账号安全</p>
{% else %}
<p>修改您的登录密码</p>
{% endif %}
</div>
<form method="POST" class="auth-form">
<div class="form-group">
<label for="current_password">当前密码</label>
<input type="password" id="current_password" name="current_password" required placeholder="请输入当前密码"
class="form-control" autofocus>
</div>
<div class="form-group">
<label for="new_password">新密码</label>
<input type="password" id="new_password" name="new_password" required placeholder="至少6位"
class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">确认新密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入新密码"
class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-block">确认修改</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}发布作品 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container create-post-container">
<div class="page-header">
<h1>📸 发布摄影作品</h1>
<p class="subtitle">分享你的精彩瞬间</p>
</div>
<div class="create-post-card">
<form method="POST" enctype="multipart/form-data" class="create-post-form">
<div class="form-group">
<label for="image">照片 <span class="required">*</span></label>
<div class="file-upload-area large" id="postImageUploadArea">
<input type="file" id="image" name="image" accept="image/*" required class="file-input">
<div class="file-upload-text">
<span class="upload-icon">🖼️</span>
<p>点击或拖拽上传照片</p>
<p class="file-hint">支持 PNG, JPG, JPEG, GIF, WEBP最大 16MB</p>
</div>
<img id="postImagePreview" class="image-preview large" style="display: none;">
</div>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea id="description" name="description" rows="5" placeholder="说说这张照片的故事..."
class="form-control"></textarea>
</div>
<div class="info-box">
<p><strong>提示:</strong>您的作品将在管理员审核通过后展示给所有用户。</p>
</div>
<div class="form-actions">
<a href="{{ url_for('posts.index') }}" class="btn btn-secondary">取消</a>
<button type="submit" class="btn btn-primary">发布作品</button>
</div>
</form>
</div>
</div>
{% endblock %}

15
templates/errors/404.html Normal file
View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}页面未找到 - 404{% endblock %}
{% block content %}
<div class="container">
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h1 style="font-size: 3rem; margin-bottom: 1rem;">404</h1>
<h2>页面未找到</h2>
<p>抱歉,您访问的页面不存在。</p>
<a href="{{ url_for('posts.index') }}" class="btn btn-primary" style="margin-top: 2rem;">返回首页</a>
</div>
</div>
{% endblock %}

15
templates/errors/500.html Normal file
View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}服务器错误 - 500{% endblock %}
{% block content %}
<div class="container">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h1 style="font-size: 3rem; margin-bottom: 1rem;">500</h1>
<h2>服务器内部错误</h2>
<p>抱歉,服务器遇到了一个错误。我们会尽快修复。</p>
<a href="{{ url_for('posts.index') }}" class="btn btn-primary" style="margin-top: 2rem;">返回首页</a>
</div>
</div>
{% endblock %}

34
templates/followers.html Normal file
View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ user.username }} 的粉丝 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>{{ user.username }} 的粉丝</h1>
<a href="{{ url_for('users.profile', username=user.username) }}" class="btn btn-secondary">返回主页</a>
</div>
{% if followers %}
<div class="users-list">
{% for follower in followers %}
<div class="user-list-item">
<a href="{{ url_for('users.profile', username=follower.username) }}" class="user-avatar">
{{ follower.username[0].upper() }}
</a>
<div class="user-list-info">
<a href="{{ url_for('users.profile', username=follower.username) }}" class="username">
{{ follower.username }}
</a>
<p class="user-stats">{{ follower.get_follower_count() }} 粉丝 · {{ follower.posts.count() }} 作品</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>还没有粉丝</p>
</div>
{% endif %}
</div>
{% endblock %}

35
templates/following.html Normal file
View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}{{ user.username }} 关注的人 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>{{ user.username }} 关注的人</h1>
<a href="{{ url_for('users.profile', username=user.username) }}" class="btn btn-secondary">返回主页</a>
</div>
{% if following %}
<div class="users-list">
{% for followed_user in following %}
<div class="user-list-item">
<a href="{{ url_for('users.profile', username=followed_user.username) }}" class="user-avatar">
{{ followed_user.username[0].upper() }}
</a>
<div class="user-list-info">
<a href="{{ url_for('users.profile', username=followed_user.username) }}" class="username">
{{ followed_user.username }}
</a>
<p class="user-stats">{{ followed_user.get_follower_count() }} 粉丝 · {{ followed_user.posts.count() }} 作品
</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>还没有关注任何人</p>
</div>
{% endif %}
</div>
{% endblock %}

87
templates/index.html Normal file
View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}首页 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>📸 摄影作品分享</h1>
<p class="subtitle">发现精彩瞬间</p>
</div>
{% if posts %}
<div class="posts-grid">
{% for post in posts %}
<div class="post-card">
<div class="post-header">
<div class="user-info">
<a href="{{ url_for('users.profile', username=post.author.username) }}" class="user-avatar">
{{ post.author.username[0].upper() }}
</a>
<div>
<a href="{{ url_for('users.profile', username=post.author.username) }}" class="username">
{{ post.author.username }}
</a>
<span class="post-time">{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
<a href="{{ url_for('posts.post_detail', post_id=post.id) }}" class="post-image-link">
<img src="{{ url_for('uploaded_file', filename=post.image_path) }}" alt="Post image" class="post-image"
loading="lazy">
</a>
{% if post.description %}
<div class="post-description">
{{ post.description }}
</div>
{% endif %}
<div class="post-footer">
<a href="{{ url_for('posts.post_detail', post_id=post.id) }}" class="comment-count">
💬 {{ post.get_comment_count() }} 条评论
</a>
</div>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('posts.index', page=pagination.prev_num) }}" class="page-link">上一页</a>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<span class="page-link active">{{ page_num }}</span>
{% else %}
<a href="{{ url_for('posts.index', page=page_num) }}" class="page-link">{{ page_num }}</a>
{% endif %}
{% else %}
<span class="page-link">...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('posts.index', page=pagination.next_num) }}" class="page-link">下一页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<div class="empty-icon">📷</div>
<h2>还没有帖子</h2>
<p>成为第一个分享摄影作品的人吧!</p>
{% if current_user.is_authenticated %}
<a href="{{ url_for('posts.create_post') }}" class="btn btn-primary">发布作品</a>
{% else %}
<a href="{{ url_for('auth.register') }}" class="btn btn-primary">立即注册</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

38
templates/login.html Normal file
View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}登录 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>🔐 欢迎回来</h1>
<p>登录查看更多精彩作品</p>
</div>
<form method="POST" class="auth-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required placeholder="请输入用户名" class="form-control"
autofocus>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="请输入密码" class="form-control">
</div>
<div class="form-check">
<input type="checkbox" id="remember" name="remember" class="form-checkbox">
<label for="remember">记住我</label>
</div>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<div class="auth-footer">
<p>还没有账号?<a href="{{ url_for('auth.register') }}">立即注册</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}{{ post.author.username }} 的作品 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container post-detail-container">
<div class="post-detail-card">
<!-- Post Header -->
<div class="post-detail-header">
<a href="{{ url_for('users.profile', username=post.author.username) }}" class="user-info-large">
<div class="user-avatar-large">
{{ post.author.username[0].upper() }}
</div>
<div>
<div class="username-large">{{ post.author.username }}</div>
<div class="post-time">{{ post.created_at.strftime('%Y年%m月%d日 %H:%M') }}</div>
</div>
</a>
{% if not post.is_approved %}
<div class="badge badge-warning">等待审核</div>
{% endif %}
</div>
<!-- Post Image -->
<div class="post-detail-image">
<img src="{{ url_for('uploaded_file', filename=post.image_path) }}" alt="Post image" class="detail-image">
</div>
<!-- Post Description -->
{% if post.description %}
<div class="post-detail-description">
{{ post.description }}
</div>
{% endif %}
<!-- Comments Section -->
<div class="comments-section">
<h2 class="comments-title">💬 评论 ({{ comments|length }})</h2>
{% if current_user.is_authenticated and current_user.is_approved and post.is_approved %}
<form method="POST" action="{{ url_for('posts.add_comment', post_id=post.id) }}" class="comment-form">
<textarea name="content" placeholder="发表你的想法..." required class="comment-input"></textarea>
<button type="submit" class="btn btn-primary">发表评论</button>
</form>
{% elif not current_user.is_authenticated %}
<div class="info-box">
<p><a href="{{ url_for('auth.login') }}">登录</a> 后可以发表评论</p>
</div>
{% endif %}
<!-- Comments List -->
<div class="comments-list">
{% for comment in comments %}
<div class="comment-item">
<a href="{{ url_for('users.profile', username=comment.author.username) }}" class="comment-avatar">
{{ comment.author.username[0].upper() }}
</a>
<div class="comment-content">
<div class="comment-header">
<a href="{{ url_for('users.profile', username=comment.author.username) }}"
class="comment-username">
{{ comment.author.username }}
</a>
<span class="comment-time">{{ comment.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
</div>
</div>
{% endfor %}
{% if not comments %}
<div class="empty-comments">
<p>还没有评论,快来抢沙发吧!</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

87
templates/profile.html Normal file
View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}{{ user.username }} - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="container profile-container">
<!-- Profile Header -->
<div class="profile-header">
<div class="profile-avatar-large">
{{ user.username[0].upper() }}
</div>
<div class="profile-info">
<h1 class="profile-username">{{ user.username }}</h1>
{% if not user.is_approved %}
<div class="badge badge-warning">等待审核</div>
{% endif %}
<div class="profile-stats">
<div class="stat-item">
<span class="stat-number">{{ posts|length }}</span>
<span class="stat-label">作品</span>
</div>
<div class="stat-item">
<a href="{{ url_for('users.followers', username=user.username) }}">
<span class="stat-number">{{ follower_count }}</span>
<span class="stat-label">粉丝</span>
</a>
</div>
<div class="stat-item">
<a href="{{ url_for('users.following', username=user.username) }}">
<span class="stat-number">{{ following_count }}</span>
<span class="stat-label">关注</span>
</a>
</div>
</div>
{% if user.bio %}
<p class="profile-bio">{{ user.bio }}</p>
{% endif %}
<!-- Follow Button -->
{% if current_user.is_authenticated and current_user.id != user.id %}
{% if is_following %}
<form method="POST" action="{{ url_for('users.unfollow', username=user.username) }}"
style="display: inline;">
<button type="submit" class="btn btn-secondary">已关注</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('users.follow', username=user.username) }}" style="display: inline;">
<button type="submit" class="btn btn-primary">关注</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
<!-- User Posts -->
<div class="profile-posts">
<h2 class="section-title">📸 作品集</h2>
{% if posts %}
<div class="posts-grid">
{% for post in posts %}
<div class="post-card">
<a href="{{ url_for('posts.post_detail', post_id=post.id) }}" class="post-image-link">
<img src="{{ url_for('uploaded_file', filename=post.image_path) }}" alt="Post image"
class="post-image" loading="lazy">
{% if not post.is_approved %}
<div class="badge badge-overlay">审核中</div>
{% endif %}
</a>
<div class="post-footer">
<span class="comment-count">💬 {{ post.get_comment_count() }}</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📷</div>
<p>该用户还没有发布作品</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

61
templates/register.html Normal file
View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}注册 - 泸州高中摄影社论坛{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>📷 加入摄影社</h1>
<p>分享你的摄影作品,发现更多精彩</p>
</div>
<form method="POST" enctype="multipart/form-data" class="auth-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required placeholder="请输入用户名" class="form-control">
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required placeholder="请输入邮箱" class="form-control">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="至少6位" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码"
class="form-control">
</div>
<div class="form-group">
<label for="student_id_photo">学生证照片 <span class="required">*</span></label>
<div class="file-upload-area" id="fileUploadArea">
<input type="file" id="student_id_photo" name="student_id_photo" accept="image/*" required
class="file-input">
<div class="file-upload-text">
<span class="upload-icon">📤</span>
<p>点击或拖拽上传学生证照片</p>
<p class="file-hint">支持 PNG, JPG, JPEG, GIF, WEBP</p>
</div>
<img id="imagePreview" class="image-preview" style="display: none;">
</div>
</div>
<div class="info-box">
<p><strong>注意:</strong>您上传的学生证照片将用于身份验证,管理员审核通过后才能使用论坛功能。</p>
</div>
<button type="submit" class="btn btn-primary btn-block">提交注册</button>
</form>
<div class="auth-footer">
<p>已有账号?<a href="{{ url_for('auth.login') }}">立即登录</a></p>
</div>
</div>
</div>
{% endblock %}