新增文件夹功能
Some checks failed
CI — Docker Build & Push / Build & Push Image (push) Failing after 10m15s

This commit is contained in:
2026-04-23 15:45:15 +09:00
parent 467977f198
commit f3bd3f68a5
17 changed files with 718 additions and 29 deletions

View File

@@ -27,11 +27,13 @@ def create_app(config_name=None):
from app.routes.auth import auth_bp
from app.routes.admin import admin_bp
from app.routes.resources import resources_bp
from app.routes.folders import folders_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(folders_bp, url_prefix='/folders')
app.register_blueprint(main_bp)
# 注册错误处理

View File

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

84
app/models/folder.py Normal file
View File

@@ -0,0 +1,84 @@
from datetime import datetime
from app.extensions import db
class Folder(db.Model):
__tablename__ = 'folders'
__table_args__ = (
# 同一用户同一父目录下名称唯一
# 注意MySQL 对 NULL 不强制唯一,根目录重名由路由层补充校验
db.UniqueConstraint('user_id', 'parent_id', 'name',
name='uq_folder_user_parent_name'),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
name = db.Column(db.String(128), nullable=False)
parent_id = db.Column(db.Integer,
db.ForeignKey('folders.id', ondelete='CASCADE'),
nullable=True, index=True)
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)
# 自引用关系:父删 → 子自动删
children = db.relationship(
'Folder',
backref=db.backref('parent', remote_side=[id]),
lazy='select',
cascade='all, delete-orphan'
)
# 文件夹内的资源
resources = db.relationship(
'Resource',
backref='folder',
lazy='dynamic',
foreign_keys='Resource.folder_id'
)
# ── 业务方法 ─────────────────────────────────────────────────
def is_ancestor_of(self, target_id: int) -> bool:
"""检测 self 是否是 target_id 的祖先(防止移动到自身子树产生循环引用)"""
visited = set()
current = Folder.query.get(target_id)
while current is not None:
if current.id == self.id:
return True
if current.id in visited:
break
visited.add(current.id)
current = current.parent
return False
def get_breadcrumb(self) -> list:
"""从根到当前节点的路径列表(用于面包屑导航)"""
path, node = [], self
while node is not None:
path.append(node)
node = node.parent
path.reverse()
return path
def to_dict(self) -> dict:
return {
'id': self.id,
'name': self.name,
'parent_id': self.parent_id,
'resource_count': self.resources.count(),
'children': [c.to_dict() for c in sorted(self.children, key=lambda x: x.name)]
}
@staticmethod
def get_user_tree(user_id: int) -> list:
"""返回某用户所有根文件夹的完整树(递归序列化)"""
roots = (Folder.query
.filter_by(user_id=user_id, parent_id=None)
.order_by(Folder.name)
.all())
return [f.to_dict() for f in roots]
def __repr__(self):
return f'<Folder {self.id} {self.name!r}>'

View File

@@ -40,6 +40,11 @@ class Resource(db.Model):
tags = db.Column(db.String(512), nullable=True) # 逗号分隔
is_public = db.Column(db.Boolean, default=False)
# 所属文件夹null = 根目录/未归档)
folder_id = db.Column(db.Integer,
db.ForeignKey('folders.id', ondelete='SET NULL'),
nullable=True, index=True)
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)

View File

@@ -82,7 +82,8 @@ def login():
else:
flash('用户名或密码错误', 'danger')
return render_template('auth/login.html', form=form)
allow_register = SystemSetting.get('allow_register', 'true') == 'true'
return render_template('auth/login.html', form=form, allow_register=allow_register)
@auth_bp.route('/logout', methods=['POST'])

143
app/routes/folders.py Normal file
View File

@@ -0,0 +1,143 @@
from flask import Blueprint, jsonify, request, abort
from flask_login import login_required, current_user
from app.extensions import db
from app.models.folder import Folder
from app.utils.file_handler import delete_resource_file
folders_bp = Blueprint('folders', __name__)
# ── 递归删除辅助函数 ──────────────────────────────────────────────────────────
def _delete_folder_recursive(folder: Folder):
"""DFS 递归删除文件夹:先删子文件夹,再删资源(含磁盘文件),最后删自身"""
for child in list(folder.children):
_delete_folder_recursive(child)
for res in folder.resources.all():
delete_resource_file(res.file_path)
db.session.delete(res)
db.session.delete(folder)
# ── 路由 ──────────────────────────────────────────────────────────────────────
@folders_bp.route('/tree')
@login_required
def tree():
"""返回当前用户的完整文件夹树JSON"""
return jsonify(Folder.get_user_tree(current_user.id))
@folders_bp.route('/create', methods=['POST'])
@login_required
def create():
"""创建文件夹"""
name = request.form.get('name', '').strip()
parent_id_raw = request.form.get('parent_id', '').strip()
parent_id = int(parent_id_raw) if parent_id_raw.isdigit() else None
if not name or len(name) > 128:
return jsonify(ok=False, msg='文件夹名称无效1-128位'), 400
# 验证父文件夹归属
if parent_id is not None:
parent = db.session.get(Folder, parent_id)
if parent is None or parent.user_id != current_user.id:
abort(403)
# 同级重名校验(覆盖 NULL parent_id 场景)
dup = Folder.query.filter_by(
user_id=current_user.id,
parent_id=parent_id,
name=name
).first()
if dup:
return jsonify(ok=False, msg='该位置已存在同名文件夹'), 409
folder = Folder(user_id=current_user.id, name=name, parent_id=parent_id)
db.session.add(folder)
db.session.commit()
return jsonify(ok=True, folder={
'id': folder.id, 'name': folder.name,
'parent_id': folder.parent_id, 'resource_count': 0, 'children': []
})
@folders_bp.route('/<int:folder_id>/rename', methods=['POST'])
@login_required
def rename(folder_id):
"""重命名文件夹"""
folder = db.session.get(Folder, folder_id)
if folder is None or folder.user_id != current_user.id:
abort(403)
name = request.form.get('name', '').strip()
if not name or len(name) > 128:
return jsonify(ok=False, msg='文件夹名称无效'), 400
if name == folder.name:
return jsonify(ok=True)
# 同级重名校验
dup = Folder.query.filter_by(
user_id=current_user.id,
parent_id=folder.parent_id,
name=name
).filter(Folder.id != folder_id).first()
if dup:
return jsonify(ok=False, msg='该位置已存在同名文件夹'), 409
folder.name = name
db.session.commit()
return jsonify(ok=True)
@folders_bp.route('/<int:folder_id>/delete', methods=['POST'])
@login_required
def delete(folder_id):
"""递归删除文件夹(含所有子文件夹和资源)"""
folder = db.session.get(Folder, folder_id)
if folder is None or folder.user_id != current_user.id:
abort(403)
_delete_folder_recursive(folder)
db.session.commit()
return jsonify(ok=True)
@folders_bp.route('/<int:folder_id>/move', methods=['POST'])
@login_required
def move(folder_id):
"""移动文件夹到新的父文件夹"""
folder = db.session.get(Folder, folder_id)
if folder is None or folder.user_id != current_user.id:
abort(403)
parent_id_raw = request.form.get('parent_id', '').strip()
new_parent_id = int(parent_id_raw) if parent_id_raw.isdigit() else None
if new_parent_id is not None:
# 不能移动到自身
if new_parent_id == folder_id:
return jsonify(ok=False, msg='不能将文件夹移动到自身'), 400
# 不能移动到自己的子孙节点
if folder.is_ancestor_of(new_parent_id):
return jsonify(ok=False, msg='不能将文件夹移动到其子文件夹中'), 400
# 验证目标父文件夹归属
new_parent = db.session.get(Folder, new_parent_id)
if new_parent is None or new_parent.user_id != current_user.id:
abort(403)
# 同级重名校验
dup = Folder.query.filter_by(
user_id=current_user.id,
parent_id=new_parent_id,
name=folder.name
).filter(Folder.id != folder_id).first()
if dup:
return jsonify(ok=False, msg='目标位置已有同名文件夹'), 409
folder.parent_id = new_parent_id
db.session.commit()
return jsonify(ok=True)

View File

@@ -10,6 +10,7 @@ from wtforms.validators import DataRequired, Optional, URL, Length
from app.extensions import db
from app.models.resource import Resource
from app.models.folder import Folder
from app.models.setting import SystemSetting
from app.utils.file_handler import (save_uploaded_file, delete_resource_file,
allowed_file, guess_resource_type)
@@ -78,17 +79,59 @@ def _check_setting(key):
return SystemSetting.get(key, 'true') == 'true'
def get_folder_choices(user_id: int) -> list:
"""生成带层级缩进的文件夹扁平列表,用于表单下拉框"""
def _walk(folders, depth=0):
result = []
for f in sorted(folders, key=lambda x: x.name):
result.append((str(f.id), '\u3000' * depth + f.name))
result.extend(_walk(f.children, depth + 1))
return result
roots = (Folder.query
.filter_by(user_id=user_id, parent_id=None)
.order_by(Folder.name)
.all())
return [('', '— 根目录 —')] + _walk(roots)
def _parse_folder_id(user_id: int) -> int | None:
"""从 form/args 中解析并验证 folder_id返回合法 id 或 None"""
raw = request.form.get('folder_id', '').strip()
if not raw:
return None
if not raw.isdigit():
return None
folder_id = int(raw)
folder = Folder.query.filter_by(id=folder_id, user_id=user_id).first()
if folder is None:
abort(403)
return folder_id
# ── 路由 ─────────────────────────────────────────────────────────────────────
@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', '')
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '')
rtype = request.args.get('type', '')
source = request.args.get('source', '')
folder_id = request.args.get('folder_id', type=int)
query = Resource.query.filter_by(user_id=current_user.id)
# 文件夹过滤
current_folder = None
breadcrumb = []
if folder_id is not None:
current_folder = Folder.query.filter_by(
id=folder_id, user_id=current_user.id
).first_or_404()
query = query.filter_by(folder_id=folder_id)
breadcrumb = current_folder.get_breadcrumb()
if q:
query = query.filter(
Resource.title.ilike(f'%{q}%') |
@@ -104,38 +147,46 @@ def list_resources():
page=page, per_page=24, error_out=False)
counts = {}
base_q = Resource.query.filter_by(user_id=current_user.id)
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()
counts[t] = base_q.filter_by(resource_type=t).count()
counts['total'] = base_q.count()
return render_template('user/resources.html',
pagination=pagination, q=q,
rtype=rtype, source=source,
counts=counts)
counts=counts,
folder_id=folder_id,
current_folder=current_folder,
breadcrumb=breadcrumb)
@resources_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
form = UploadForm()
folder_choices = get_folder_choices(current_user.id)
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')
return render_template('user/upload.html', form=form, tab='upload',
folder_choices=folder_choices)
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')
return render_template('user/upload.html', form=form, tab='upload',
folder_choices=folder_choices)
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')
return render_template('user/upload.html', form=form, tab='upload',
folder_choices=folder_choices)
folder_id = _parse_folder_id(current_user.id)
res = Resource(
user_id=current_user.id,
title=form.title.data,
@@ -148,14 +199,16 @@ def upload():
file_size=size,
mime_type=mime,
tags=form.tags.data,
download_status='na'
download_status='na',
folder_id=folder_id
)
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')
return render_template('user/upload.html', form=form, tab='upload',
folder_choices=folder_choices)
@resources_bp.route('/url-download', methods=['GET', 'POST'])
@@ -166,8 +219,10 @@ def url_download():
return redirect(url_for('resources.list_resources'))
form = UrlDownloadForm()
folder_choices = get_folder_choices(current_user.id)
if form.validate_on_submit():
rtype = form.resource_type.data or None
folder_id = _parse_folder_id(current_user.id)
res = Resource(
user_id=current_user.id,
title=form.title.data,
@@ -176,7 +231,8 @@ def url_download():
source_type='url',
source_url=form.source_url.data,
tags=form.tags.data,
download_status='pending'
download_status='pending',
folder_id=folder_id
)
db.session.add(res)
db.session.commit()
@@ -191,7 +247,8 @@ def url_download():
flash('下载任务已启动,请稍后刷新查看进度', 'info')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/upload.html', form=form, tab='url')
return render_template('user/upload.html', form=form, tab='url',
folder_choices=folder_choices)
@resources_bp.route('/magnet-download', methods=['GET', 'POST'])
@@ -202,11 +259,14 @@ def magnet_download():
return redirect(url_for('resources.list_resources'))
form = MagnetForm()
folder_choices = get_folder_choices(current_user.id)
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')
return render_template('user/upload.html', form=form, tab='magnet',
folder_choices=folder_choices)
folder_id = _parse_folder_id(current_user.id)
res = Resource(
user_id=current_user.id,
title=form.title.data,
@@ -215,7 +275,8 @@ def magnet_download():
source_type='magnet',
source_url=form.magnet_uri.data,
tags=form.tags.data,
download_status='pending'
download_status='pending',
folder_id=folder_id
)
db.session.add(res)
db.session.commit()
@@ -229,7 +290,8 @@ def magnet_download():
flash('磁力下载任务已启动(需要安装 aria2c', 'info')
return redirect(url_for('resources.detail', resource_id=res.id))
return render_template('user/upload.html', form=form, tab='magnet')
return render_template('user/upload.html', form=form, tab='magnet',
folder_choices=folder_choices)
@resources_bp.route('/<int:resource_id>')
@@ -248,15 +310,50 @@ def edit(resource_id):
if res.user_id != current_user.id and not current_user.is_admin:
abort(403)
form = EditForm(obj=res)
folder_choices = get_folder_choices(current_user.id)
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
# 更新文件夹
folder_id_raw = request.form.get('folder_id', '').strip()
if folder_id_raw == '':
res.folder_id = None
elif folder_id_raw.isdigit():
fid = int(folder_id_raw)
folder = Folder.query.filter_by(id=fid, user_id=current_user.id).first()
if folder:
res.folder_id = fid
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)
return render_template('user/edit.html', form=form, resource=res,
folder_choices=folder_choices)
@resources_bp.route('/<int:resource_id>/move', methods=['POST'])
@login_required
def move_resource(resource_id):
"""移动资源到指定文件夹JSON 接口)"""
res = db.get_or_404(Resource, resource_id)
if res.user_id != current_user.id:
abort(403)
folder_id_raw = request.form.get('folder_id', '').strip()
if folder_id_raw == '':
res.folder_id = None
elif folder_id_raw.isdigit():
fid = int(folder_id_raw)
folder = Folder.query.filter_by(id=fid, user_id=current_user.id).first()
if folder is None:
abort(403)
res.folder_id = fid
else:
return jsonify(ok=False, msg='无效的文件夹 ID'), 400
db.session.commit()
return jsonify(ok=True, folder_id=res.folder_id)
@resources_bp.route('/<int:resource_id>/delete', methods=['POST'])

View File

@@ -51,11 +51,13 @@
</button>
</form>
{% if allow_register %}
<hr class="my-4">
<p class="text-center text-muted mb-0">
还没有账号?
<a href="{{ url_for('auth.register') }}" class="text-decoration-none">立即注册</a>
</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -11,6 +11,9 @@
<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') }}">
{% if current_user.is_authenticated %}
<meta name="csrf-token" content="{{ csrf_token() }}">
{% endif %}
{% block head %}{% endblock %}
</head>
<body>
@@ -57,7 +60,7 @@
<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 %}">
class="nav-link text-white {% if request.endpoint == 'resources.list_resources' and not request.args.get('folder_id') and not request.args.get('type') %}active{% endif %}">
<i class="bi bi-grid me-2"></i>全部资源
</a>
</li>
@@ -85,6 +88,32 @@
<i class="bi bi-camera-video me-2"></i>视频
</a>
</li>
<!-- 文件夹区块 -->
<li class="mt-2">
<div class="d-flex align-items-center justify-content-between px-1 py-1">
<button class="nav-link text-white text-start d-flex align-items-center gap-1 p-0 border-0 bg-transparent"
type="button" data-bs-toggle="collapse" data-bs-target="#folderTree"
aria-expanded="true" style="font-size:.9rem">
<i class="bi bi-folder2 me-1"></i>文件夹
<i class="bi bi-chevron-down small ms-1" id="folderChevron"></i>
</button>
<button class="btn btn-sm p-0 text-white opacity-75 border-0 bg-transparent"
title="新建文件夹"
data-bs-toggle="modal" data-bs-target="#createFolderModal"
onclick="openCreateFolder(null)">
<i class="bi bi-folder-plus"></i>
</button>
</div>
</li>
<div class="collapse show" id="folderTree">
<ul class="nav flex-column ps-2" id="folderTreeList">
<li class="text-muted small px-2 py-1" id="folderTreeLoading">
<i class="bi bi-hourglass-split me-1"></i>加载中…
</li>
</ul>
</div>
<li class="mt-2">
<a href="{{ url_for('resources.upload') }}"
class="nav-link text-white {% if request.endpoint == 'resources.upload' %}active{% endif %}">
@@ -177,9 +206,237 @@
</div>
{% endif %}
<!-- 创建文件夹 Modal -->
<div class="modal fade" id="createFolderModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-folder-plus me-2"></i>新建文件夹</h6>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-2">
<input type="hidden" id="createFolderParentId" value="">
<div class="mb-2">
<label class="form-label form-label-sm mb-1">文件夹名称</label>
<input type="text" id="createFolderName" class="form-control form-control-sm"
placeholder="输入名称" maxlength="128">
</div>
<div class="mb-1">
<label class="form-label form-label-sm mb-1">父文件夹</label>
<select id="createFolderParentSelect" class="form-select form-select-sm">
<option value="">— 根目录 —</option>
</select>
</div>
<div class="text-danger small mt-1 d-none" id="createFolderError"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary btn-sm" onclick="submitCreateFolder()">创建</button>
</div>
</div>
</div>
</div>
<!-- 重命名文件夹 Modal -->
<div class="modal fade" id="renameFolderModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-pencil me-2"></i>重命名文件夹</h6>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-2">
<input type="hidden" id="renameFolderId">
<label class="form-label form-label-sm mb-1">新名称</label>
<input type="text" id="renameFolderName" class="form-control form-control-sm"
placeholder="新名称" maxlength="128">
<div class="text-danger small mt-1 d-none" id="renameFolderError"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary btn-sm" onclick="submitRenameFolder()">保存</button>
</div>
</div>
</div>
</div>
<!-- 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>
{% if current_user.is_authenticated %}
<script>
// ── 文件夹树 ─────────────────────────────────────────────────────────────────
const _currentFolderId = {{ (request.args.get('folder_id') or 'null') | safe }};
let _folderTreeData = [];
function _escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderFolderNodes(nodes, depth) {
if (!nodes || !nodes.length) return '';
return nodes.map(n => `
<li class="nav-item">
<div class="d-flex align-items-center folder-tree-item"
style="padding-left:${(depth + 1) * 10}px">
<a href="/resources/?folder_id=${n.id}"
class="nav-link text-white py-1 flex-grow-1 text-truncate
${_currentFolderId == n.id ? 'active' : ''}"
style="font-size:.82rem;padding-right:2px">
<i class="bi bi-folder${n.children && n.children.length ? '2' : ''} me-1"></i>
${_escHtml(n.name)}
<span class="badge bg-secondary ms-1" style="font-size:.6rem">${n.resource_count}</span>
</a>
<div class="folder-actions d-flex gap-1 me-1">
<button class="btn btn-sm p-0 text-white opacity-50 border-0 bg-transparent"
style="font-size:.7rem" title="子文件夹"
onclick="openCreateFolder(${n.id});event.preventDefault()">
<i class="bi bi-folder-plus"></i>
</button>
<button class="btn btn-sm p-0 text-white opacity-50 border-0 bg-transparent"
style="font-size:.7rem" title="重命名"
onclick="openRename(${n.id},'${_escHtml(n.name)}');event.preventDefault()">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm p-0 text-danger opacity-75 border-0 bg-transparent"
style="font-size:.7rem" title="删除"
onclick="deleteFolder(${n.id},'${_escHtml(n.name)}');event.preventDefault()">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
${n.children && n.children.length
? '<ul class="nav flex-column">' + renderFolderNodes(n.children, depth + 1) + '</ul>'
: ''}
</li>`).join('');
}
async function loadFolderTree() {
const list = document.getElementById('folderTreeList');
if (!list) return;
try {
const resp = await fetch('/folders/tree');
_folderTreeData = await resp.json();
const html = renderFolderNodes(_folderTreeData, 0);
list.innerHTML = html ||
'<li class="text-muted small px-2 py-1">暂无文件夹</li>';
_fillParentSelect(_folderTreeData, document.getElementById('createFolderParentSelect'));
} catch(e) {
list.innerHTML = '<li class="text-muted small px-2 py-1">加载失败</li>';
}
}
function _fillParentSelect(nodes, sel, depth) {
depth = depth || 0;
(nodes || []).forEach(n => {
const opt = document.createElement('option');
opt.value = n.id;
opt.textContent = '\u3000'.repeat(depth) + n.name;
sel.appendChild(opt);
if (n.children && n.children.length) _fillParentSelect(n.children, sel, depth + 1);
});
}
// ── 创建文件夹 ───────────────────────────────────────────────────────────────
function openCreateFolder(parentId) {
document.getElementById('createFolderName').value = '';
document.getElementById('createFolderError').classList.add('d-none');
const sel = document.getElementById('createFolderParentSelect');
sel.innerHTML = '<option value="">— 根目录 —</option>';
_fillParentSelect(_folderTreeData, sel);
sel.value = parentId || '';
new bootstrap.Modal(document.getElementById('createFolderModal')).show();
}
async function submitCreateFolder() {
const name = document.getElementById('createFolderName').value.trim();
const parentId = document.getElementById('createFolderParentSelect').value;
const errEl = document.getElementById('createFolderError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = '请输入文件夹名称'; errEl.classList.remove('d-none'); return; }
const fd = new FormData();
fd.append('name', name);
if (parentId) fd.append('parent_id', parentId);
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.content || '');
const resp = await fetch('/folders/create', {method:'POST', body:fd});
const data = await resp.json();
if (data.ok) {
bootstrap.Modal.getInstance(document.getElementById('createFolderModal')).hide();
loadFolderTree();
} else {
errEl.textContent = data.msg || '创建失败';
errEl.classList.remove('d-none');
}
}
// ── 重命名文件夹 ─────────────────────────────────────────────────────────────
function openRename(id, name) {
document.getElementById('renameFolderId').value = id;
document.getElementById('renameFolderName').value = name;
document.getElementById('renameFolderError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('renameFolderModal')).show();
}
async function submitRenameFolder() {
const id = document.getElementById('renameFolderId').value;
const name = document.getElementById('renameFolderName').value.trim();
const errEl = document.getElementById('renameFolderError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = '请输入名称'; errEl.classList.remove('d-none'); return; }
const fd = new FormData();
fd.append('name', name);
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.content || '');
const resp = await fetch(`/folders/${id}/rename`, {method:'POST', body:fd});
const data = await resp.json();
if (data.ok) {
bootstrap.Modal.getInstance(document.getElementById('renameFolderModal')).hide();
loadFolderTree();
if (_currentFolderId == id) location.reload();
} else {
errEl.textContent = data.msg || '重命名失败';
errEl.classList.remove('d-none');
}
}
// ── 删除文件夹 ───────────────────────────────────────────────────────────────
async function deleteFolder(id, name) {
if (!confirm(`确认删除文件夹「${name}」?\n其中的所有子文件夹和资源将一并删除,且无法恢复。`)) return;
const fd = new FormData();
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.content || '');
const resp = await fetch(`/folders/${id}/delete`, {method:'POST', body:fd});
const data = await resp.json();
if (data.ok) {
if (_currentFolderId == id) {
location.href = '/resources/';
} else {
loadFolderTree();
}
} else {
alert(data.msg || '删除失败');
}
}
document.addEventListener('DOMContentLoaded', loadFolderTree);
// Collapse 图标旋转
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('folderTree');
const chevron = document.getElementById('folderChevron');
if (el && chevron) {
el.addEventListener('hide.bs.collapse', () => chevron.style.transform = 'rotate(-90deg)');
el.addEventListener('show.bs.collapse', () => chevron.style.transform = '');
}
});
</script>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -32,6 +32,17 @@
<label class="form-label fw-medium">标签(逗号分隔)</label>
{{ form.tags(class='form-control', placeholder='tag1, tag2') }}
</div>
<div class="mb-3">
<label class="form-label fw-medium">保存到文件夹</label>
<select name="folder_id" class="form-select">
{% for value, label in folder_choices %}
<option value="{{ value }}"
{% if value == (resource.folder_id | string if resource.folder_id else '') %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-4 form-check form-switch">
{{ form.is_public(class='form-check-input', role='switch') }}
<label class="form-check-label">公开资源</label>

View File

@@ -3,25 +3,73 @@
{% block breadcrumb %}
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">我的资源</li>
<li class="breadcrumb-item">
<a href="{{ url_for('resources.list_resources') }}">我的资源</a>
</li>
{% for crumb in breadcrumb %}
<li class="breadcrumb-item {% if loop.last %}active{% endif %}">
{% if loop.last %}
<i class="bi bi-folder2-open me-1"></i>{{ crumb.name }}
{% else %}
<a href="{{ url_for('resources.list_resources', folder_id=crumb.id) }}">{{ crumb.name }}</a>
{% endif %}
</li>
{% endfor %}
{% if not breadcrumb %}
<li class="breadcrumb-item active">全部资源</li>
{% endif %}
</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>
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h4 class="mb-0">
{% if current_folder %}
<i class="bi bi-folder2-open text-warning me-2"></i>{{ current_folder.name }}
{% else %}
<i class="bi bi-grid me-2"></i>我的资源
{% endif %}
</h4>
<a href="{{ url_for('resources.upload') }}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加资源
</a>
</div>
<!-- 当前文件夹操作栏 -->
{% if current_folder %}
<div class="d-flex align-items-center gap-2 mb-3 p-2 bg-light rounded border">
<nav aria-label="breadcrumb" class="flex-grow-1 mb-0">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item"><a href="{{ url_for('resources.list_resources') }}">根目录</a></li>
{% for crumb in breadcrumb %}
<li class="breadcrumb-item {% if loop.last %}active fw-medium{% endif %}">
{% if loop.last %}{{ crumb.name }}
{% else %}<a href="{{ url_for('resources.list_resources', folder_id=crumb.id) }}">{{ crumb.name }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
<div class="d-flex gap-1 flex-shrink-0">
<button class="btn btn-sm btn-outline-secondary"
onclick="openRename({{ current_folder.id }},'{{ current_folder.name }}')">
<i class="bi bi-pencil me-1"></i>重命名
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="deleteFolder({{ current_folder.id }},'{{ current_folder.name }}')">
<i class="bi bi-trash me-1"></i>删除文件夹
</button>
</div>
</div>
{% endif %}
<!-- 统计徽章 -->
<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) }}"
<a href="{{ url_for('resources.list_resources', type=t, q=q, folder_id=folder_id or '') }}"
class="badge text-decoration-none badge-filter
{{ 'bg-primary' if rtype == t else 'bg-' + color + (' text-dark' if color in ['light','warning'] else '') }}">
{{ label }}
@@ -33,6 +81,7 @@
<!-- 搜索 -->
<form method="GET" class="mb-4">
<input type="hidden" name="type" value="{{ rtype }}">
{% if folder_id %}<input type="hidden" name="folder_id" value="{{ folder_id }}">{% endif %}
<div class="input-group" style="max-width:480px">
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="搜索标题、描述、标签…">
@@ -40,7 +89,7 @@
<i class="bi bi-search"></i>
</button>
{% if q %}
<a href="{{ url_for('resources.list_resources', type=rtype) }}"
<a href="{{ url_for('resources.list_resources', type=rtype, folder_id=folder_id or '') }}"
class="btn btn-outline-danger"><i class="bi bi-x"></i></a>
{% endif %}
</div>
@@ -81,6 +130,11 @@
{% if res.file_size %}
<span class="badge bg-light text-muted border">{{ res.file_size | filesize }}</span>
{% endif %}
{% if res.folder and not folder_id %}
<span class="badge bg-light text-secondary border" title="{{ res.folder.name }}">
<i class="bi bi-folder me-1"></i>{{ res.folder.name | truncate(12, True) }}
</span>
{% endif %}
</div>
{% if res.description %}
<p class="card-text text-muted small text-truncate mb-2">{{ res.description }}</p>
@@ -114,13 +168,15 @@
<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>
<a class="page-link"
href="?page={{ pagination.prev_num }}&q={{ q }}&type={{ rtype }}{% if folder_id %}&folder_id={{ folder_id }}{% endif %}">«</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>
<a class="page-link"
href="?page={{ p }}&q={{ q }}&type={{ rtype }}{% if folder_id %}&folder_id={{ folder_id }}{% endif %}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
@@ -128,7 +184,8 @@
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.next_num }}&q={{ q }}&type={{ rtype }}">»</a>
<a class="page-link"
href="?page={{ pagination.next_num }}&q={{ q }}&type={{ rtype }}{% if folder_id %}&folder_id={{ folder_id }}{% endif %}">»</a>
</li>
{% endif %}
</ul>
@@ -139,7 +196,11 @@
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size:4rem"></i>
<h5 class="mt-3">暂无资源</h5>
{% if current_folder %}
<p>此文件夹还没有资源,点击「添加资源」开始上传</p>
{% else %}
<p>点击右上角「添加资源」开始添加</p>
{% endif %}
<a href="{{ url_for('resources.upload') }}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>添加资源
</a>

View File

@@ -99,6 +99,15 @@
</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">保存到文件夹</label>
<select name="folder_id" class="form-select">
{% for value, label in folder_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"
@@ -165,6 +174,14 @@
<input type="text" name="tags" class="form-control" placeholder="逗号分隔">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">保存到文件夹</label>
<select name="folder_id" class="form-select">
{% for value, label in folder_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"></textarea>
@@ -216,6 +233,14 @@
<input type="text" name="tags" class="form-control" placeholder="逗号分隔">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">保存到文件夹</label>
<select name="folder_id" class="form-select">
{% for value, label in folder_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-medium">描述</label>
<textarea name="description" class="form-control" rows="2"></textarea>