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:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python -c ':*)",
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.env.example
Normal file
5
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
104
README.md
Normal 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-Bcrypt(bcrypt 自动加盐哈希) |
|
||||
| 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
|
||||
BIN
__pycache__/config.cpython-314.pyc
Normal file
BIN
__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
60
app/__init__.py
Normal file
60
app/__init__.py
Normal 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
|
||||
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/extensions.cpython-314.pyc
Normal file
BIN
app/__pycache__/extensions.cpython-314.pyc
Normal file
Binary file not shown.
15
app/extensions.py
Normal file
15
app/extensions.py
Normal 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
5
app/models/__init__.py
Normal 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']
|
||||
BIN
app/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/resource.cpython-314.pyc
Normal file
BIN
app/models/__pycache__/resource.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/setting.cpython-314.pyc
Normal file
BIN
app/models/__pycache__/setting.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/user.cpython-314.pyc
Normal file
BIN
app/models/__pycache__/user.cpython-314.pyc
Normal file
Binary file not shown.
85
app/models/resource.py
Normal file
85
app/models/resource.py
Normal 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
47
app/models/setting.py
Normal 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
36
app/models/user.py
Normal 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
0
app/routes/__init__.py
Normal file
BIN
app/routes/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/admin.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/auth.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/errors.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/errors.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/main.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/resources.cpython-314.pyc
Normal file
BIN
app/routes/__pycache__/resources.cpython-314.pyc
Normal file
Binary file not shown.
207
app/routes/admin.py
Normal file
207
app/routes/admin.py
Normal 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
138
app/routes/auth.py
Normal 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
20
app/routes/errors.py
Normal 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
26
app/routes/main.py
Normal 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
324
app/routes/resources.py
Normal 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
230
app/static/css/style.css
Normal 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
122
app/static/js/main.js
Normal 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' });
|
||||
});
|
||||
|
||||
});
|
||||
132
app/templates/admin/dashboard.html
Normal file
132
app/templates/admin/dashboard.html
Normal 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 %}
|
||||
69
app/templates/admin/settings.html
Normal file
69
app/templates/admin/settings.html
Normal 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 %}
|
||||
87
app/templates/admin/user_form.html
Normal file
87
app/templates/admin/user_form.html
Normal 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 %}
|
||||
157
app/templates/admin/users.html
Normal file
157
app/templates/admin/users.html
Normal 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 %}
|
||||
58
app/templates/auth/change_password.html
Normal file
58
app/templates/auth/change_password.html
Normal 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 %}
|
||||
77
app/templates/auth/login.html
Normal file
77
app/templates/auth/login.html
Normal 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 %}
|
||||
75
app/templates/auth/register.html
Normal file
75
app/templates/auth/register.html
Normal 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
185
app/templates/base.html
Normal 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>
|
||||
12
app/templates/errors/403.html
Normal file
12
app/templates/errors/403.html
Normal 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 %}
|
||||
12
app/templates/errors/404.html
Normal file
12
app/templates/errors/404.html
Normal 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 %}
|
||||
12
app/templates/errors/413.html
Normal file
12
app/templates/errors/413.html
Normal 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 %}
|
||||
12
app/templates/errors/500.html
Normal file
12
app/templates/errors/500.html
Normal 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 %}
|
||||
252
app/templates/user/detail.html
Normal file
252
app/templates/user/detail.html
Normal 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 %}
|
||||
51
app/templates/user/edit.html
Normal file
51
app/templates/user/edit.html
Normal 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 %}
|
||||
59
app/templates/user/profile.html
Normal file
59
app/templates/user/profile.html
Normal 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 %}
|
||||
148
app/templates/user/resources.html
Normal file
148
app/templates/user/resources.html
Normal 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 %}
|
||||
334
app/templates/user/upload.html
Normal file
334
app/templates/user/upload.html
Normal 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
0
app/utils/__init__.py
Normal file
BIN
app/utils/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/utils/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/decorators.cpython-314.pyc
Normal file
BIN
app/utils/__pycache__/decorators.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/downloader.cpython-314.pyc
Normal file
BIN
app/utils/__pycache__/downloader.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/file_handler.cpython-314.pyc
Normal file
BIN
app/utils/__pycache__/file_handler.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/filters.cpython-314.pyc
Normal file
BIN
app/utils/__pycache__/filters.cpython-314.pyc
Normal file
Binary file not shown.
25
app/utils/decorators.py
Normal file
25
app/utils/decorators.py
Normal 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
194
app/utils/downloader.py
Normal 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
107
app/utils/file_handler.py
Normal 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
32
app/utils/filters.py
Normal 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
57
config.py
Normal 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
75
init_db.py
Normal 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
14
requirements.txt
Normal 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'
|
||||
Reference in New Issue
Block a user