feat: 初始化个人资料库 Web 应用

基于 Flask + MySQL + Bootstrap 5 的全栈个人资料库管理系统。

主要功能:
- 管理员/普通用户双角色权限体系,全站登录保护
- 资源管理:文本、图片、音频、视频四类资源
- 三种添加方式:本地上传(拖拽)、URL 后台下载、磁力下载(aria2c)
- 在线预览:文本、图片、HTML5 音视频播放器
- 安全:bcrypt 加盐密码哈希、CSRF 防护、SQLAlchemy ORM 防注入

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:16:59 +09:00
commit f103148ebf
62 changed files with 3696 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pip install:*)",
"Bash(python -c ':*)",
"Bash(git add:*)"
]
}
}

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
SECRET_KEY=change-this-to-a-random-secret-key
DATABASE_URL=mysql+pymysql://root:yourpassword@localhost:3306/resource_library
FLASK_ENV=development
MAX_UPLOAD_SIZE_MB=500
UPLOAD_FOLDER=app/static/uploads

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
*.pyo
.env
*.egg-info/
dist/
build/
.venv/
venv/
env/
app/static/uploads/
*.sqlite3
*.db
migrations/
.DS_Store
Thumbs.db

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# 个人资料库 (Personal Resource Library)
基于 Flask + MySQL 的个人多媒体资料管理系统。支持文本、图片、音频、视频的上传、URL 下载、磁力下载及在线预览。
## 功能特性
| 功能 | 说明 |
|------|------|
| 权限管理 | 管理员 / 普通用户两级权限,登录保护所有页面 |
| 资源管理 | 文本、图片、音频、视频四类资源 |
| 本地上传 | 拖拽或点击上传,带实时进度条 |
| URL 下载 | 后台异步下载远程文件,实时轮询进度 |
| 磁力下载 | 调用 aria2c 在后台下载磁力链接资源 |
| 在线预览 | 文本高亮/图片查看/HTML5 音视频播放器 |
| 安全 | bcrypt 加盐哈希密码、CSRF 防护、SQL 注入防护、XSS 防护 |
## 快速开始
### 1. 环境准备
- Python 3.10+
- MySQL 8.0+
- (可选)[aria2c](https://aria2.github.io/) — 用于磁力下载
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置数据库
创建 MySQL 数据库:
```sql
CREATE DATABASE resource_library CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
复制并修改环境配置:
```bash
cp .env.example .env
```
编辑 `.env`
```env
SECRET_KEY=your-random-secret-key
DATABASE_URL=mysql+pymysql://root:yourpassword@localhost:3306/resource_library
```
### 4. 初始化数据库
```bash
python init_db.py
# 自定义管理员账号:
# python init_db.py --admin-user admin --admin-pass YourPass123 --admin-email admin@example.com
```
### 5. 启动服务
```bash
python run.py
```
访问 `http://localhost:5000`,使用默认管理员账号登录:
- 用户名:`admin`
- 密码:`Admin@123456`
## 项目结构
```
.
├── app/
│ ├── models/ # 数据库模型 (User, Resource, SystemSetting)
│ ├── routes/ # 路由蓝图 (auth, admin, resources, main)
│ ├── utils/ # 工具模块 (decorators, file_handler, downloader)
│ ├── static/ # 静态文件 (CSS, JS, 上传文件)
│ │ └── uploads/ # 用户上传文件目录(自动创建)
│ └── templates/ # Jinja2 模板
├── config.py # 配置类
├── init_db.py # 数据库初始化脚本
├── run.py # 启动入口
└── requirements.txt
```
## 安全设计
| 安全措施 | 实现方式 |
|----------|----------|
| 密码存储 | Flask-Bcryptbcrypt 自动加盐哈希) |
| CSRF 防护 | Flask-WTF CSRFProtect所有 POST 请求验证 token |
| 权限控制 | Flask-Login `@login_required` + 自定义 `@admin_required` |
| SQL 注入 | SQLAlchemy ORM 参数化查询 |
| XSS 防护 | Jinja2 自动转义 HTML 输出 |
| 文件安全 | `werkzeug.secure_filename` + 扩展名白名单验证 |
| Session | HTTPOnly Cookie + SameSite=Lax |
## 磁力下载说明
磁力下载需要在服务器上安装 `aria2c`
- **Linux/macOS**`apt install aria2` / `brew install aria2`
- **Windows**:从 [aria2 Releases](https://github.com/aria2/aria2/releases) 下载并加入 PATH

Binary file not shown.

60
app/__init__.py Normal file
View File

@@ -0,0 +1,60 @@
import os
from flask import Flask
from config import config
from app.extensions import db, login_manager, bcrypt, csrf, migrate
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'default')
app = Flask(__name__)
app.config.from_object(config[config_name])
# 确保上传目录存在
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
for sub in ['text', 'image', 'audio', 'video', 'temp']:
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], sub), exist_ok=True)
# 初始化扩展
db.init_app(app)
login_manager.init_app(app)
bcrypt.init_app(app)
csrf.init_app(app)
migrate.init_app(app, db)
# 注册蓝图
from app.routes.auth import auth_bp
from app.routes.admin import admin_bp
from app.routes.resources import resources_bp
from app.routes.main import main_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(resources_bp, url_prefix='/resources')
app.register_blueprint(main_bp)
# 注册错误处理
from app.routes.errors import register_error_handlers
register_error_handlers(app)
# 注册模板过滤器
from app.utils.filters import register_filters
register_filters(app)
# 注册全局模板函数
from flask_wtf.csrf import generate_csrf
from markupsafe import Markup
@app.context_processor
def inject_globals():
from app.models.setting import SystemSetting
site_name = SystemSetting.get('site_name', '个人资料库')
return dict(site_name=site_name)
@app.template_global()
def csrf_token_input():
token = generate_csrf()
return Markup(f'<input type="hidden" name="csrf_token" value="{token}">')
return app

Binary file not shown.

Binary file not shown.

15
app/extensions.py Normal file
View File

@@ -0,0 +1,15 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from flask_wtf.csrf import CSRFProtect
from flask_migrate import Migrate
db = SQLAlchemy()
login_manager = LoginManager()
bcrypt = Bcrypt()
csrf = CSRFProtect()
migrate = Migrate()
login_manager.login_view = 'auth.login'
login_manager.login_message = '请先登录以访问此页面'
login_manager.login_message_category = 'warning'

5
app/models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from app.models.user import User
from app.models.resource import Resource
from app.models.setting import SystemSetting
__all__ = ['User', 'Resource', 'SystemSetting']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

85
app/models/resource.py Normal file
View File

@@ -0,0 +1,85 @@
from datetime import datetime
from app.extensions import db
class Resource(db.Model):
__tablename__ = 'resources'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
title = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
# 资源类型: text / image / audio / video
resource_type = db.Column(db.Enum('text', 'image', 'audio', 'video'),
nullable=False, index=True)
# 来源类型: upload / url / magnet
source_type = db.Column(db.Enum('upload', 'url', 'magnet'),
nullable=False, default='upload')
# 文件信息
filename = db.Column(db.String(255), nullable=True) # 磁盘上的文件名
original_name = db.Column(db.String(255), nullable=True) # 原始文件名
file_path = db.Column(db.String(512), nullable=True) # 相对于 static 的路径
file_size = db.Column(db.BigInteger, nullable=True) # 字节
mime_type = db.Column(db.String(128), nullable=True)
# 来源 URL / 磁力链
source_url = db.Column(db.Text, nullable=True)
# 下载状态: pending / downloading / done / failed (针对 url/magnet)
download_status = db.Column(
db.Enum('pending', 'downloading', 'done', 'failed', 'na'),
nullable=False, default='na'
)
download_progress = db.Column(db.Integer, default=0) # 0-100
download_error = db.Column(db.Text, nullable=True)
tags = db.Column(db.String(512), nullable=True) # 逗号分隔
is_public = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f'<Resource {self.title}>'
@property
def size_human(self):
"""返回人性化的文件大小字符串"""
if not self.file_size:
return '未知'
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if self.file_size < 1024:
return f'{self.file_size:.1f} {unit}'
self.file_size /= 1024
return f'{self.file_size:.1f} PB'
@property
def tag_list(self):
if not self.tags:
return []
return [t.strip() for t in self.tags.split(',') if t.strip()]
@property
def type_icon(self):
icons = {
'text': 'bi-file-text',
'image': 'bi-image',
'audio': 'bi-music-note-beamed',
'video': 'bi-camera-video',
}
return icons.get(self.resource_type, 'bi-file')
@property
def type_badge_color(self):
colors = {
'text': 'secondary',
'image': 'success',
'audio': 'warning',
'video': 'danger',
}
return colors.get(self.resource_type, 'secondary')

47
app/models/setting.py Normal file
View File

@@ -0,0 +1,47 @@
from datetime import datetime
from app.extensions import db
class SystemSetting(db.Model):
__tablename__ = 'system_settings'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64), unique=True, nullable=False, index=True)
value = db.Column(db.Text, nullable=True)
value_type = db.Column(db.Enum('string', 'integer', 'boolean', 'json'),
default='string')
description = db.Column(db.String(255), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow)
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
@classmethod
def get(cls, key, default=None):
setting = cls.query.filter_by(key=key).first()
if setting is None:
return default
return setting.value
@classmethod
def set(cls, key, value, description=None):
setting = cls.query.filter_by(key=key).first()
if setting:
setting.value = str(value)
setting.updated_at = datetime.utcnow()
else:
setting = cls(key=key, value=str(value), description=description)
db.session.add(setting)
db.session.commit()
# 默认配置项
DEFAULTS = {
'site_name': ('个人资料库', '站点名称'),
'site_description': ('我的个人多媒体资料管理系统', '站点描述'),
'allow_register': ('true', '是否允许用户注册'),
'max_upload_mb': ('500', '单次最大上传大小(MB)'),
'allowed_types': ('text,image,audio,video', '允许的资源类型'),
'enable_magnet': ('true', '是否启用磁力下载'),
'enable_url_download': ('true', '是否启用 URL 下载'),
}

36
app/models/user.py Normal file
View File

@@ -0,0 +1,36 @@
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db, login_manager
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(128), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.Enum('admin', 'user'), nullable=False, default='user')
is_active = db.Column(db.Boolean, default=True, nullable=False)
avatar = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True)
# 关联资源
resources = db.relationship('Resource', backref='owner', lazy='dynamic',
cascade='all, delete-orphan')
def __repr__(self):
return f'<User {self.username}>'
@property
def is_admin(self):
return self.role == 'admin'
def get_id(self):
return str(self.id)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))

0
app/routes/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

207
app/routes/admin.py Normal file
View File

@@ -0,0 +1,207 @@
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, abort)
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import (StringField, PasswordField, SelectField,
BooleanField, SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Email, Length, Optional, EqualTo, ValidationError
from app.extensions import db, bcrypt
from app.models.user import User
from app.models.resource import Resource
from app.models.setting import SystemSetting
from app.utils.decorators import admin_required
admin_bp = Blueprint('admin', __name__)
# ── 表单 ─────────────────────────────────────────────────────────────────────
class UserCreateForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(3, 64)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
role = SelectField('角色', choices=[('user', '普通用户'), ('admin', '管理员')])
is_active = BooleanField('启用账号', default=True)
submit = SubmitField('创建')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已存在')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('邮箱已被使用')
class UserEditForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(3, 64)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
password = PasswordField('新密码(留空不修改)', validators=[Optional(), Length(min=8)])
role = SelectField('角色', choices=[('user', '普通用户'), ('admin', '管理员')])
is_active = BooleanField('启用账号')
submit = SubmitField('保存')
class SettingForm(FlaskForm):
site_name = StringField('站点名称', validators=[DataRequired()])
site_description = TextAreaField('站点描述')
allow_register = BooleanField('允许用户自行注册')
max_upload_mb = StringField('最大上传大小(MB)', validators=[DataRequired()])
enable_url_download = BooleanField('启用 URL 下载')
enable_magnet = BooleanField('启用磁力下载')
submit = SubmitField('保存设置')
# ── 路由 ─────────────────────────────────────────────────────────────────────
@admin_bp.route('/')
@login_required
@admin_required
def dashboard():
total_users = User.query.count()
total_resources = Resource.query.count()
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
recent_resources = Resource.query.order_by(Resource.created_at.desc()).limit(10).all()
type_stats = {}
for t in ('text', 'image', 'audio', 'video'):
type_stats[t] = Resource.query.filter_by(resource_type=t).count()
return render_template('admin/dashboard.html',
total_users=total_users,
total_resources=total_resources,
recent_users=recent_users,
recent_resources=recent_resources,
type_stats=type_stats)
# ─── 用户管理 ─────────────────────────────────────────────────────────────────
@admin_bp.route('/users')
@login_required
@admin_required
def users():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '')
query = User.query
if q:
query = query.filter(
User.username.ilike(f'%{q}%') | User.email.ilike(f'%{q}%')
)
pagination = query.order_by(User.created_at.desc()).paginate(
page=page, per_page=20, error_out=False)
return render_template('admin/users.html',
pagination=pagination, q=q)
@admin_bp.route('/users/create', methods=['GET', 'POST'])
@login_required
@admin_required
def user_create():
form = UserCreateForm()
if form.validate_on_submit():
pw_hash = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
user = User(
username=form.username.data,
email=form.email.data,
password_hash=pw_hash,
role=form.role.data,
is_active=form.is_active.data
)
db.session.add(user)
db.session.commit()
flash(f'用户 {user.username} 创建成功', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', form=form,
title='创建用户', action='create')
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def user_edit(user_id):
user = db.get_or_404(User, user_id)
form = UserEditForm(obj=user)
if form.validate_on_submit():
# 检查用户名/邮箱唯一性
dup_name = User.query.filter(
User.username == form.username.data, User.id != user_id).first()
dup_email = User.query.filter(
User.email == form.email.data, User.id != user_id).first()
if dup_name:
flash('用户名已存在', 'danger')
elif dup_email:
flash('邮箱已被使用', 'danger')
else:
user.username = form.username.data
user.email = form.email.data
user.role = form.role.data
user.is_active = form.is_active.data
if form.password.data:
user.password_hash = bcrypt.generate_password_hash(
form.password.data).decode('utf-8')
db.session.commit()
flash('用户信息已更新', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', form=form,
title='编辑用户', action='edit', user=user)
@admin_bp.route('/users/<int:user_id>/toggle', methods=['POST'])
@login_required
@admin_required
def user_toggle(user_id):
user = db.get_or_404(User, user_id)
if user.id == current_user.id:
flash('不能禁用自己的账号', 'warning')
else:
user.is_active = not user.is_active
db.session.commit()
status = '启用' if user.is_active else '禁用'
flash(f'账号已{status}', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def user_delete(user_id):
user = db.get_or_404(User, user_id)
if user.id == current_user.id:
flash('不能删除自己的账号', 'warning')
else:
db.session.delete(user)
db.session.commit()
flash(f'用户 {user.username} 已删除', 'success')
return redirect(url_for('admin.users'))
# ─── 系统设置 ─────────────────────────────────────────────────────────────────
@admin_bp.route('/settings', methods=['GET', 'POST'])
@login_required
@admin_required
def settings():
form = SettingForm()
if form.validate_on_submit():
SystemSetting.set('site_name', form.site_name.data)
SystemSetting.set('site_description', form.site_description.data)
SystemSetting.set('allow_register',
'true' if form.allow_register.data else 'false')
SystemSetting.set('max_upload_mb', form.max_upload_mb.data)
SystemSetting.set('enable_url_download',
'true' if form.enable_url_download.data else 'false')
SystemSetting.set('enable_magnet',
'true' if form.enable_magnet.data else 'false')
flash('设置已保存', 'success')
return redirect(url_for('admin.settings'))
# 填充表单当前值
form.site_name.data = SystemSetting.get('site_name', '个人资料库')
form.site_description.data = SystemSetting.get('site_description', '')
form.allow_register.data = SystemSetting.get('allow_register', 'true') == 'true'
form.max_upload_mb.data = SystemSetting.get('max_upload_mb', '500')
form.enable_url_download.data = SystemSetting.get('enable_url_download', 'true') == 'true'
form.enable_magnet.data = SystemSetting.get('enable_magnet', 'true') == 'true'
return render_template('admin/settings.html', form=form)

138
app/routes/auth.py Normal file
View File

@@ -0,0 +1,138 @@
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, session)
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.extensions import db, bcrypt
from app.models.user import User
from app.models.setting import SystemSetting
auth_bp = Blueprint('auth', __name__)
# ── 表单定义 ──────────────────────────────────────────────────────────────────
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(message='请输入用户名')])
password = PasswordField('密码', validators=[DataRequired(message='请输入密码')])
remember = BooleanField('记住我')
submit = SubmitField('登录')
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(), Length(min=3, max=64, message='用户名长度 3-64 位')
])
email = StringField('邮箱', validators=[
DataRequired(), Email(message='请输入有效的邮箱地址')
])
password = PasswordField('密码', validators=[
DataRequired(), Length(min=8, message='密码至少 8 位')
])
password2 = PasswordField('确认密码', validators=[
DataRequired(), EqualTo('password', message='两次密码不一致')
])
submit = SubmitField('注册')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已被使用')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('邮箱已被注册')
class ChangePasswordForm(FlaskForm):
old_password = PasswordField('当前密码', validators=[DataRequired()])
new_password = PasswordField('新密码', validators=[
DataRequired(), Length(min=8, message='密码至少 8 位')
])
new_password2 = PasswordField('确认新密码', validators=[
DataRequired(), EqualTo('new_password', message='两次密码不一致')
])
submit = SubmitField('修改密码')
# ── 路由 ──────────────────────────────────────────────────────────────────────
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and bcrypt.check_password_hash(user.password_hash, form.password.data):
if not user.is_active:
flash('账号已被禁用,请联系管理员', 'danger')
return render_template('auth/login.html', form=form)
login_user(user, remember=form.remember.data)
user.last_login = datetime.utcnow()
db.session.commit()
# 安全重定向
next_page = request.args.get('next')
if next_page and next_page.startswith('/'):
return redirect(next_page)
return redirect(url_for('main.index'))
else:
flash('用户名或密码错误', 'danger')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout', methods=['POST'])
@login_required
def logout():
logout_user()
flash('已安全退出', 'success')
return redirect(url_for('auth.login'))
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
allow = SystemSetting.get('allow_register', 'true')
if allow != 'true':
flash('系统暂未开放注册,请联系管理员', 'warning')
return redirect(url_for('auth.login'))
form = RegisterForm()
if form.validate_on_submit():
pw_hash = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
user = User(
username=form.username.data,
email=form.email.data,
password_hash=pw_hash,
role='user'
)
db.session.add(user)
db.session.commit()
flash('注册成功,请登录', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
form = ChangePasswordForm()
if form.validate_on_submit():
if not bcrypt.check_password_hash(current_user.password_hash,
form.old_password.data):
flash('当前密码错误', 'danger')
else:
current_user.password_hash = bcrypt.generate_password_hash(
form.new_password.data
).decode('utf-8')
db.session.commit()
flash('密码修改成功', 'success')
return redirect(url_for('main.index'))
return render_template('auth/change_password.html', form=form)

20
app/routes/errors.py Normal file
View File

@@ -0,0 +1,20 @@
from flask import render_template
def register_error_handlers(app):
@app.errorhandler(403)
def forbidden(e):
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(413)
def request_entity_too_large(e):
return render_template('errors/413.html'), 413
@app.errorhandler(500)
def internal_error(e):
return render_template('errors/500.html'), 500

26
app/routes/main.py Normal file
View File

@@ -0,0 +1,26 @@
from flask import Blueprint, redirect, url_for, render_template
from flask_login import login_required, current_user
from app.models.resource import Resource
from app.models.user import User
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def index():
if current_user.is_admin:
return redirect(url_for('admin.dashboard'))
return redirect(url_for('resources.list_resources'))
@main_bp.route('/profile')
@login_required
def profile():
total = Resource.query.filter_by(user_id=current_user.id).count()
by_type = {}
for t in ('text', 'image', 'audio', 'video'):
by_type[t] = Resource.query.filter_by(
user_id=current_user.id, resource_type=t).count()
return render_template('user/profile.html', total=total, by_type=by_type)

324
app/routes/resources.py Normal file
View File

@@ -0,0 +1,324 @@
import os
import json
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, abort, current_app, send_file, jsonify, Response)
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField, SelectField,
FileField, SubmitField, BooleanField)
from wtforms.validators import DataRequired, Optional, URL, Length
from app.extensions import db
from app.models.resource import Resource
from app.models.setting import SystemSetting
from app.utils.file_handler import (save_uploaded_file, delete_resource_file,
allowed_file, guess_resource_type)
from app.utils.downloader import start_url_download, start_magnet_download
resources_bp = Blueprint('resources', __name__)
# ── 表单 ─────────────────────────────────────────────────────────────────────
class UploadForm(FlaskForm):
title = StringField('标题', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('描述', validators=[Optional()])
resource_type = SelectField('类型', choices=[
('', '— 自动识别 —'),
('text', '文本'),
('image', '图片'),
('audio', '音频'),
('video', '视频'),
], validators=[Optional()])
tags = StringField('标签(逗号分隔)', validators=[Optional(), Length(max=512)])
file = FileField('选择文件', validators=[DataRequired(message='请选择文件')])
submit = SubmitField('上传')
class UrlDownloadForm(FlaskForm):
title = StringField('标题', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('描述', validators=[Optional()])
source_url = StringField('下载 URL', validators=[DataRequired()])
resource_type = SelectField('类型', choices=[
('', '— 自动识别 —'),
('text', '文本'),
('image', '图片'),
('audio', '音频'),
('video', '视频'),
], validators=[Optional()])
tags = StringField('标签', validators=[Optional(), Length(max=512)])
submit = SubmitField('开始下载')
class MagnetForm(FlaskForm):
title = StringField('标题', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('描述', validators=[Optional()])
magnet_uri = StringField('磁力链接', validators=[DataRequired()])
resource_type = SelectField('类型', choices=[
('video', '视频'),
('audio', '音频'),
('image', '图片'),
('text', '文本'),
])
tags = StringField('标签', validators=[Optional(), Length(max=512)])
submit = SubmitField('开始下载')
class EditForm(FlaskForm):
title = StringField('标题', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('描述', validators=[Optional()])
tags = StringField('标签', validators=[Optional(), Length(max=512)])
is_public = BooleanField('公开资源')
submit = SubmitField('保存')
# ── 辅助函数 ─────────────────────────────────────────────────────────────────
def _check_setting(key):
return SystemSetting.get(key, 'true') == 'true'
# ── 路由 ─────────────────────────────────────────────────────────────────────
@resources_bp.route('/')
@login_required
def list_resources():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '')
rtype = request.args.get('type', '')
source = request.args.get('source', '')
query = Resource.query.filter_by(user_id=current_user.id)
if q:
query = query.filter(
Resource.title.ilike(f'%{q}%') |
Resource.description.ilike(f'%{q}%') |
Resource.tags.ilike(f'%{q}%')
)
if rtype:
query = query.filter_by(resource_type=rtype)
if source:
query = query.filter_by(source_type=source)
pagination = query.order_by(Resource.created_at.desc()).paginate(
page=page, per_page=24, error_out=False)
counts = {}
for t in ('text', 'image', 'audio', 'video'):
counts[t] = Resource.query.filter_by(
user_id=current_user.id, resource_type=t).count()
counts['total'] = Resource.query.filter_by(user_id=current_user.id).count()
return render_template('user/resources.html',
pagination=pagination, q=q,
rtype=rtype, source=source,
counts=counts)
@resources_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
form = UploadForm()
if form.validate_on_submit():
file_obj = form.file.data
if not allowed_file(file_obj.filename):
flash('不支持的文件类型', 'danger')
return render_template('user/upload.html', form=form, tab='upload')
rtype = form.resource_type.data or guess_resource_type(file_obj.filename)
if not rtype:
flash('无法识别文件类型,请手动选择', 'warning')
return render_template('user/upload.html', form=form, tab='upload')
try:
uname, rel_path, size, mime, orig = save_uploaded_file(file_obj, rtype)
except Exception as e:
flash(f'文件保存失败: {e}', 'danger')
return render_template('user/upload.html', form=form, tab='upload')
res = Resource(
user_id=current_user.id,
title=form.title.data,
description=form.description.data,
resource_type=rtype,
source_type='upload',
filename=uname,
original_name=orig,
file_path=rel_path,
file_size=size,
mime_type=mime,
tags=form.tags.data,
download_status='na'
)
db.session.add(res)
db.session.commit()
flash('文件上传成功', 'success')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/upload.html', form=form, tab='upload')
@resources_bp.route('/url-download', methods=['GET', 'POST'])
@login_required
def url_download():
if not _check_setting('enable_url_download'):
flash('URL 下载功能已关闭', 'warning')
return redirect(url_for('resources.list_resources'))
form = UrlDownloadForm()
if form.validate_on_submit():
rtype = form.resource_type.data or None
res = Resource(
user_id=current_user.id,
title=form.title.data,
description=form.description.data,
resource_type=rtype or 'text',
source_type='url',
source_url=form.source_url.data,
tags=form.tags.data,
download_status='pending'
)
db.session.add(res)
db.session.commit()
start_url_download(
current_app._get_current_object(),
res.id,
form.source_url.data,
rtype,
current_user.id
)
flash('下载任务已启动,请稍后刷新查看进度', 'info')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/upload.html', form=form, tab='url')
@resources_bp.route('/magnet-download', methods=['GET', 'POST'])
@login_required
def magnet_download():
if not _check_setting('enable_magnet'):
flash('磁力下载功能已关闭', 'warning')
return redirect(url_for('resources.list_resources'))
form = MagnetForm()
if form.validate_on_submit():
if not form.magnet_uri.data.startswith('magnet:'):
flash('请输入有效的磁力链接(以 magnet: 开头)', 'danger')
return render_template('user/upload.html', form=form, tab='magnet')
res = Resource(
user_id=current_user.id,
title=form.title.data,
description=form.description.data,
resource_type=form.resource_type.data,
source_type='magnet',
source_url=form.magnet_uri.data,
tags=form.tags.data,
download_status='pending'
)
db.session.add(res)
db.session.commit()
start_magnet_download(
current_app._get_current_object(),
res.id,
form.magnet_uri.data,
form.resource_type.data
)
flash('磁力下载任务已启动(需要安装 aria2c', 'info')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/upload.html', form=form, tab='magnet')
@resources_bp.route('/<int:resource_id>')
@login_required
def detail(resource_id):
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
return render_template('user/detail.html', resource=res)
@resources_bp.route('/<int:resource_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(resource_id):
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
form = EditForm(obj=res)
if form.validate_on_submit():
res.title = form.title.data
res.description = form.description.data
res.tags = form.tags.data
res.is_public = form.is_public.data
db.session.commit()
flash('资源信息已更新', 'success')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/edit.html', form=form, resource=res)
@resources_bp.route('/<int:resource_id>/delete', methods=['POST'])
@login_required
def delete(resource_id):
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
delete_resource_file(res.file_path)
db.session.delete(res)
db.session.commit()
flash('资源已删除', 'success')
return redirect(url_for('resources.list_resources'))
@resources_bp.route('/<int:resource_id>/download')
@login_required
def download(resource_id):
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
if not res.file_path:
flash('文件尚未就绪', 'warning')
return redirect(url_for('resources.detail', resource_id=res.id))
abs_path = os.path.join(current_app.root_path, 'static', res.file_path)
if not os.path.exists(abs_path):
flash('文件不存在', 'danger')
return redirect(url_for('resources.detail', resource_id=res.id))
return send_file(abs_path, as_attachment=True,
download_name=res.original_name or res.filename)
@resources_bp.route('/<int:resource_id>/progress')
@login_required
def progress(resource_id):
"""返回下载进度 JSON轮询接口"""
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
return jsonify({
'status': res.download_status,
'progress': res.download_progress,
'error': res.download_error,
'file_ready': res.file_path is not None and res.download_status == 'done'
})
@resources_bp.route('/<int:resource_id>/preview')
@login_required
def preview(resource_id):
"""直接返回文件内容(用于 iframe 文本预览)"""
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
if not res.file_path or res.resource_type != 'text':
abort(404)
abs_path = os.path.join(current_app.root_path, 'static', res.file_path)
if not os.path.exists(abs_path):
abort(404)
try:
with open(abs_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
except Exception:
abort(500)
return Response(content, mimetype='text/plain; charset=utf-8')

230
app/static/css/style.css Normal file
View File

@@ -0,0 +1,230 @@
/* ═══════════════════════════════════════════════════
个人资料库 — 全局样式
═══════════════════════════════════════════════════ */
:root {
--sidebar-width: 240px;
--sidebar-bg: #1e2a3a;
--sidebar-text: rgba(255,255,255,.85);
--topbar-height: 52px;
--transition: 0.25s ease;
}
/* ── 布局 ────────────────────────────────────────── */
#wrapper { min-height: 100vh; }
/* 侧边栏 */
#sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--sidebar-bg);
transition: width var(--transition), transform var(--transition);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,.2) transparent;
flex-shrink: 0;
z-index: 100;
}
#sidebar.collapsed {
width: 60px;
}
#sidebar.collapsed .fs-5,
#sidebar.collapsed p.text-muted,
#sidebar.collapsed .nav-link span,
#sidebar.collapsed strong {
display: none !important;
}
#sidebar.collapsed .nav-link {
justify-content: center;
padding: .5rem;
}
#sidebar.collapsed .nav-link i { margin: 0 !important; }
/* 侧边栏导航链接 */
#sidebar .nav-link {
color: var(--sidebar-text);
border-radius: 6px;
margin-bottom: 2px;
transition: background var(--transition);
padding: .5rem .75rem;
}
#sidebar .nav-link:hover { background: rgba(255,255,255,.08); }
#sidebar .nav-link.active { background: rgba(255,255,255,.15); color: #fff; }
/* 主内容区 */
#main-content {
min-width: 0;
min-height: 100vh;
background: #f8f9fa;
transition: margin var(--transition);
}
[data-bs-theme="dark"] #main-content { background: #111827; }
/* 顶栏 */
.topbar {
position: sticky;
top: 0;
height: var(--topbar-height);
z-index: 99;
background: #fff !important;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
[data-bs-theme="dark"] .topbar { background: #1f2937 !important; }
/* ── 头像 ────────────────────────────────────────── */
.avatar-circle {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
}
.avatar-circle.avatar-sm { width: 28px; height: 28px; font-size: .75rem; }
.avatar-circle.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
/* ── 认证页面 ────────────────────────────────────── */
.auth-wrapper {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 420px;
border-radius: 16px;
border: none;
}
/* ── 统计卡片 ────────────────────────────────────── */
.stat-card { border-radius: 12px; }
.stat-icon { width: 64px; height: 64px; display: flex; align-items: center; justify-content: center; }
/* ── 资源卡片 ────────────────────────────────────── */
.resource-card {
border-radius: 12px !important;
transition: transform .2s, box-shadow .2s;
overflow: hidden;
}
.resource-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,.12) !important;
}
.resource-thumb {
height: 160px;
overflow: hidden;
}
.resource-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── 拖拽上传区 ──────────────────────────────────── */
.upload-drop-zone {
border: 2px dashed #dee2e6;
border-radius: 12px;
padding: 2.5rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.upload-drop-zone:hover,
.upload-drop-zone.drop-active {
border-color: #0d6efd;
background: #f0f5ff;
}
/* ── 文本预览 ────────────────────────────────────── */
.text-preview {
min-height: 400px;
max-height: 70vh;
overflow: auto;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
background: #fafafa;
}
[data-bs-theme="dark"] .text-preview { background: #1a1a2e; }
.text-preview-toolbar { background: #f8f9fa; }
[data-bs-theme="dark"] .text-preview-toolbar { background: #1f2937; }
/* ── 棋盘格背景(图片透明背景展示)────────────────── */
.bg-checkerboard {
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
/* ── 预览容器 ────────────────────────────────────── */
.preview-container { min-height: 300px; }
/* ── 过滤徽章 ────────────────────────────────────── */
.badge-filter {
padding: .45em .8em;
font-size: .8rem;
cursor: pointer;
transition: opacity .15s;
}
.badge-filter:hover { opacity: .85; }
/* ── Dark mode 支持 ──────────────────────────────── */
[data-bs-theme="dark"] .upload-drop-zone { border-color: #374151; }
[data-bs-theme="dark"] .upload-drop-zone:hover,
[data-bs-theme="dark"] .upload-drop-zone.drop-active {
border-color: #3b82f6;
background: #1e2a3a;
}
[data-bs-theme="dark"] .topbar { border-bottom-color: #374151 !important; }
[data-bs-theme="dark"] .resource-thumb { background-color: #1f2937 !important; }
/* ── 响应式:移动端侧边栏 ────────────────────────── */
@media (max-width: 768px) {
#sidebar {
position: fixed;
transform: translateX(-100%);
z-index: 1050;
}
#sidebar.mobile-open {
transform: translateX(0);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 1040;
}
.sidebar-overlay.active { display: block; }
}
/* ── 滚动条美化 ──────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── 过渡动画 ────────────────────────────────────── */
.fade-in {
animation: fadeIn .3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}

122
app/static/js/main.js Normal file
View File

@@ -0,0 +1,122 @@
/* ═══════════════════════════════════════════════════
个人资料库 — 主脚本
═══════════════════════════════════════════════════ */
document.addEventListener('DOMContentLoaded', () => {
// ── 侧边栏折叠/展开 ──────────────────────────────────────────────────────
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('sidebarToggle');
const overlay = document.createElement('div');
overlay.className = 'sidebar-overlay';
document.body.appendChild(overlay);
if (toggleBtn && sidebar) {
const isMobile = () => window.innerWidth <= 768;
const STORAGE_KEY = 'sidebar_collapsed';
// 恢复桌面端折叠状态
if (!isMobile() && localStorage.getItem(STORAGE_KEY) === 'true') {
sidebar.classList.add('collapsed');
}
toggleBtn.addEventListener('click', () => {
if (isMobile()) {
sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('active');
} else {
sidebar.classList.toggle('collapsed');
localStorage.setItem(STORAGE_KEY,
sidebar.classList.contains('collapsed') ? 'true' : 'false');
}
});
overlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
});
window.addEventListener('resize', () => {
if (!isMobile()) {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
}
});
}
// ── 深色/浅色主题切换 ──────────────────────────────────────────────────────
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
const html = document.documentElement;
const THEME_KEY = 'theme';
function applyTheme(theme) {
html.setAttribute('data-bs-theme', theme);
if (themeIcon) {
themeIcon.className = theme === 'dark' ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
}
localStorage.setItem(THEME_KEY, theme);
}
// 初始化主题
const savedTheme = localStorage.getItem(THEME_KEY) ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
if (themeToggle) {
themeToggle.addEventListener('click', () => {
applyTheme(html.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark');
});
}
// ── 自动关闭 Flash 消息 ───────────────────────────────────────────────────
document.querySelectorAll('.alert.alert-success, .alert.alert-info').forEach(el => {
setTimeout(() => {
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
if (bsAlert) bsAlert.close();
}, 4000);
});
// ── 表单提交防重复点击 ────────────────────────────────────────────────────
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', () => {
const btn = form.querySelector('[type="submit"]');
if (btn && !btn.dataset.noDisable) {
setTimeout(() => { btn.disabled = true; }, 0);
}
});
});
// ── 密码强度指示(注册/修改密码页面)────────────────────────────────────
const pwdInputs = document.querySelectorAll('input[type="password"][id$="Pwd"],' +
'input[type="password"][id$="Password"]');
pwdInputs.forEach(input => {
const meter = document.createElement('div');
meter.className = 'password-strength mt-1';
meter.innerHTML = '<div class="strength-bar d-flex gap-1 mt-1">' +
'<div class="flex-fill rounded" style="height:4px;transition:background .3s"></div>'.repeat(4) +
'</div>';
input.parentElement.appendChild(meter);
input.addEventListener('input', () => {
const val = input.value;
let strength = 0;
if (val.length >= 8) strength++;
if (/[A-Z]/.test(val)) strength++;
if (/[0-9]/.test(val)) strength++;
if (/[^A-Za-z0-9]/.test(val)) strength++;
const colors = ['#ef4444','#f97316','#eab308','#22c55e'];
const bars = meter.querySelectorAll('.flex-fill');
bars.forEach((bar, i) => {
bar.style.background = i < strength ? colors[strength - 1] : '#e5e7eb';
});
});
});
// ── 工具提示初始化 ────────────────────────────────────────────────────────
document.querySelectorAll('[title]').forEach(el => {
new bootstrap.Tooltip(el, { trigger: 'hover', placement: 'top' });
});
});

View File

@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block title %}管理控制台{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">管理控制台</li>
</ol>
{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-speedometer2 me-2"></i>管理控制台</h4>
<!-- 统计卡片 -->
<div class="row g-4 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-primary-subtle text-primary rounded-3 p-3">
<i class="bi bi-people fs-2"></i>
</div>
<div>
<div class="fs-2 fw-bold">{{ total_users }}</div>
<div class="text-muted small">注册用户</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-success-subtle text-success rounded-3 p-3">
<i class="bi bi-collection fs-2"></i>
</div>
<div>
<div class="fs-2 fw-bold">{{ total_resources }}</div>
<div class="text-muted small">资源总数</div>
</div>
</div>
</div>
</div>
{% for t, label, icon, color in [
('text', '文本', 'file-text', 'secondary'),
('image', '图片', 'image', 'info'),
('audio', '音频', 'music-note-beamed', 'warning'),
('video', '视频', 'camera-video', 'danger'),
] %}
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-{{ color }}-subtle text-{{ color }} rounded-3 p-3">
<i class="bi bi-{{ icon }} fs-2"></i>
</div>
<div>
<div class="fs-2 fw-bold">{{ type_stats[t] }}</div>
<div class="text-muted small">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-4">
<!-- 最近用户 -->
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-person-plus me-1"></i>最近注册用户</span>
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-primary">查看全部</a>
</div>
<div class="list-group list-group-flush">
{% for user in recent_users %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<div class="avatar-circle avatar-sm">{{ user.username[0].upper() }}</div>
<div>
<div class="fw-medium">{{ user.username }}</div>
<div class="text-muted small">{{ user.email }}</div>
</div>
</div>
<div class="text-end">
<span class="badge bg-{{ 'danger' if user.is_admin else 'secondary' }}">
{{ '管理员' if user.is_admin else '用户' }}
</span>
<div class="text-muted small">{{ user.created_at | datetime_fmt }}</div>
</div>
</div>
{% else %}
<div class="list-group-item text-muted text-center py-3">暂无用户</div>
{% endfor %}
</div>
</div>
</div>
<!-- 最近资源 -->
<div class="col-lg-7">
<div class="card shadow-sm h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history me-1"></i>最近上传资源</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>标题</th><th>类型</th><th>用户</th><th>时间</th>
</tr>
</thead>
<tbody>
{% for r in recent_resources %}
<tr>
<td>
<a href="{{ url_for('resources.detail', resource_id=r.id) }}"
class="text-decoration-none text-truncate d-block" style="max-width:200px">
{{ r.title }}
</a>
</td>
<td>
<span class="badge bg-{{ r.type_badge_color }}">{{ r.resource_type }}</span>
</td>
<td class="text-muted small">{{ r.owner.username }}</td>
<td class="text-muted small">{{ r.created_at | datetime_fmt }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">暂无资源</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% block title %}系统设置{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">控制台</a></li>
<li class="breadcrumb-item active">系统设置</li>
</ol>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>系统设置</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<h6 class="text-muted text-uppercase small mb-3">基本信息</h6>
<div class="mb-3">
<label class="form-label fw-medium">{{ form.site_name.label.text }}</label>
{{ form.site_name(class='form-control' + (' is-invalid' if form.site_name.errors else '')) }}
{% for e in form.site_name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-4">
<label class="form-label fw-medium">{{ form.site_description.label.text }}</label>
{{ form.site_description(class='form-control', rows=2) }}
</div>
<hr>
<h6 class="text-muted text-uppercase small mb-3">用户与注册</h6>
<div class="mb-4 form-check form-switch">
{{ form.allow_register(class='form-check-input', role='switch') }}
<label class="form-check-label">{{ form.allow_register.label.text }}</label>
<div class="form-text">关闭后新用户无法自行注册,只能由管理员创建</div>
</div>
<hr>
<h6 class="text-muted text-uppercase small mb-3">上传与下载</h6>
<div class="mb-3">
<label class="form-label fw-medium">{{ form.max_upload_mb.label.text }}</label>
<div class="input-group" style="max-width:200px">
{{ form.max_upload_mb(class='form-control') }}
<span class="input-group-text">MB</span>
</div>
{% for e in form.max_upload_mb.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3 form-check form-switch">
{{ form.enable_url_download(class='form-check-input', role='switch') }}
<label class="form-check-label">{{ form.enable_url_download.label.text }}</label>
</div>
<div class="mb-4 form-check form-switch">
{{ form.enable_magnet(class='form-check-input', role='switch') }}
<label class="form-check-label">{{ form.enable_magnet.label.text }}</label>
<div class="form-text">需要服务器安装 <code>aria2c</code></div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>保存设置
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">控制台</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('admin.users') }}">用户管理</a></li>
<li class="breadcrumb-item active">{{ title }}</li>
</ol>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-person-{{ 'plus' if action == 'create' else 'gear' }} me-2"></i>
{{ title }}
</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
{% macro render_field(f, ph='') %}
<div class="mb-3">
<label class="form-label fw-medium">{{ f.label.text }}</label>
{{ f(class='form-control' + (' is-invalid' if f.errors else ''),
placeholder=ph) }}
{% for err in f.errors %}
<div class="invalid-feedback">{{ err }}</div>
{% endfor %}
</div>
{% endmacro %}
{{ render_field(form.username, '用户名') }}
{{ render_field(form.email, 'email@example.com') }}
<div class="mb-3">
<label class="form-label fw-medium">{{ form.password.label.text }}</label>
<div class="input-group">
{{ form.password(class='form-control' + (' is-invalid' if form.password.errors else ''),
placeholder='至少 8 位', id='adminPwd') }}
<button class="btn btn-outline-secondary" type="button"
onclick="togglePwd('adminPwd',this)">
<i class="bi bi-eye"></i>
</button>
</div>
{% for err in form.password.errors %}
<div class="text-danger small">{{ err }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-medium">{{ form.role.label.text }}</label>
{{ form.role(class='form-select') }}
</div>
<div class="mb-4 form-check">
{{ form.is_active(class='form-check-input') }}
<label class="form-check-label">{{ form.is_active.label.text }}</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{ '创建' if action == 'create' else '保存' }}
</button>
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePwd(id, btn) {
const input = document.getElementById(id);
const icon = btn.querySelector('i');
input.type = input.type === 'password' ? 'text' : 'password';
icon.className = input.type === 'text' ? 'bi bi-eye-slash' : 'bi bi-eye';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,157 @@
{% extends 'base.html' %}
{% block title %}用户管理{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">控制台</a></li>
<li class="breadcrumb-item active">用户管理</li>
</ol>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-people me-2"></i>用户管理</h4>
<a href="{{ url_for('admin.user_create') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>新建用户
</a>
</div>
<!-- 搜索 -->
<form method="GET" class="mb-3">
<div class="input-group" style="max-width:400px">
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="搜索用户名或邮箱">
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
{% if q %}
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-danger">
<i class="bi bi-x"></i>
</a>
{% endif %}
</div>
</form>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th><th>用户名</th><th>邮箱</th><th>角色</th>
<th>状态</th><th>注册时间</th><th>最后登录</th><th>操作</th>
</tr>
</thead>
<tbody>
{% for user in pagination.items %}
<tr class="{{ 'table-secondary' if not user.is_active else '' }}">
<td class="text-muted small">{{ user.id }}</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="avatar-circle avatar-sm">{{ user.username[0].upper() }}</div>
{{ user.username }}
{% if user.id == current_user.id %}
<span class="badge bg-info text-dark small"></span>
{% endif %}
</div>
</td>
<td class="text-muted small">{{ user.email }}</td>
<td>
<span class="badge bg-{{ 'danger' if user.is_admin else 'secondary' }}">
{{ '管理员' if user.is_admin else '普通用户' }}
</span>
</td>
<td>
<span class="badge bg-{{ 'success' if user.is_active else 'warning text-dark' }}">
{{ '正常' if user.is_active else '已禁用' }}
</span>
</td>
<td class="text-muted small">{{ user.created_at | datetime_fmt }}</td>
<td class="text-muted small">{{ user.last_login | datetime_fmt if user.last_login else '—' }}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.user_edit', user_id=user.id) }}"
class="btn btn-outline-primary"><i class="bi bi-pencil"></i></a>
{% if user.id != current_user.id %}
<form action="{{ url_for('admin.user_toggle', user_id=user.id) }}"
method="POST" class="d-inline">
{{ csrf_token_input() }}
<button class="btn btn-outline-{{ 'warning' if user.is_active else 'success' }}"
title="{{ '禁用' if user.is_active else '启用' }}">
<i class="bi bi-{{ 'slash-circle' if user.is_active else 'check-circle' }}"></i>
</button>
</form>
<button class="btn btn-outline-danger"
onclick="confirmDelete({{ user.id }}, '{{ user.username }}')"
title="删除">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">暂无用户</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer d-flex justify-content-center">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.prev_num }}&q={{ q }}">«</a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {{ 'active' if p == pagination.page else '' }}">
<a class="page-link" href="?page={{ p }}&q={{ q }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.next_num }}&q={{ q }}">»</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
<!-- 删除确认模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title text-danger"><i class="bi bi-exclamation-triangle me-1"></i>确认删除</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="deleteModalBody"></div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">取消</button>
<form id="deleteForm" method="POST">
{{ csrf_token_input() }}
<button type="submit" class="btn btn-danger btn-sm">确认删除</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function confirmDelete(id, name) {
document.getElementById('deleteModalBody').textContent =
`确定要删除用户「${name}」及其所有资源吗?此操作不可恢复。`;
document.getElementById('deleteForm').action = `/admin/users/${id}/delete`;
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block title %}修改密码{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
<li class="breadcrumb-item active">修改密码</li>
</ol>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>修改密码</h5></div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
{% macro pw_field(f, label, id) %}
<div class="mb-3">
<label class="form-label">{{ label }}</label>
<div class="input-group">
{{ f(class='form-control' + (' is-invalid' if f.errors else ''),
id=id) }}
<button class="btn btn-outline-secondary" type="button"
onclick="togglePwd('{{ id }}', this)">
<i class="bi bi-eye"></i>
</button>
</div>
{% for err in f.errors %}
<div class="text-danger small">{{ err }}</div>
{% endfor %}
</div>
{% endmacro %}
{{ pw_field(form.old_password, '当前密码', 'oldPwd') }}
{{ pw_field(form.new_password, '新密码(至少 8 位)', 'newPwd') }}
{{ pw_field(form.new_password2, '确认新密码', 'newPwd2') }}
<button type="submit" class="btn btn-primary w-100">保存修改</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePwd(id, btn) {
const input = document.getElementById(id);
const icon = btn.querySelector('i');
input.type = input.type === 'password' ? 'text' : 'password';
icon.className = input.type === 'text' ? 'bi bi-eye-slash' : 'bi bi-eye';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% block title %}登录{% endblock %}
{% block content %}
<div class="card auth-card shadow-lg">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-collection-play text-primary" style="font-size:3rem"></i>
<h2 class="fw-bold mt-2">个人资料库</h2>
<p class="text-muted">登录以管理您的资源</p>
</div>
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label">{{ form.username.label.text }}</label>
{{ form.username(class='form-control form-control-lg' +
(' is-invalid' if form.username.errors else ''),
placeholder='请输入用户名') }}
{% for err in form.username.errors %}
<div class="invalid-feedback">{{ err }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label">{{ form.password.label.text }}</label>
<div class="input-group">
{{ form.password(class='form-control form-control-lg' +
(' is-invalid' if form.password.errors else ''),
placeholder='请输入密码', id='loginPassword') }}
<button class="btn btn-outline-secondary" type="button"
onclick="togglePwd('loginPassword', this)">
<i class="bi bi-eye"></i>
</button>
{% for err in form.password.errors %}
<div class="invalid-feedback">{{ err }}</div>
{% endfor %}
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="form-check">
{{ form.remember(class='form-check-input') }}
<label class="form-check-label" for="remember">记住我</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-box-arrow-in-right me-1"></i>登录
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted mb-0">
还没有账号?
<a href="{{ url_for('auth.register') }}" class="text-decoration-none">立即注册</a>
</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePwd(id, btn) {
const input = document.getElementById(id);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends 'base.html' %}
{% block title %}注册{% endblock %}
{% block content %}
<div class="card auth-card shadow-lg">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-person-plus text-primary" style="font-size:3rem"></i>
<h2 class="fw-bold mt-2">创建账号</h2>
</div>
<form method="POST" action="{{ url_for('auth.register') }}" novalidate>
{{ form.hidden_tag() }}
{% macro field(f, placeholder='', type='text') %}
<div class="mb-3">
<label class="form-label">{{ f.label.text }}</label>
{{ f(class='form-control' + (' is-invalid' if f.errors else ''),
placeholder=placeholder, type=type) }}
{% for err in f.errors %}
<div class="invalid-feedback">{{ err }}</div>
{% endfor %}
</div>
{% endmacro %}
{{ field(form.username, '3-64 位用户名') }}
{{ field(form.email, 'your@email.com', 'email') }}
<div class="mb-3">
<label class="form-label">密码</label>
<div class="input-group">
{{ form.password(class='form-control' + (' is-invalid' if form.password.errors else ''),
placeholder='至少 8 位', id='regPwd') }}
<button class="btn btn-outline-secondary" type="button"
onclick="togglePwd('regPwd',this)">
<i class="bi bi-eye"></i>
</button>
</div>
{% for err in form.password.errors %}
<div class="text-danger small">{{ err }}</div>
{% endfor %}
</div>
<div class="mb-4">
<label class="form-label">确认密码</label>
{{ form.password2(class='form-control' + (' is-invalid' if form.password2.errors else ''),
placeholder='再次输入密码') }}
{% for err in form.password2.errors %}
<div class="invalid-feedback">{{ err }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-success btn-lg w-100">
<i class="bi bi-check-circle me-1"></i>注册
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted mb-0">
已有账号?<a href="{{ url_for('auth.login') }}" class="text-decoration-none">立即登录</a>
</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePwd(id, btn) {
const input = document.getElementById(id);
const icon = btn.querySelector('i');
input.type = input.type === 'password' ? 'text' : 'password';
icon.className = input.type === 'text' ? 'bi bi-eye-slash' : 'bi bi-eye';
}
</script>
{% endblock %}

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

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %} — {{ site_name|default('个人资料库') }}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- Custom -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<!-- ── 侧边栏布局 ─────────────────────────────────────────────── -->
<div class="d-flex" id="wrapper">
<!-- 侧边栏 -->
<nav id="sidebar" class="sidebar d-flex flex-column flex-shrink-0 p-3 text-white">
<a href="{{ url_for('main.index') }}"
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<i class="bi bi-collection-play fs-4 me-2"></i>
<span class="fs-5 fw-semibold">{{ site_name|default('个人资料库') }}</span>
</a>
<hr>
{% if current_user.is_admin %}
<p class="text-uppercase text-muted small px-1 mb-1">管理</p>
<ul class="nav nav-pills flex-column mb-2">
<li class="nav-item">
<a href="{{ url_for('admin.dashboard') }}"
class="nav-link text-white {% if request.endpoint == 'admin.dashboard' %}active{% endif %}">
<i class="bi bi-speedometer2 me-2"></i>控制台
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin.users') }}"
class="nav-link text-white {% if request.endpoint and 'admin.user' in request.endpoint %}active{% endif %}">
<i class="bi bi-people me-2"></i>用户管理
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('admin.settings') }}"
class="nav-link text-white {% if request.endpoint == 'admin.settings' %}active{% endif %}">
<i class="bi bi-gear me-2"></i>系统设置
</a>
</li>
</ul>
<hr>
{% endif %}
<p class="text-uppercase text-muted small px-1 mb-1">资料库</p>
<ul class="nav nav-pills flex-column mb-auto">
<li>
<a href="{{ url_for('resources.list_resources') }}"
class="nav-link text-white {% if request.endpoint == 'resources.list_resources' %}active{% endif %}">
<i class="bi bi-grid me-2"></i>全部资源
</a>
</li>
<li>
<a href="{{ url_for('resources.list_resources') }}?type=text"
class="nav-link text-white">
<i class="bi bi-file-text me-2"></i>文本
</a>
</li>
<li>
<a href="{{ url_for('resources.list_resources') }}?type=image"
class="nav-link text-white">
<i class="bi bi-image me-2"></i>图片
</a>
</li>
<li>
<a href="{{ url_for('resources.list_resources') }}?type=audio"
class="nav-link text-white">
<i class="bi bi-music-note-beamed me-2"></i>音频
</a>
</li>
<li>
<a href="{{ url_for('resources.list_resources') }}?type=video"
class="nav-link text-white">
<i class="bi bi-camera-video me-2"></i>视频
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('resources.upload') }}"
class="nav-link text-white {% if request.endpoint == 'resources.upload' %}active{% endif %}">
<i class="bi bi-cloud-upload me-2"></i>上传资源
</a>
</li>
</ul>
<hr>
<!-- 用户信息 -->
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
data-bs-toggle="dropdown">
<div class="avatar-circle me-2">
{{ current_user.username[0].upper() }}
</div>
<strong class="text-truncate" style="max-width:120px">{{ current_user.username }}</strong>
</a>
<ul class="dropdown-menu dropdown-menu-dark text-small shadow">
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}">
<i class="bi bi-person me-1"></i>个人资料
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key me-1"></i>修改密码
</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('auth.logout') }}" method="post" class="d-inline">
{{ csrf_token_input() }}
<button type="submit" class="dropdown-item text-danger">
<i class="bi bi-box-arrow-right me-1"></i>退出登录
</button>
</form>
</li>
</ul>
</div>
</nav>
<!-- 主内容区 -->
<div id="main-content" class="flex-grow-1">
<!-- 顶栏 -->
<header class="topbar d-flex align-items-center px-4 py-2 border-bottom bg-white">
<button class="btn btn-sm btn-outline-secondary me-3" id="sidebarToggle">
<i class="bi bi-list"></i>
</button>
<!-- 面包屑 -->
<nav aria-label="breadcrumb" class="flex-grow-1">
{% block breadcrumb %}{% endblock %}
</nav>
<!-- 主题切换 -->
<button class="btn btn-sm btn-outline-secondary" id="themeToggle" title="切换主题">
<i class="bi bi-sun-fill" id="themeIcon"></i>
</button>
</header>
<div class="p-4">
<!-- Flash 消息 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
</div>
{% else %}
<!-- 未登录:居中布局 -->
<div class="auth-wrapper d-flex align-items-center justify-content-center min-vh-100">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="auth-flash position-fixed top-0 start-50 translate-middle-x mt-3 w-auto" style="z-index:9999">
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show mb-2">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{{ self.content() }}
</div>
{% endif %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}403 禁止访问{% endblock %}
{% block content %}
<div class="text-center py-5">
<div class="display-1 fw-bold text-danger">403</div>
<h3 class="mt-3">禁止访问</h3>
<p class="text-muted">您没有权限访问此页面</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary mt-2">
<i class="bi bi-house me-1"></i>返回首页
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}404 页面不存在{% endblock %}
{% block content %}
<div class="text-center py-5">
<div class="display-1 fw-bold text-secondary">404</div>
<h3 class="mt-3">页面不存在</h3>
<p class="text-muted">您访问的页面不存在或已被删除</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary mt-2">
<i class="bi bi-house me-1"></i>返回首页
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}文件过大{% endblock %}
{% block content %}
<div class="text-center py-5">
<div class="display-1 fw-bold text-warning">413</div>
<h3 class="mt-3">文件过大</h3>
<p class="text-muted">上传的文件超过了系统允许的最大大小限制</p>
<a href="{{ url_for('resources.upload') }}" class="btn btn-primary mt-2">
<i class="bi bi-arrow-left me-1"></i>返回上传页
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}服务器错误{% endblock %}
{% block content %}
<div class="text-center py-5">
<div class="display-1 fw-bold text-danger">500</div>
<h3 class="mt-3">服务器内部错误</h3>
<p class="text-muted">服务器遇到了一个错误,请稍后再试</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary mt-2">
<i class="bi bi-house me-1"></i>返回首页
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,252 @@
{% extends 'base.html' %}
{% block title %}{{ resource.title }}{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('resources.list_resources') }}">我的资源</a></li>
<li class="breadcrumb-item active text-truncate" style="max-width:200px">{{ resource.title }}</li>
</ol>
{% endblock %}
{% block content %}
<div class="row g-4">
<!-- ── 左:信息面板 ── -->
<div class="col-lg-4 col-xl-3">
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5 class="card-title">{{ resource.title }}</h5>
{% if resource.description %}
<p class="text-muted small">{{ resource.description }}</p>
{% endif %}
<table class="table table-sm table-borderless small mb-0">
<tr><td class="text-muted">类型</td>
<td><span class="badge bg-{{ resource.type_badge_color }}">
{{ resource.resource_type }}</span></td></tr>
<tr><td class="text-muted">来源</td>
<td>{{ resource.source_type }}</td></tr>
{% if resource.original_name %}
<tr><td class="text-muted">文件名</td>
<td class="text-truncate" style="max-width:140px"
title="{{ resource.original_name }}">
{{ resource.original_name }}</td></tr>
{% endif %}
{% if resource.file_size %}
<tr><td class="text-muted">大小</td>
<td>{{ resource.file_size | filesize }}</td></tr>
{% endif %}
{% if resource.mime_type %}
<tr><td class="text-muted">MIME</td>
<td class="text-truncate" style="max-width:140px">
{{ resource.mime_type }}</td></tr>
{% endif %}
<tr><td class="text-muted">上传时间</td>
<td>{{ resource.created_at | datetime_fmt }}</td></tr>
</table>
{% if resource.tag_list %}
<div class="mt-2">
{% for tag in resource.tag_list %}
<span class="badge bg-light text-dark border me-1">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-footer d-flex gap-2 flex-wrap">
{% if resource.file_path and resource.download_status in ('na','done') %}
<a href="{{ url_for('resources.download', resource_id=resource.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-download me-1"></i>下载
</a>
{% endif %}
<a href="{{ url_for('resources.edit', resource_id=resource.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>编辑
</a>
<form action="{{ url_for('resources.delete', resource_id=resource.id) }}"
method="POST" class="d-inline"
onsubmit="return confirm('确认删除此资源?')">
{{ csrf_token_input() }}
<button class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>删除
</button>
</form>
</div>
</div>
<!-- 来源信息 -->
{% if resource.source_url %}
<div class="card shadow-sm">
<div class="card-body small">
<div class="text-muted mb-1">来源链接</div>
<div class="text-break" style="word-break:break-all;font-size:0.75rem">
{{ resource.source_url | truncate_mid(80) }}
</div>
</div>
</div>
{% endif %}
</div>
<!-- ── 右:预览区 ── -->
<div class="col-lg-8 col-xl-9">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-{{ resource.type_icon }} me-2"></i>预览</span>
{% if resource.download_status in ('pending','downloading') %}
<span class="badge bg-info" id="statusBadge">
<i class="bi bi-arrow-down-circle me-1"></i>
下载中 <span id="progressPct">{{ resource.download_progress }}</span>%
</span>
{% elif resource.download_status == 'failed' %}
<span class="badge bg-danger">
<i class="bi bi-exclamation-triangle me-1"></i>下载失败
</span>
{% endif %}
</div>
<div class="card-body p-0 preview-container">
{% if resource.download_status in ('pending','downloading') %}
<!-- 下载进度 -->
<div class="d-flex flex-column align-items-center justify-content-center p-5" id="downloadingView">
<div class="spinner-border text-primary mb-3"></div>
<p class="text-muted mb-2">正在下载,请稍候…</p>
<div class="progress w-50" style="height:8px">
<div class="progress-bar progress-bar-striped progress-bar-animated"
id="progressBar" style="width:{{ resource.download_progress }}%"></div>
</div>
<p class="text-muted small mt-2" id="downloadError"></p>
</div>
{% elif resource.download_status == 'failed' %}
<div class="d-flex flex-column align-items-center justify-content-center p-5 text-danger">
<i class="bi bi-exclamation-triangle fs-1 mb-2"></i>
<p>下载失败</p>
{% if resource.download_error %}
<p class="text-muted small">{{ resource.download_error }}</p>
{% endif %}
</div>
{% elif resource.file_path %}
{%- set file_url = url_for('static', filename=resource.file_path) -%}
{% if resource.resource_type == 'image' %}
<div class="text-center p-3 bg-checkerboard">
<img src="{{ file_url }}" class="img-fluid rounded" style="max-height:70vh"
alt="{{ resource.title }}">
</div>
{% elif resource.resource_type == 'audio' %}
<div class="d-flex flex-column align-items-center justify-content-center p-5">
<i class="bi bi-music-note-beamed text-warning mb-3" style="font-size:5rem"></i>
<p class="fw-medium mb-3">{{ resource.original_name or resource.title }}</p>
<audio controls class="w-100" style="max-width:600px">
<source src="{{ file_url }}" type="{{ resource.mime_type or 'audio/mpeg' }}">
您的浏览器不支持 HTML5 音频播放器
</audio>
</div>
{% elif resource.resource_type == 'video' %}
<div class="ratio ratio-16x9">
<video controls class="rounded-bottom">
<source src="{{ file_url }}" type="{{ resource.mime_type or 'video/mp4' }}">
您的浏览器不支持 HTML5 视频播放器
</video>
</div>
{% elif resource.resource_type == 'text' %}
<div class="text-preview-toolbar p-2 border-bottom d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="changeFont(1)">A+</button>
<button class="btn btn-sm btn-outline-secondary" onclick="changeFont(-1)">A-</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleWrap()">换行</button>
</div>
<div class="text-preview p-4" id="textContent">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>加载中…
</div>
</div>
{% else %}
<div class="d-flex flex-column align-items-center justify-content-center p-5 text-muted">
<i class="bi bi-file-earmark fs-1 mb-2"></i>
<p>该文件类型暂不支持在线预览</p>
<a href="{{ url_for('resources.download', resource_id=resource.id) }}"
class="btn btn-primary mt-2">
<i class="bi bi-download me-1"></i>下载文件
</a>
</div>
{% endif %}
{% else %}
<div class="d-flex flex-column align-items-center justify-content-center p-5 text-muted">
<i class="bi bi-hourglass fs-1 mb-2"></i>
<p>文件尚未就绪</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── 文本内容加载 ────────────────────────────────────────────────────────────
{% if resource.resource_type == 'text' and resource.file_path and resource.download_status in ('na','done') %}
fetch("{{ url_for('resources.preview', resource_id=resource.id) }}")
.then(r => r.text())
.then(text => {
const pre = document.createElement('pre');
pre.id = 'textPre';
pre.style.cssText = 'margin:0;font-family:monospace;font-size:14px;white-space:pre-wrap;word-break:break-all';
pre.textContent = text;
document.getElementById('textContent').innerHTML = '';
document.getElementById('textContent').appendChild(pre);
})
.catch(() => {
document.getElementById('textContent').innerHTML =
'<div class="text-danger p-3">文件加载失败</div>';
});
let fontSize = 14;
function changeFont(delta) {
fontSize = Math.max(10, Math.min(24, fontSize + delta));
const pre = document.getElementById('textPre');
if (pre) pre.style.fontSize = fontSize + 'px';
}
function toggleWrap() {
const pre = document.getElementById('textPre');
if (!pre) return;
pre.style.whiteSpace = pre.style.whiteSpace === 'pre' ? 'pre-wrap' : 'pre';
}
{% endif %}
// ── 下载进度轮询 ────────────────────────────────────────────────────────────
{% if resource.download_status in ('pending','downloading') %}
(function poll() {
fetch("{{ url_for('resources.progress', resource_id=resource.id) }}")
.then(r => r.json())
.then(data => {
const bar = document.getElementById('progressBar');
const pct = document.getElementById('progressPct');
if (bar) bar.style.width = data.progress + '%';
if (pct) pct.textContent = data.progress;
if (data.status === 'done') {
location.reload();
} else if (data.status === 'failed') {
const errEl = document.getElementById('downloadError');
if (errEl) errEl.textContent = data.error || '下载失败';
const badge = document.getElementById('statusBadge');
if (badge) { badge.className = 'badge bg-danger'; badge.textContent = '下载失败'; }
} else {
setTimeout(poll, 2000);
}
})
.catch(() => setTimeout(poll, 3000));
})();
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends 'base.html' %}
{% block title %}编辑 — {{ resource.title }}{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('resources.list_resources') }}">我的资源</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('resources.detail', resource_id=resource.id) }}">{{ resource.title }}</a></li>
<li class="breadcrumb-item active">编辑</li>
</ol>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-pencil me-2"></i>编辑资源信息</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label fw-medium">标题</label>
{{ form.title(class='form-control' + (' is-invalid' if form.title.errors else '')) }}
{% for e in form.title.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-medium">描述</label>
{{ form.description(class='form-control', rows=3) }}
</div>
<div class="mb-3">
<label class="form-label fw-medium">标签(逗号分隔)</label>
{{ form.tags(class='form-control', placeholder='tag1, tag2') }}
</div>
<div class="mb-4 form-check form-switch">
{{ form.is_public(class='form-check-input', role='switch') }}
<label class="form-check-label">公开资源</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>保存
</button>
<a href="{{ url_for('resources.detail', resource_id=resource.id) }}"
class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}个人资料{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">个人资料</li>
</ol>
{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-md-4">
<div class="card shadow-sm text-center p-4">
<div class="avatar-circle avatar-lg mx-auto mb-3">
{{ current_user.username[0].upper() }}
</div>
<h5>{{ current_user.username }}</h5>
<p class="text-muted small">{{ current_user.email }}</p>
<span class="badge bg-{{ 'danger' if current_user.is_admin else 'secondary' }} mb-3">
{{ '管理员' if current_user.is_admin else '普通用户' }}
</span>
<a href="{{ url_for('auth.change_password') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-key me-1"></i>修改密码
</a>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm h-100">
<div class="card-header"><h6 class="mb-0">资源统计</h6></div>
<div class="card-body">
<div class="row g-3 text-center">
<div class="col-6 col-sm-3">
<div class="fs-1 fw-bold text-primary">{{ total }}</div>
<div class="text-muted small">全部</div>
</div>
{% for t, label, color in [('text','文本','secondary'),('image','图片','success'),
('audio','音频','warning'),('video','视频','danger')] %}
<div class="col-6 col-sm-3">
<div class="fs-1 fw-bold text-{{ color }}">{{ by_type[t] }}</div>
<div class="text-muted small">{{ label }}</div>
</div>
{% endfor %}
</div>
<hr>
<div class="row g-2 text-muted small">
<div class="col-6">
<i class="bi bi-calendar me-1"></i>注册时间:
{{ current_user.created_at | datetime_fmt }}
</div>
<div class="col-6">
<i class="bi bi-clock me-1"></i>最后登录:
{{ current_user.last_login | datetime_fmt if current_user.last_login else '首次登录' }}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,148 @@
{% extends 'base.html' %}
{% block title %}我的资源{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">我的资源</li>
</ol>
{% endblock %}
{% block content %}
<!-- 顶部操作栏 -->
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-grid me-2"></i>我的资源</h4>
<a href="{{ url_for('resources.upload') }}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加资源
</a>
</div>
<!-- 统计徽章 -->
<div class="d-flex flex-wrap gap-2 mb-3">
{% for t, label, color in [('','全部','secondary'),('text','文本','light'),
('image','图片','success'),('audio','音频','warning'),
('video','视频','danger')] %}
<a href="{{ url_for('resources.list_resources', type=t, q=q) }}"
class="badge text-decoration-none badge-filter
{{ 'bg-primary' if rtype == t else 'bg-' + color + (' text-dark' if color in ['light','warning'] else '') }}">
{{ label }}
<span class="ms-1">{{ counts[t if t else 'total'] }}</span>
</a>
{% endfor %}
</div>
<!-- 搜索 -->
<form method="GET" class="mb-4">
<input type="hidden" name="type" value="{{ rtype }}">
<div class="input-group" style="max-width:480px">
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="搜索标题、描述、标签…">
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
{% if q %}
<a href="{{ url_for('resources.list_resources', type=rtype) }}"
class="btn btn-outline-danger"><i class="bi bi-x"></i></a>
{% endif %}
</div>
</form>
<!-- 资源卡片网格 -->
{% if pagination.items %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-4 g-4 mb-4">
{% for res in pagination.items %}
<div class="col">
<div class="card resource-card h-100 shadow-sm border-0">
<!-- 缩略图 / 类型图标区 -->
<div class="resource-thumb d-flex align-items-center justify-content-center
bg-{{ res.type_badge_color }}-subtle position-relative">
{% if res.resource_type == 'image' and res.file_path and res.download_status != 'downloading' %}
<img src="{{ url_for('static', filename=res.file_path) }}"
class="resource-thumb-img" alt="{{ res.title }}" loading="lazy">
{% else %}
<i class="bi bi-{{ res.type_icon }} text-{{ res.type_badge_color }}" style="font-size:3rem"></i>
{% endif %}
<!-- 下载状态徽章 -->
{% if res.download_status in ('pending','downloading') %}
<span class="badge bg-info position-absolute top-0 end-0 m-2">
<i class="bi bi-arrow-down-circle me-1"></i>下载中 {{ res.download_progress }}%
</span>
{% elif res.download_status == 'failed' %}
<span class="badge bg-danger position-absolute top-0 end-0 m-2">
<i class="bi bi-exclamation-triangle me-1"></i>失败
</span>
{% endif %}
</div>
<div class="card-body d-flex flex-column p-3">
<h6 class="card-title text-truncate mb-1" title="{{ res.title }}">{{ res.title }}</h6>
<div class="d-flex gap-1 mb-2 flex-wrap">
<span class="badge bg-{{ res.type_badge_color }}">{{ res.resource_type }}</span>
<span class="badge bg-light text-dark border">{{ res.source_type }}</span>
{% if res.file_size %}
<span class="badge bg-light text-muted border">{{ res.file_size | filesize }}</span>
{% endif %}
</div>
{% if res.description %}
<p class="card-text text-muted small text-truncate mb-2">{{ res.description }}</p>
{% endif %}
<div class="mt-auto d-flex gap-2">
<a href="{{ url_for('resources.detail', resource_id=res.id) }}"
class="btn btn-sm btn-outline-primary flex-grow-1">
<i class="bi bi-eye me-1"></i>查看
</a>
<form action="{{ url_for('resources.delete', resource_id=res.id) }}"
method="POST" class="d-inline"
onsubmit="return confirm('确认删除「{{ res.title }}」?')">
{{ csrf_token_input() }}
<button class="btn btn-sm btn-outline-danger" title="删除">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
<div class="card-footer text-muted small py-1 px-3">
{{ res.created_at | datetime_fmt }}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<nav class="d-flex justify-content-center">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.prev_num }}&q={{ q }}&type={{ rtype }}">«</a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {{ 'active' if p == pagination.page else '' }}">
<a class="page-link" href="?page={{ p }}&q={{ q }}&type={{ rtype }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.next_num }}&q={{ q }}&type={{ rtype }}">»</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size:4rem"></i>
<h5 class="mt-3">暂无资源</h5>
<p>点击右上角「添加资源」开始添加</p>
<a href="{{ url_for('resources.upload') }}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加资源
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,334 @@
{% extends 'base.html' %}
{% block title %}添加资源{% endblock %}
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('resources.list_resources') }}">我的资源</a></li>
<li class="breadcrumb-item active">添加资源</li>
</ol>
{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-plus-circle me-2"></i>添加资源</h4>
<!-- Tab 导航 -->
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
<li class="nav-item">
<button class="nav-link {{ 'active' if tab == 'upload' }}"
data-bs-toggle="tab" data-bs-target="#tabUpload" type="button">
<i class="bi bi-upload me-1"></i>本地上传
</button>
</li>
<li class="nav-item">
<button class="nav-link {{ 'active' if tab == 'url' }}"
id="urlTabBtn"
data-bs-toggle="tab" data-bs-target="#tabUrl" type="button">
<i class="bi bi-link-45deg me-1"></i>URL 下载
</button>
</li>
<li class="nav-item">
<button class="nav-link {{ 'active' if tab == 'magnet' }}"
id="magnetTabBtn"
data-bs-toggle="tab" data-bs-target="#tabMagnet" type="button">
<i class="bi bi-magnet me-1"></i>磁力下载
</button>
</li>
</ul>
<div class="tab-content">
<!-- ── 本地上传 ── -->
<div class="tab-pane fade {{ 'show active' if tab == 'upload' else '' }}" id="tabUpload">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body p-4">
<form method="POST" action="{{ url_for('resources.upload') }}"
enctype="multipart/form-data" novalidate id="uploadForm">
{{ form.hidden_tag() if tab == 'upload' else '' }}
{% if tab == 'upload' %}{{ form.hidden_tag() }}{% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-medium">标题 <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="资源标题" required
{% if tab=='upload' and form.title.data %}value="{{ form.title.data }}"{% endif %}>
</div>
<div class="mb-3">
<label class="form-label fw-medium">选择文件 <span class="text-danger">*</span></label>
<!-- 拖拽上传区 -->
<div class="upload-drop-zone" id="dropZone">
<i class="bi bi-cloud-arrow-up fs-1 text-muted"></i>
<p class="mt-2 mb-1">拖拽文件到此处,或点击选择</p>
<p class="text-muted small">支持文本、图片、音频、视频</p>
<input type="file" name="file" id="fileInput" class="d-none"
accept=".txt,.md,.csv,.json,.xml,.log,.html,.htm,
.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg,
.mp3,.wav,.ogg,.flac,.m4a,.aac,
.mp4,.webm,.avi,.mkv,.mov,.wmv">
</div>
<div id="filePreview" class="mt-2 d-none">
<div class="d-flex align-items-center gap-2 p-2 border rounded">
<i class="bi bi-file-earmark fs-4 text-primary" id="fileIcon"></i>
<div class="flex-grow-1 overflow-hidden">
<div class="text-truncate fw-medium" id="fileName"></div>
<div class="text-muted small" id="fileSize"></div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="clearFile()"><i class="bi bi-x"></i></button>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-medium">类型</label>
<select name="resource_type" class="form-select">
<option value="">— 自动识别 —</option>
<option value="text">文本</option>
<option value="image">图片</option>
<option value="audio">音频</option>
<option value="video">视频</option>
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-medium">标签</label>
<input type="text" name="tags" class="form-control"
placeholder="用逗号分隔多个标签">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"
placeholder="可选描述"></textarea>
</div>
<!-- 上传进度 -->
<div id="uploadProgress" class="d-none mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="small">上传中…</span>
<span class="small" id="uploadPct">0%</span>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
id="uploadBar" style="width:0%"></div>
</div>
</div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-cloud-upload me-1"></i>立即上传
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- ── URL 下载 ── -->
<div class="tab-pane fade {{ 'show active' if tab == 'url' else '' }}" id="tabUrl">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
填写 URL 后,系统将在后台下载文件,可在资源详情页查看进度。
</div>
<form method="POST" action="{{ url_for('resources.url_download') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-medium">标题 <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="资源标题" required>
</div>
<div class="mb-3">
<label class="form-label fw-medium">下载 URL <span class="text-danger">*</span></label>
<input type="url" name="source_url" class="form-control"
placeholder="https://example.com/file.mp4" required>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-medium">类型</label>
<select name="resource_type" class="form-select">
<option value="">— 自动识别 —</option>
<option value="text">文本</option>
<option value="image">图片</option>
<option value="audio">音频</option>
<option value="video">视频</option>
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-medium">标签</label>
<input type="text" name="tags" class="form-control" placeholder="逗号分隔">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"></textarea>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-cloud-download me-1"></i>开始下载
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- ── 磁力下载 ── -->
<div class="tab-pane fade {{ 'show active' if tab == 'magnet' else '' }}" id="tabMagnet">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="alert alert-warning small">
<i class="bi bi-exclamation-triangle me-1"></i>
磁力下载需要服务器安装 <strong>aria2c</strong>。下载任务在后台进行,可在资源详情页查看进度。
</div>
<form method="POST" action="{{ url_for('resources.magnet_download') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-medium">标题 <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="资源标题" required>
</div>
<div class="mb-3">
<label class="form-label fw-medium">磁力链接 <span class="text-danger">*</span></label>
<textarea name="magnet_uri" class="form-control" rows="3"
placeholder="magnet:?xt=urn:btih:..." required></textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-medium">类型</label>
<select name="resource_type" class="form-select">
<option value="video">视频</option>
<option value="audio">音频</option>
<option value="image">图片</option>
<option value="text">文本</option>
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-medium">标签</label>
<input type="text" name="tags" class="form-control" placeholder="逗号分隔">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"></textarea>
</div>
<button type="submit" class="btn btn-warning w-100">
<i class="bi bi-magnet me-1"></i>开始磁力下载
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div><!-- /tab-content -->
{% endblock %}
{% block scripts %}
<script>
// ── 拖拽上传区 ──────────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
['dragenter','dragover'].forEach(e =>
dropZone.addEventListener(e, ev => {
ev.preventDefault();
dropZone.classList.add('drop-active');
})
);
['dragleave','drop'].forEach(e =>
dropZone.addEventListener(e, ev => {
ev.preventDefault();
dropZone.classList.remove('drop-active');
})
);
dropZone.addEventListener('drop', ev => {
const dt = ev.dataTransfer;
if (dt.files.length) {
fileInput.files = dt.files;
showFilePreview(dt.files[0]);
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) showFilePreview(fileInput.files[0]);
});
}
function showFilePreview(file) {
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = formatSize(file.size);
document.getElementById('filePreview').classList.remove('d-none');
dropZone.classList.add('d-none');
// 自动填入标题
const titleInput = document.querySelector('input[name="title"]');
if (titleInput && !titleInput.value) {
titleInput.value = file.name.replace(/\.[^.]+$/, '');
}
}
function clearFile() {
fileInput.value = '';
document.getElementById('filePreview').classList.add('d-none');
dropZone.classList.remove('d-none');
}
function formatSize(bytes) {
const units = ['B','KB','MB','GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return `${bytes.toFixed(1)} ${units[i]}`;
}
// ── 上传进度XHR────────────────────────────────────────────────────────────
const uploadForm = document.getElementById('uploadForm');
if (uploadForm) {
uploadForm.addEventListener('submit', function(e) {
if (!fileInput || !fileInput.files.length) return; // 让浏览器验证
e.preventDefault();
const bar = document.getElementById('uploadBar');
const pct = document.getElementById('uploadPct');
document.getElementById('uploadProgress').classList.remove('d-none');
document.getElementById('submitBtn').disabled = true;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', ev => {
if (ev.lengthComputable) {
const p = Math.round(ev.loaded * 100 / ev.total);
bar.style.width = p + '%';
pct.textContent = p + '%';
}
});
xhr.addEventListener('load', () => {
if (xhr.responseURL) window.location.href = xhr.responseURL;
else location.reload();
});
xhr.addEventListener('error', () => {
alert('上传失败,请重试');
document.getElementById('submitBtn').disabled = false;
});
xhr.open('POST', uploadForm.action);
xhr.send(new FormData(uploadForm));
});
}
// ── 激活正确的 Tab ────────────────────────────────────────────────────────────
const activeTab = '{{ tab }}';
if (activeTab === 'url') {
document.getElementById('urlTabBtn')?.click();
} else if (activeTab === 'magnet') {
document.getElementById('magnetTabBtn')?.click();
}
</script>
{% endblock %}

0
app/utils/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

25
app/utils/decorators.py Normal file
View File

@@ -0,0 +1,25 @@
from functools import wraps
from flask import abort
from flask_login import current_user
def admin_required(f):
"""要求管理员权限"""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated
def active_required(f):
"""要求账号处于激活状态"""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_active:
abort(403)
return f(*args, **kwargs)
return decorated

194
app/utils/downloader.py Normal file
View File

@@ -0,0 +1,194 @@
"""
后台下载模块:支持 HTTP URL 下载和磁力链接下载(需要安装 aria2c
"""
import os
import uuid
import mimetypes
import threading
import subprocess
import requests
from urllib.parse import urlparse, unquote
from flask import current_app
from app.extensions import db
from app.utils.file_handler import guess_resource_type
def _get_filename_from_url(url):
"""从 URL 猜测文件名"""
path = urlparse(url).path
name = unquote(os.path.basename(path))
return name if name else 'download'
def _update_resource(app, resource_id, **kwargs):
"""在 app context 内更新 Resource 记录"""
with app.app_context():
from app.models.resource import Resource
res = db.session.get(Resource, resource_id)
if res:
for k, v in kwargs.items():
setattr(res, k, v)
db.session.commit()
def download_url(app, resource_id, url, resource_type, user_id):
"""后台线程HTTP 下载"""
try:
_update_resource(app, resource_id,
download_status='downloading', download_progress=0)
headers = {'User-Agent': 'Mozilla/5.0 (compatible; ResourceLibrary/1.0)'}
response = requests.get(url, stream=True, timeout=60, headers=headers)
response.raise_for_status()
# 确定文件名和类型
orig_name = _get_filename_from_url(url)
content_type = response.headers.get('content-type', '').split(';')[0].strip()
ext = mimetypes.guess_extension(content_type) or \
('.' + orig_name.rsplit('.', 1)[-1] if '.' in orig_name else '')
ext = ext.lstrip('.')
if not resource_type:
resource_type = guess_resource_type(orig_name, content_type) or 'text'
unique_name = f"{uuid.uuid4().hex}.{ext}" if ext else uuid.uuid4().hex
save_dir = os.path.join(app.config['UPLOAD_FOLDER'], resource_type)
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, unique_name)
total = int(response.headers.get('content-length', 0))
downloaded = 0
chunk_size = 65536 # 64KB
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total > 0:
progress = int(downloaded * 100 / total)
_update_resource(app, resource_id,
download_progress=progress)
file_size = os.path.getsize(save_path)
rel_path = f"uploads/{resource_type}/{unique_name}"
_update_resource(app, resource_id,
download_status='done',
download_progress=100,
filename=unique_name,
original_name=orig_name,
file_path=rel_path,
file_size=file_size,
mime_type=content_type,
resource_type=resource_type)
except Exception as e:
_update_resource(app, resource_id,
download_status='failed',
download_error=str(e)[:500])
def start_url_download(app, resource_id, url, resource_type, user_id):
"""启动 URL 下载线程"""
t = threading.Thread(
target=download_url,
args=(app, resource_id, url, resource_type, user_id),
daemon=True
)
t.start()
def start_magnet_download(app, resource_id, magnet_uri, resource_type):
"""启动磁力下载线程(需要 aria2c"""
t = threading.Thread(
target=_magnet_download_worker,
args=(app, resource_id, magnet_uri, resource_type),
daemon=True
)
t.start()
def _magnet_download_worker(app, resource_id, magnet_uri, resource_type):
"""使用 aria2c 下载磁力链接"""
if not resource_type:
resource_type = 'video' # 磁力下载默认视频
save_dir = os.path.join(app.config['UPLOAD_FOLDER'], resource_type)
os.makedirs(save_dir, exist_ok=True)
try:
_update_resource(app, resource_id,
download_status='downloading', download_progress=0)
# 检查 aria2c 是否可用
result = subprocess.run(
['aria2c', '--version'],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
raise RuntimeError('aria2c 未安装或不可用,请先安装 aria2c')
cmd = [
'aria2c',
'--dir', save_dir,
'--seed-time=0',
'--max-connection-per-server=4',
'--console-log-level=warn',
magnet_uri
]
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding='utf-8', errors='replace'
)
# 实时读取输出更新进度(简单估算)
progress = 10
for line in proc.stdout:
line = line.strip()
if '[' in line and '%' in line:
try:
pct_str = line.split('%')[0].split()[-1]
progress = int(float(pct_str))
_update_resource(app, resource_id,
download_progress=min(progress, 99))
except (ValueError, IndexError):
pass
proc.wait()
if proc.returncode == 0:
# 查找下载的文件
files = []
for root, dirs, fnames in os.walk(save_dir):
for fname in fnames:
fpath = os.path.join(root, fname)
files.append((fpath, os.path.getmtime(fpath)))
if files:
latest = max(files, key=lambda x: x[1])[0]
orig_name = os.path.basename(latest)
rel_path = os.path.relpath(
latest,
os.path.join(app.root_path, 'static')
).replace('\\', '/')
_update_resource(app, resource_id,
download_status='done',
download_progress=100,
filename=orig_name,
original_name=orig_name,
file_path=rel_path,
file_size=os.path.getsize(latest))
else:
raise RuntimeError('下载完成但未找到文件')
else:
raise RuntimeError(f'aria2c 退出码: {proc.returncode}')
except FileNotFoundError:
_update_resource(app, resource_id,
download_status='failed',
download_error='aria2c 未安装,请安装后重试')
except Exception as e:
_update_resource(app, resource_id,
download_status='failed',
download_error=str(e)[:500])

107
app/utils/file_handler.py Normal file
View File

@@ -0,0 +1,107 @@
import os
import uuid
import mimetypes
from flask import current_app
from werkzeug.utils import secure_filename
# MIME 类型到资源类型的映射
MIME_TYPE_MAP = {
# 文本
'text/plain': 'text',
'text/markdown': 'text',
'text/csv': 'text',
'text/html': 'text',
'text/xml': 'text',
'application/json': 'text',
'application/xml': 'text',
# 图片
'image/jpeg': 'image',
'image/png': 'image',
'image/gif': 'image',
'image/webp': 'image',
'image/bmp': 'image',
'image/svg+xml': 'image',
'image/x-icon': 'image',
# 音频
'audio/mpeg': 'audio',
'audio/wav': 'audio',
'audio/ogg': 'audio',
'audio/flac': 'audio',
'audio/mp4': 'audio',
'audio/aac': 'audio',
'audio/x-ms-wma': 'audio',
# 视频
'video/mp4': 'video',
'video/webm': 'video',
'video/x-msvideo': 'video',
'video/x-matroska': 'video',
'video/quicktime': 'video',
'video/x-ms-wmv': 'video',
'video/x-flv': 'video',
'video/mp2t': 'video',
}
EXT_TYPE_MAP = {
'txt': 'text', 'md': 'text', 'csv': 'text', 'json': 'text',
'xml': 'text', 'log': 'text', 'html': 'text', 'htm': 'text',
'jpg': 'image', 'jpeg': 'image', 'png': 'image', 'gif': 'image',
'webp': 'image', 'bmp': 'image', 'svg': 'image', 'ico': 'image',
'mp3': 'audio', 'wav': 'audio', 'ogg': 'audio', 'flac': 'audio',
'm4a': 'audio', 'aac': 'audio', 'wma': 'audio',
'mp4': 'video', 'webm': 'video', 'avi': 'video', 'mkv': 'video',
'mov': 'video', 'wmv': 'video', 'flv': 'video', 'm4v': 'video',
}
def guess_resource_type(filename, mime=None):
"""根据 MIME 或扩展名猜测资源类型"""
if mime and mime in MIME_TYPE_MAP:
return MIME_TYPE_MAP[mime]
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return EXT_TYPE_MAP.get(ext)
def allowed_file(filename):
"""判断文件扩展名是否在允许列表中"""
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
return ext in current_app.config.get('ALLOWED_TEXT_EXT', set()) | \
current_app.config.get('ALLOWED_IMAGE_EXT', set()) | \
current_app.config.get('ALLOWED_AUDIO_EXT', set()) | \
current_app.config.get('ALLOWED_VIDEO_EXT', set())
def save_uploaded_file(file_obj, resource_type):
"""
保存上传的文件到 uploads/<resource_type>/ 目录。
返回 (filename_on_disk, relative_path, file_size, mime_type)
"""
original_name = secure_filename(file_obj.filename)
ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
unique_name = f"{uuid.uuid4().hex}.{ext}" if ext else uuid.uuid4().hex
save_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], resource_type)
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, unique_name)
file_obj.save(save_path)
file_size = os.path.getsize(save_path)
mime_type = mimetypes.guess_type(original_name)[0] or 'application/octet-stream'
# 相对于 static 的路径,供 url_for('static', filename=...) 使用
rel_path = os.path.join('uploads', resource_type, unique_name).replace('\\', '/')
return unique_name, rel_path, file_size, mime_type, original_name
def delete_resource_file(file_path):
"""删除资源文件file_path 是相对于 static 的路径"""
if not file_path:
return
abs_path = os.path.join(
current_app.root_path, 'static', file_path.lstrip('/')
)
if os.path.exists(abs_path):
os.remove(abs_path)

32
app/utils/filters.py Normal file
View File

@@ -0,0 +1,32 @@
from datetime import datetime
def register_filters(app):
@app.template_filter('datetime_fmt')
def datetime_fmt(value, fmt='%Y-%m-%d %H:%M'):
if not value:
return ''
if isinstance(value, str):
try:
value = datetime.fromisoformat(value)
except ValueError:
return value
return value.strftime(fmt)
@app.template_filter('filesize')
def filesize_filter(size):
if not size:
return ''
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f'{size:.1f} {unit}'
size /= 1024
return f'{size:.1f} TB'
@app.template_filter('truncate_mid')
def truncate_mid(s, max_len=40):
if not s or len(s) <= max_len:
return s
half = max_len // 2
return s[:half] + '' + s[-half:]

57
config.py Normal file
View File

@@ -0,0 +1,57 @@
import os
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-please-change')
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:password@localhost:3306/resource_library'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 上传配置
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'app', 'static', 'uploads')
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_UPLOAD_SIZE_MB', 500)) * 1024 * 1024
# Session
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# CSRF
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600
# 允许的文件类型
ALLOWED_TEXT_EXT = {'txt', 'md', 'csv', 'json', 'xml', 'log', 'html', 'htm'}
ALLOWED_IMAGE_EXT = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'}
ALLOWED_AUDIO_EXT = {'mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'wma'}
ALLOWED_VIDEO_EXT = {'mp4', 'webm', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'm4v'}
@classmethod
def all_allowed_extensions(cls):
return (cls.ALLOWED_TEXT_EXT | cls.ALLOWED_IMAGE_EXT |
cls.ALLOWED_AUDIO_EXT | cls.ALLOWED_VIDEO_EXT)
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
SESSION_COOKIE_SECURE = True
WTF_CSRF_SSL_STRICT = True
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

75
init_db.py Normal file
View File

@@ -0,0 +1,75 @@
"""
数据库初始化脚本:
1. 创建所有数据库表
2. 写入默认系统配置
3. 创建管理员账号(首次运行)
用法:
python init_db.py
python init_db.py --admin-user admin --admin-pass AdminPass123
"""
import sys
import argparse
from app import create_app
from app.extensions import db, bcrypt
from app.models.user import User
from app.models.resource import Resource
from app.models.setting import SystemSetting
def init_db(admin_username='admin', admin_password='Admin@123456',
admin_email='admin@example.com'):
app = create_app('development')
with app.app_context():
print('[1/3] 正在创建数据库表…')
db.create_all()
print(' ✓ 表结构创建完成')
print('[2/3] 写入默认系统配置…')
for key, (value, description) in SystemSetting.DEFAULTS.items():
if not SystemSetting.query.filter_by(key=key).first():
db.session.add(SystemSetting(
key=key, value=value, description=description
))
db.session.commit()
print(' ✓ 系统配置写入完成')
print('[3/3] 创建管理员账号…')
admin = User.query.filter_by(username=admin_username).first()
if admin:
print(f' ✓ 管理员 "{admin_username}" 已存在,跳过')
else:
pw_hash = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin = User(
username=admin_username,
email=admin_email,
password_hash=pw_hash,
role='admin',
is_active=True
)
db.session.add(admin)
db.session.commit()
print(f' ✓ 管理员创建成功')
print(f' 用户名: {admin_username}')
print(f' 密 码: {admin_password}')
print(f' 邮 箱: {admin_email}')
print('\n[完成] 数据库初始化成功!')
print(' 运行 python run.py 启动服务')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='初始化数据库')
parser.add_argument('--admin-user', default='admin', help='管理员用户名')
parser.add_argument('--admin-pass', default='Admin@123456', help='管理员密码')
parser.add_argument('--admin-email', default='admin@example.com', help='管理员邮箱')
args = parser.parse_args()
init_db(
admin_username=args.admin_user,
admin_password=args.admin_pass,
admin_email=args.admin_email
)

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Bcrypt==1.0.1
Flask-Migrate==4.0.7
PyMySQL==1.1.1
cryptography==42.0.8
WTForms==3.1.2
Pillow==10.4.0
requests==2.32.3
python-dotenv==1.0.1
python-magic-bin==0.4.14; sys_platform == 'win32'
python-magic==0.4.27; sys_platform != 'win32'

11
run.py Normal file
View File

@@ -0,0 +1,11 @@
import os
from app import create_app
app = create_app(os.environ.get('FLASK_ENV', 'development'))
if __name__ == '__main__':
app.run(
host='0.0.0.0',
port=int(os.environ.get('PORT', 5000)),
debug=app.config.get('DEBUG', True)
)