新增文件夹功能
Some checks failed
CI — Docker Build & Push / Build & Push Image (push) Failing after 10m15s
Some checks failed
CI — Docker Build & Push / Build & Push Image (push) Failing after 10m15s
This commit is contained in:
@@ -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)
|
||||
|
||||
# 注册错误处理
|
||||
|
||||
Binary file not shown.
@@ -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']
|
||||
|
||||
Binary file not shown.
Binary file not shown.
84
app/models/folder.py
Normal file
84
app/models/folder.py
Normal 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}>'
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
143
app/routes/folders.py
Normal 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)
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user