Complete project files including setup.sh
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
136
README.md
Normal 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
82
app.py
Normal 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
33
config.py
Normal 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
150
models.py
Normal 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
8
requirements.txt
Normal 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
162
routes/auth.py
Normal 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
178
routes/new_admin.py
Normal 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
126
routes/posts.py
Normal 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
93
routes/users.py
Normal 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
140
setup.sh
Normal 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
965
static/css/style.css
Normal 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
99
static/js/main.js
Normal 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);
|
||||
43
templates/admin/create_admin.html
Normal file
43
templates/admin/create_admin.html
Normal 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 %}
|
||||
49
templates/admin/dashboard.html
Normal file
49
templates/admin/dashboard.html
Normal 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 %}
|
||||
84
templates/admin/posts.html
Normal file
84
templates/admin/posts.html
Normal 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 %}
|
||||
85
templates/admin/users.html
Normal file
85
templates/admin/users.html
Normal 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
67
templates/base.html
Normal 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>© 2026 泸州高中摄影社 | 分享精彩瞬间</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
templates/change_password.html
Normal file
40
templates/change_password.html
Normal 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 %}
|
||||
44
templates/create_post.html
Normal file
44
templates/create_post.html
Normal 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
15
templates/errors/404.html
Normal 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
15
templates/errors/500.html
Normal 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
34
templates/followers.html
Normal 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
35
templates/following.html
Normal 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
87
templates/index.html
Normal 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
38
templates/login.html
Normal 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 %}
|
||||
81
templates/post_detail.html
Normal file
81
templates/post_detail.html
Normal 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
87
templates/profile.html
Normal 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
61
templates/register.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user