feat: 管理系统新增文件管理模块 (#20)
新增文件管理模块,包括文件列表展示、下载、删除及批量删除功能。后端新增文件管理相关路由和服务,前端新增文件管理页面及相关API接口。同时新增MinIO存储桶清理脚本,用于清理未使用的存储桶。
This commit is contained in:
parent
be716880c1
commit
5d900c3883
|
@ -1,5 +1,5 @@
|
|||
name: "💞 Feature request"
|
||||
description: Propose a feature request for RAGFlow.
|
||||
description: Propose a feature request for RAGFlow-Plus.
|
||||
title: "[Feature Request]: "
|
||||
labels: ["💞 feature"]
|
||||
body:
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
import mysql.connector
|
||||
from minio import Minio
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv("../../docker/.env")
|
||||
|
||||
# 数据库连接配置
|
||||
DB_CONFIG = {
|
||||
"host": "localhost",
|
||||
"port": int(os.getenv("MYSQL_PORT", "5455")),
|
||||
"user": "root",
|
||||
"password": os.getenv("MYSQL_PASSWORD", "infini_rag_flow"),
|
||||
"database": "rag_flow"
|
||||
}
|
||||
|
||||
# MinIO连接配置
|
||||
MINIO_CONFIG = {
|
||||
"endpoint": "localhost:" + os.getenv("MINIO_PORT", "9000"),
|
||||
"access_key": os.getenv("MINIO_USER", "rag_flow"),
|
||||
"secret_key": os.getenv("MINIO_PASSWORD", "infini_rag_flow"),
|
||||
"secure": False
|
||||
}
|
||||
|
||||
def get_minio_client():
|
||||
"""创建MinIO客户端"""
|
||||
return Minio(
|
||||
endpoint=MINIO_CONFIG["endpoint"],
|
||||
access_key=MINIO_CONFIG["access_key"],
|
||||
secret_key=MINIO_CONFIG["secret_key"],
|
||||
secure=MINIO_CONFIG["secure"]
|
||||
)
|
||||
|
||||
def clear_database_tables():
|
||||
"""清空数据库表"""
|
||||
try:
|
||||
conn = mysql.connector.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 禁用外键检查
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||
|
||||
# 清空表数据
|
||||
tables = ["document", "file2document", "file"]
|
||||
for table in tables:
|
||||
cursor.execute(f"TRUNCATE TABLE {table}")
|
||||
print(f"已清空表: {table}")
|
||||
|
||||
# 启用外键检查
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
print("数据库表已全部清空")
|
||||
|
||||
except Exception as e:
|
||||
print(f"清空数据库表失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def clear_minio_buckets():
|
||||
"""清空并删除MinIO所有存储桶"""
|
||||
try:
|
||||
minio_client = get_minio_client()
|
||||
buckets = minio_client.list_buckets()
|
||||
|
||||
if not buckets:
|
||||
print("MinIO中没有存储桶需要清理")
|
||||
return
|
||||
|
||||
print(f"开始清理 {len(buckets)} 个MinIO存储桶...")
|
||||
|
||||
for bucket in buckets:
|
||||
bucket_name = bucket.name
|
||||
|
||||
# 跳过系统保留的存储桶
|
||||
if bucket_name.startswith('.'):
|
||||
print(f"跳过系统存储桶: {bucket_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
# 递归删除存储桶中的所有对象(包括版本控制对象)
|
||||
objects = minio_client.list_objects(bucket_name, recursive=True)
|
||||
for obj in objects:
|
||||
try:
|
||||
# 强制删除对象(包括所有版本)
|
||||
minio_client.remove_object(bucket_name, obj.object_name, version_id=obj.version_id)
|
||||
except Exception as e:
|
||||
print(f"删除对象 {obj.object_name} 失败: {str(e)}")
|
||||
continue
|
||||
|
||||
# 确保所有对象已删除
|
||||
while True:
|
||||
remaining_objects = list(minio_client.list_objects(bucket_name))
|
||||
if not remaining_objects:
|
||||
break
|
||||
for obj in remaining_objects:
|
||||
minio_client.remove_object(bucket_name, obj.object_name)
|
||||
|
||||
# 实际删除存储桶
|
||||
try:
|
||||
minio_client.remove_bucket(bucket_name)
|
||||
print(f"已删除存储桶: {bucket_name}")
|
||||
except Exception as e:
|
||||
print(f"删除存储桶 {bucket_name} 失败: {str(e)}. 尝试强制删除...")
|
||||
# 强制删除存储桶(即使非空)
|
||||
try:
|
||||
# 再次确保删除所有对象
|
||||
objects = minio_client.list_objects(bucket_name, recursive=True)
|
||||
for obj in objects:
|
||||
minio_client.remove_object(bucket_name, obj.object_name)
|
||||
minio_client.remove_bucket(bucket_name)
|
||||
print(f"已强制删除存储桶: {bucket_name}")
|
||||
except Exception as e:
|
||||
print(f"强制删除存储桶 {bucket_name} 仍然失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理存储桶 {bucket_name} 时发生错误: {str(e)}")
|
||||
|
||||
print("MinIO存储桶清理完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f"清理MinIO存储桶失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def confirm_action():
|
||||
"""确认操作"""
|
||||
print("警告: 此操作将永久删除所有数据!")
|
||||
confirmation = input("确认要清空所有数据吗? (输入'y'确认): ")
|
||||
return confirmation.lower() == 'y'
|
||||
|
||||
if __name__ == "__main__":
|
||||
if confirm_action():
|
||||
print("开始清理数据...")
|
||||
clear_database_tables()
|
||||
clear_minio_buckets()
|
||||
print("数据清理完成")
|
||||
else:
|
||||
print("操作已取消")
|
|
@ -0,0 +1,91 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
import mysql.connector
|
||||
from minio import Minio
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv("../../docker/.env")
|
||||
|
||||
# 数据库连接配置
|
||||
DB_CONFIG = {
|
||||
"host": "localhost",
|
||||
"port": int(os.getenv("MYSQL_PORT", "5455")),
|
||||
"user": "root",
|
||||
"password": os.getenv("MYSQL_PASSWORD", "infini_rag_flow"),
|
||||
"database": "rag_flow"
|
||||
}
|
||||
|
||||
# MinIO连接配置
|
||||
MINIO_CONFIG = {
|
||||
"endpoint": "localhost:" + os.getenv("MINIO_PORT", "9000"),
|
||||
"access_key": os.getenv("MINIO_USER", "rag_flow"),
|
||||
"secret_key": os.getenv("MINIO_PASSWORD", "infini_rag_flow"),
|
||||
"secure": False
|
||||
}
|
||||
|
||||
def get_used_buckets_from_db():
|
||||
"""从数据库获取正在使用的存储桶(kb_id)列表"""
|
||||
try:
|
||||
conn = mysql.connector.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 查询所有不重复的kb_id
|
||||
cursor.execute("SELECT DISTINCT kb_id FROM document")
|
||||
kb_ids = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return kb_ids
|
||||
|
||||
except Exception as e:
|
||||
print(f"数据库查询失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def cleanup_unused_buckets():
|
||||
"""清理未使用的MinIO存储桶"""
|
||||
try:
|
||||
# 获取MinIO客户端
|
||||
minio_client = Minio(
|
||||
endpoint=MINIO_CONFIG["endpoint"],
|
||||
access_key=MINIO_CONFIG["access_key"],
|
||||
secret_key=MINIO_CONFIG["secret_key"],
|
||||
secure=MINIO_CONFIG["secure"]
|
||||
)
|
||||
|
||||
# 获取数据库中的有效kb_id列表
|
||||
used_buckets = set(get_used_buckets_from_db())
|
||||
|
||||
# 获取MinIO中的所有存储桶
|
||||
all_buckets = minio_client.list_buckets()
|
||||
minio_bucket_names = {bucket.name for bucket in all_buckets}
|
||||
|
||||
# 计算需要删除的存储桶
|
||||
buckets_to_delete = minio_bucket_names - used_buckets
|
||||
|
||||
if not buckets_to_delete:
|
||||
print("没有需要删除的多余存储桶")
|
||||
return
|
||||
|
||||
print(f"发现 {len(buckets_to_delete)} 个多余存储桶需要清理:")
|
||||
|
||||
# 删除多余的存储桶
|
||||
for bucket_name in buckets_to_delete:
|
||||
try:
|
||||
# 先确保存储桶为空
|
||||
objects = minio_client.list_objects(bucket_name)
|
||||
for obj in objects:
|
||||
minio_client.remove_object(bucket_name, obj.object_name)
|
||||
|
||||
# 删除存储桶
|
||||
minio_client.remove_bucket(bucket_name)
|
||||
print(f"已删除存储桶: {bucket_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"删除存储桶 {bucket_name} 失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"清理存储桶过程中发生错误: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
cleanup_unused_buckets()
|
|
@ -0,0 +1,214 @@
|
|||
import os
|
||||
import mysql.connector
|
||||
from tabulate import tabulate
|
||||
from dotenv import load_dotenv
|
||||
from minio import Minio
|
||||
from io import BytesIO
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv("../../docker/.env")
|
||||
|
||||
# 数据库连接配置
|
||||
DB_CONFIG = {
|
||||
"host": "localhost", # 如果在Docker外运行,使用localhost
|
||||
"port": int(os.getenv("MYSQL_PORT", "5455")),
|
||||
"user": "root",
|
||||
"password": os.getenv("MYSQL_PASSWORD", "infini_rag_flow"),
|
||||
"database": "rag_flow"
|
||||
}
|
||||
|
||||
# MinIO连接配置
|
||||
MINIO_CONFIG = {
|
||||
"endpoint": "localhost:" + os.getenv("MINIO_PORT", "9000"),
|
||||
"access_key": os.getenv("MINIO_USER", "rag_flow"),
|
||||
"secret_key": os.getenv("MINIO_PASSWORD", "infini_rag_flow"),
|
||||
"secure": False
|
||||
}
|
||||
|
||||
def get_all_documents():
|
||||
"""获取所有文档信息及其在MinIO中的存储位置"""
|
||||
try:
|
||||
# 连接到MySQL数据库
|
||||
conn = mysql.connector.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 首先获取document表的列信息
|
||||
cursor.execute("SHOW COLUMNS FROM document")
|
||||
columns = [column['Field'] for column in cursor.fetchall()]
|
||||
|
||||
# 构建动态查询语句,只选择存在的列
|
||||
select_fields = []
|
||||
for field in ['id', 'name', 'kb_id', 'location', 'size', 'type', 'created_by', 'create_time']:
|
||||
if field in columns:
|
||||
select_fields.append(f'd.{field}')
|
||||
|
||||
# 添加可选字段
|
||||
optional_fields = ['token_count', 'chunk_count']
|
||||
for field in optional_fields:
|
||||
if field in columns:
|
||||
select_fields.append(f'd.{field}')
|
||||
|
||||
# 构建并执行查询
|
||||
query = f"""
|
||||
SELECT {', '.join(select_fields)}
|
||||
FROM document d
|
||||
ORDER BY d.create_time DESC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
|
||||
documents = cursor.fetchall()
|
||||
|
||||
# 获取文档与文件的关联信息
|
||||
cursor.execute("""
|
||||
SELECT f2d.document_id, f.id as file_id, f.parent_id, f.source_type
|
||||
FROM file2document f2d
|
||||
JOIN file f ON f2d.file_id = f.id
|
||||
""")
|
||||
|
||||
file_mappings = {}
|
||||
for row in cursor.fetchall():
|
||||
file_mappings[row['document_id']] = {
|
||||
'file_id': row['file_id'],
|
||||
'parent_id': row['parent_id'],
|
||||
'source_type': row['source_type']
|
||||
}
|
||||
|
||||
# 整合信息
|
||||
result = []
|
||||
for doc in documents:
|
||||
doc_id = doc['id']
|
||||
kb_id = doc['kb_id']
|
||||
location = doc['location']
|
||||
|
||||
# 确定存储位置
|
||||
storage_bucket = kb_id
|
||||
storage_location = location
|
||||
|
||||
# 如果有文件映射,检查是否需要使用文件的parent_id作为bucket
|
||||
if doc_id in file_mappings:
|
||||
file_info = file_mappings[doc_id]
|
||||
# 模拟File2DocumentService.get_storage_address的逻辑
|
||||
if file_info.get('source_type') is None or file_info.get('source_type') == 0: # LOCAL
|
||||
storage_bucket = file_info['parent_id']
|
||||
|
||||
# 构建MinIO存储路径
|
||||
minio_path = f"{storage_bucket}/{storage_location}"
|
||||
|
||||
# 构建结果字典,只包含存在的字段
|
||||
result_item = {
|
||||
'id': doc_id,
|
||||
'name': doc.get('name', ''),
|
||||
'kb_id': kb_id,
|
||||
'size': doc.get('size', 0),
|
||||
'type': doc.get('type', ''),
|
||||
'minio_path': minio_path,
|
||||
'storage_bucket': storage_bucket,
|
||||
'storage_location': storage_location
|
||||
}
|
||||
|
||||
# 添加可选字段
|
||||
if 'token_count' in doc:
|
||||
result_item['token_count'] = doc['token_count']
|
||||
if 'chunk_count' in doc:
|
||||
result_item['chunk_count'] = doc['chunk_count']
|
||||
|
||||
result.append(result_item)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return []
|
||||
|
||||
def download_document_from_minio(bucket, object_name, output_path):
|
||||
"""从MinIO下载文档"""
|
||||
try:
|
||||
# 创建MinIO客户端
|
||||
minio_client = Minio(
|
||||
endpoint=MINIO_CONFIG["endpoint"],
|
||||
access_key=MINIO_CONFIG["access_key"],
|
||||
secret_key=MINIO_CONFIG["secret_key"],
|
||||
secure=MINIO_CONFIG["secure"]
|
||||
)
|
||||
|
||||
# 检查bucket是否存在
|
||||
if not minio_client.bucket_exists(bucket):
|
||||
print(f"错误: Bucket '{bucket}' 不存在")
|
||||
return False
|
||||
|
||||
# 下载文件
|
||||
print(f"正在从MinIO下载: {bucket}/{object_name} 到 {output_path}")
|
||||
minio_client.fget_object(bucket, object_name, output_path)
|
||||
print(f"文件已成功下载到: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"下载文件时出错: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
documents = get_all_documents()
|
||||
|
||||
if not documents:
|
||||
print("未找到任何文档信息")
|
||||
return
|
||||
|
||||
# 使用tabulate打印表格
|
||||
# 动态确定表头
|
||||
sample_doc = documents[0]
|
||||
headers = ['ID', '文档名', '数据集ID', '大小(字节)', '类型', 'MinIO路径']
|
||||
if 'token_count' in sample_doc:
|
||||
headers.insert(-1, 'Token数')
|
||||
if 'chunk_count' in sample_doc:
|
||||
headers.insert(-1, '块数')
|
||||
|
||||
# 构建表格数据
|
||||
table_data = []
|
||||
for doc in documents:
|
||||
row = [
|
||||
doc['id'],
|
||||
doc['name'],
|
||||
doc['kb_id'],
|
||||
doc['size'],
|
||||
doc['type']
|
||||
]
|
||||
|
||||
if 'token_count' in doc:
|
||||
row.append(doc['token_count'])
|
||||
if 'chunk_count' in doc:
|
||||
row.append(doc['chunk_count'])
|
||||
|
||||
row.append(doc['minio_path'])
|
||||
table_data.append(row)
|
||||
|
||||
print(tabulate(table_data, headers=headers, tablefmt="grid"))
|
||||
print(f"总计: {len(documents)}个文档")
|
||||
|
||||
# 下载第一个文档
|
||||
if documents:
|
||||
first_doc = documents[0]
|
||||
doc_name = first_doc['name']
|
||||
bucket = first_doc['storage_bucket']
|
||||
object_name = first_doc['storage_location']
|
||||
|
||||
# 创建下载目录
|
||||
download_dir = "downloads"
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
|
||||
# 构建输出文件路径
|
||||
output_path = os.path.join(download_dir, doc_name)
|
||||
|
||||
# 下载文件
|
||||
# success = download_document_from_minio(bucket, object_name, output_path)
|
||||
# if success:
|
||||
# print(f"\n已成功下载第一个文档: {doc_name}")
|
||||
# print(f"文件保存在: {os.path.abspath(output_path)}")
|
||||
# else:
|
||||
# print("\n下载第一个文档失败")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -5,14 +5,18 @@ from flask import Blueprint
|
|||
users_bp = Blueprint('users', __name__, url_prefix='/api/v1/users')
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/api/v1/teams')
|
||||
tenants_bp = Blueprint('tenants', __name__, url_prefix='/api/v1/tenants')
|
||||
files_bp = Blueprint('files', __name__, url_prefix='/api/v1/files')
|
||||
|
||||
# 导入路由
|
||||
from .users.routes import *
|
||||
from .teams.routes import *
|
||||
from .tenants.routes import *
|
||||
from .files.routes import *
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
"""注册所有路由蓝图到应用"""
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(tenants_bp)
|
||||
app.register_blueprint(tenants_bp)
|
||||
app.register_blueprint(files_bp)
|
|
@ -0,0 +1,177 @@
|
|||
from flask import jsonify, request, send_file, current_app
|
||||
from io import BytesIO
|
||||
from .. import files_bp
|
||||
from services.files.service import (
|
||||
get_files_list,
|
||||
get_file_info,
|
||||
download_file_from_minio,
|
||||
delete_file,
|
||||
batch_delete_files,
|
||||
get_minio_client
|
||||
)
|
||||
|
||||
@files_bp.route('', methods=['GET', 'OPTIONS'])
|
||||
def get_files():
|
||||
"""获取文件列表的API端点"""
|
||||
if request.method == 'OPTIONS':
|
||||
return '', 200
|
||||
|
||||
try:
|
||||
current_page = int(request.args.get('currentPage', 1))
|
||||
page_size = int(request.args.get('size', 10))
|
||||
name_filter = request.args.get('name', '')
|
||||
|
||||
result, total = get_files_list(current_page, page_size, name_filter)
|
||||
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": result,
|
||||
"total": total
|
||||
},
|
||||
"message": "获取文件列表成功"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"message": f"获取文件列表失败: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@files_bp.route('/<string:file_id>/download', methods=['GET', 'OPTIONS'])
|
||||
def download_file(file_id):
|
||||
try:
|
||||
current_app.logger.info(f"开始处理文件下载请求: {file_id}")
|
||||
document, _, storage_bucket, storage_location = get_file_info(file_id)
|
||||
|
||||
if not document:
|
||||
current_app.logger.error(f"文件不存在: {file_id}")
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"message": f"文件 {file_id} 不存在",
|
||||
"details": "文件记录不存在或已被删除"
|
||||
}), 404
|
||||
|
||||
current_app.logger.info(f"文件信息获取成功: {file_id}, 存储位置: {storage_bucket}/{storage_location}")
|
||||
|
||||
try:
|
||||
minio_client = get_minio_client()
|
||||
current_app.logger.info(f"MinIO客户端创建成功, 准备检查文件: {storage_bucket}/{storage_location}")
|
||||
|
||||
obj = minio_client.stat_object(storage_bucket, storage_location)
|
||||
if not obj:
|
||||
current_app.logger.error(f"文件对象为空: {storage_bucket}/{storage_location}")
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"message": "文件内容为空",
|
||||
"details": "MinIO存储桶中存在文件记录但内容为空"
|
||||
}), 404
|
||||
|
||||
if obj.size == 0:
|
||||
current_app.logger.error(f"文件大小为0: {storage_bucket}/{storage_location}")
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"message": "文件内容为空",
|
||||
"details": "MinIO存储桶中文件大小为0"
|
||||
}), 404
|
||||
|
||||
current_app.logger.info(f"文件检查成功, 大小: {obj.size} 字节, 准备下载")
|
||||
|
||||
response = minio_client.get_object(storage_bucket, storage_location)
|
||||
file_data = response.read()
|
||||
|
||||
current_app.logger.info(f"文件读取成功, 大小: {len(file_data)} 字节, 准备发送")
|
||||
|
||||
return send_file(
|
||||
BytesIO(file_data),
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=document['name']
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"MinIO操作异常: {str(e)}", exc_info=True)
|
||||
# 检查是否是连接错误
|
||||
if "connection" in str(e).lower():
|
||||
return jsonify({
|
||||
"code": 503,
|
||||
"message": "存储服务连接失败",
|
||||
"details": f"无法连接到MinIO服务: {str(e)}"
|
||||
}), 503
|
||||
# 检查是否是权限错误
|
||||
elif "access denied" in str(e).lower() or "permission" in str(e).lower():
|
||||
return jsonify({
|
||||
"code": 403,
|
||||
"message": "存储服务访问被拒绝",
|
||||
"details": f"MinIO访问权限错误: {str(e)}"
|
||||
}), 403
|
||||
# 其他错误
|
||||
else:
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"message": "存储服务异常",
|
||||
"details": str(e)
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"文件下载异常: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"message": "文件下载失败",
|
||||
"details": str(e)
|
||||
}), 500
|
||||
|
||||
@files_bp.route('/<string:file_id>', methods=['DELETE', 'OPTIONS'])
|
||||
def delete_file_route(file_id):
|
||||
"""删除文件的API端点"""
|
||||
if request.method == 'OPTIONS':
|
||||
return '', 200
|
||||
|
||||
try:
|
||||
success = delete_file(file_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"message": "文件删除成功"
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"message": f"文件 {file_id} 不存在"
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"message": f"删除文件失败: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@files_bp.route('/batch', methods=['DELETE', 'OPTIONS'])
|
||||
def batch_delete_files_route():
|
||||
"""批量删除文件的API端点"""
|
||||
if request.method == 'OPTIONS':
|
||||
return '', 200
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
file_ids = data.get('ids', [])
|
||||
|
||||
if not file_ids:
|
||||
return jsonify({
|
||||
"code": 400,
|
||||
"message": "未提供要删除的文件ID"
|
||||
}), 400
|
||||
|
||||
success_count = batch_delete_files(file_ids)
|
||||
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"message": f"成功删除 {success_count}/{len(file_ids)} 个文件"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"message": f"批量删除文件失败: {str(e)}"
|
||||
}), 500
|
|
@ -0,0 +1,400 @@
|
|||
import os
|
||||
import mysql.connector
|
||||
from io import BytesIO
|
||||
from minio import Minio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv("../../docker/.env")
|
||||
|
||||
# 数据库连接配置
|
||||
DB_CONFIG = {
|
||||
"host": "localhost", # 如果在Docker外运行,使用localhost
|
||||
"port": int(os.getenv("MYSQL_PORT", "5455")),
|
||||
"user": "root",
|
||||
"password": os.getenv("MYSQL_PASSWORD", "infini_rag_flow"),
|
||||
"database": "rag_flow"
|
||||
}
|
||||
|
||||
# MinIO连接配置
|
||||
MINIO_CONFIG = {
|
||||
"endpoint": "localhost:" + os.getenv("MINIO_PORT", "9000"),
|
||||
"access_key": os.getenv("MINIO_USER", "rag_flow"),
|
||||
"secret_key": os.getenv("MINIO_PASSWORD", "infini_rag_flow"),
|
||||
"secure": False
|
||||
}
|
||||
|
||||
def get_minio_client():
|
||||
"""创建MinIO客户端"""
|
||||
return Minio(
|
||||
endpoint=MINIO_CONFIG["endpoint"],
|
||||
access_key=MINIO_CONFIG["access_key"],
|
||||
secret_key=MINIO_CONFIG["secret_key"],
|
||||
secure=MINIO_CONFIG["secure"]
|
||||
)
|
||||
|
||||
def get_db_connection():
|
||||
"""创建数据库连接"""
|
||||
return mysql.connector.connect(**DB_CONFIG)
|
||||
|
||||
def get_files_list(current_page, page_size, name_filter=""):
|
||||
"""
|
||||
获取文件列表
|
||||
|
||||
Args:
|
||||
current_page: 当前页码
|
||||
page_size: 每页大小
|
||||
name_filter: 文件名过滤条件
|
||||
|
||||
Returns:
|
||||
tuple: (文件列表, 总数)
|
||||
"""
|
||||
try:
|
||||
# 计算偏移量
|
||||
offset = (current_page - 1) * page_size
|
||||
|
||||
# 连接数据库
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 构建查询条件
|
||||
where_clause = ""
|
||||
params = []
|
||||
|
||||
if name_filter:
|
||||
where_clause = "WHERE d.name LIKE %s"
|
||||
params.append(f"%{name_filter}%")
|
||||
|
||||
# 查询总数
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) as total
|
||||
FROM document d
|
||||
{where_clause}
|
||||
"""
|
||||
cursor.execute(count_query, params)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 查询文件列表
|
||||
query = f"""
|
||||
SELECT d.id, d.name, d.kb_id, d.location, d.size, d.type, d.create_time
|
||||
FROM document d
|
||||
{where_clause}
|
||||
ORDER BY d.create_time DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cursor.execute(query, params + [page_size, offset])
|
||||
documents = cursor.fetchall()
|
||||
|
||||
# 获取文档与文件的关联信息
|
||||
doc_ids = [doc['id'] for doc in documents]
|
||||
file_mappings = {}
|
||||
|
||||
if doc_ids:
|
||||
placeholders = ', '.join(['%s'] * len(doc_ids))
|
||||
cursor.execute(f"""
|
||||
SELECT f2d.document_id, f.id as file_id, f.parent_id, f.source_type
|
||||
FROM file2document f2d
|
||||
JOIN file f ON f2d.file_id = f.id
|
||||
WHERE f2d.document_id IN ({placeholders})
|
||||
""", doc_ids)
|
||||
|
||||
for row in cursor.fetchall():
|
||||
file_mappings[row['document_id']] = {
|
||||
'file_id': row['file_id'],
|
||||
'parent_id': row['parent_id'],
|
||||
'source_type': row['source_type']
|
||||
}
|
||||
|
||||
# 整合信息
|
||||
result = []
|
||||
for doc in documents:
|
||||
doc_id = doc['id']
|
||||
kb_id = doc['kb_id']
|
||||
location = doc['location']
|
||||
|
||||
# 确定存储位置
|
||||
storage_bucket = kb_id
|
||||
storage_location = location
|
||||
|
||||
# 如果有文件映射,检查是否需要使用文件的parent_id作为bucket
|
||||
if doc_ids and doc_id in file_mappings:
|
||||
file_info = file_mappings[doc_id]
|
||||
# 模拟File2DocumentService.get_storage_address的逻辑
|
||||
if file_info.get('source_type') is None or file_info.get('source_type') == 0: # LOCAL
|
||||
storage_bucket = file_info['parent_id']
|
||||
|
||||
# 构建结果字典
|
||||
result_item = {
|
||||
'id': doc_id,
|
||||
'name': doc.get('name', ''),
|
||||
'kb_id': kb_id,
|
||||
'size': doc.get('size', 0),
|
||||
'type': doc.get('type', ''),
|
||||
'location': location,
|
||||
'create_time': doc.get('create_time', 0)
|
||||
}
|
||||
|
||||
result.append(result_item)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return result, total
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def get_file_info(file_id):
|
||||
"""
|
||||
获取文件信息
|
||||
|
||||
Args:
|
||||
file_id: 文件ID
|
||||
|
||||
Returns:
|
||||
tuple: (文档信息, 文件映射信息, 存储桶, 存储位置)
|
||||
"""
|
||||
try:
|
||||
# 连接数据库
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 查询文档信息
|
||||
cursor.execute("""
|
||||
SELECT d.id, d.name, d.kb_id, d.location, d.type
|
||||
FROM document d
|
||||
WHERE d.id = %s
|
||||
""", (file_id,))
|
||||
|
||||
document = cursor.fetchone()
|
||||
if not document:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return None, None, None, None
|
||||
|
||||
# 获取文档与文件的关联信息
|
||||
cursor.execute("""
|
||||
SELECT f2d.document_id, f.id as file_id, f.parent_id, f.source_type
|
||||
FROM file2document f2d
|
||||
JOIN file f ON f2d.file_id = f.id
|
||||
WHERE f2d.document_id = %s
|
||||
""", (file_id,))
|
||||
|
||||
file_mapping = cursor.fetchone()
|
||||
|
||||
# 确定存储位置
|
||||
storage_bucket = document['kb_id']
|
||||
storage_location = document['location']
|
||||
|
||||
# 如果有文件映射,检查是否需要使用文件的parent_id作为bucket
|
||||
if file_mapping:
|
||||
# 模拟File2DocumentService.get_storage_address的逻辑
|
||||
if file_mapping.get('source_type') is None or file_mapping.get('source_type') == 0: # LOCAL
|
||||
storage_bucket = file_mapping['parent_id']
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return document, file_mapping, storage_bucket, storage_location
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def download_file_from_minio(storage_bucket, storage_location):
|
||||
"""
|
||||
从MinIO下载文件
|
||||
|
||||
Args:
|
||||
storage_bucket: 存储桶
|
||||
storage_location: 存储位置
|
||||
|
||||
Returns:
|
||||
bytes: 文件数据
|
||||
"""
|
||||
try:
|
||||
# 从MinIO下载文件
|
||||
minio_client = get_minio_client()
|
||||
|
||||
# 检查bucket是否存在
|
||||
if not minio_client.bucket_exists(storage_bucket):
|
||||
raise Exception(f"存储桶 {storage_bucket} 不存在")
|
||||
|
||||
# 下载文件
|
||||
response = minio_client.get_object(storage_bucket, storage_location)
|
||||
file_data = response.read()
|
||||
|
||||
return file_data
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def delete_file(file_id):
|
||||
"""
|
||||
删除文件
|
||||
|
||||
Args:
|
||||
file_id: 文件ID
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
try:
|
||||
# 获取文件信息
|
||||
document, file_mapping, storage_bucket, storage_location = get_file_info(file_id)
|
||||
|
||||
if not document:
|
||||
return False
|
||||
|
||||
# 连接数据库
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 如果有文件映射,获取文件ID
|
||||
file_id_to_delete = None
|
||||
if file_mapping:
|
||||
file_id_to_delete = file_mapping['file_id']
|
||||
|
||||
# 开始事务
|
||||
conn.start_transaction()
|
||||
|
||||
try:
|
||||
# 1. 删除document表中的记录
|
||||
cursor.execute("DELETE FROM document WHERE id = %s", (file_id,))
|
||||
|
||||
# 2. 如果有关联的file2document记录,删除它
|
||||
if file_mapping:
|
||||
cursor.execute("DELETE FROM file2document WHERE document_id = %s", (file_id,))
|
||||
|
||||
# 3. 如果有关联的file记录,删除它
|
||||
if file_id_to_delete:
|
||||
cursor.execute("DELETE FROM file WHERE id = %s", (file_id_to_delete,))
|
||||
|
||||
# 提交事务
|
||||
conn.commit()
|
||||
|
||||
# 从MinIO删除文件
|
||||
try:
|
||||
minio_client = get_minio_client()
|
||||
|
||||
# 检查bucket是否存在
|
||||
if minio_client.bucket_exists(storage_bucket):
|
||||
# 删除文件
|
||||
minio_client.remove_object(storage_bucket, storage_location)
|
||||
except Exception as e:
|
||||
# 即使MinIO删除失败,也不影响数据库操作的成功
|
||||
print(f"从MinIO删除文件失败: {str(e)}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# 回滚事务
|
||||
conn.rollback()
|
||||
raise e
|
||||
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def batch_delete_files(file_ids):
|
||||
"""
|
||||
批量删除文件
|
||||
|
||||
Args:
|
||||
file_ids: 文件ID列表
|
||||
|
||||
Returns:
|
||||
int: 成功删除的文件数量
|
||||
"""
|
||||
if not file_ids:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 创建MinIO客户端
|
||||
minio_client = get_minio_client()
|
||||
|
||||
# 开始事务
|
||||
conn.start_transaction()
|
||||
|
||||
try:
|
||||
success_count = 0
|
||||
|
||||
for file_id in file_ids:
|
||||
# 查询文档信息
|
||||
cursor.execute("""
|
||||
SELECT d.id, d.kb_id, d.location
|
||||
FROM document d
|
||||
WHERE d.id = %s
|
||||
""", (file_id,))
|
||||
|
||||
document = cursor.fetchone()
|
||||
if not document:
|
||||
continue
|
||||
|
||||
# 获取文档与文件的关联信息
|
||||
cursor.execute("""
|
||||
SELECT f2d.id as f2d_id, f2d.document_id, f2d.file_id, f.parent_id, f.source_type
|
||||
FROM file2document f2d
|
||||
JOIN file f ON f2d.file_id = f.id
|
||||
WHERE f2d.document_id = %s
|
||||
""", (file_id,))
|
||||
|
||||
file_mapping = cursor.fetchone()
|
||||
|
||||
# 确定存储位置
|
||||
storage_bucket = document['kb_id']
|
||||
storage_location = document['location']
|
||||
|
||||
# 如果有文件映射,检查是否需要使用文件的parent_id作为bucket
|
||||
file_id_to_delete = None
|
||||
if file_mapping:
|
||||
file_id_to_delete = file_mapping['file_id']
|
||||
# 模拟File2DocumentService.get_storage_address的逻辑
|
||||
if file_mapping.get('source_type') is None or file_mapping.get('source_type') == 0: # LOCAL
|
||||
storage_bucket = file_mapping['parent_id']
|
||||
|
||||
# 1. 删除document表中的记录
|
||||
cursor.execute("DELETE FROM document WHERE id = %s", (file_id,))
|
||||
|
||||
# 2. 如果有关联的file2document记录,删除它
|
||||
if file_mapping:
|
||||
cursor.execute("DELETE FROM file2document WHERE id = %s", (file_mapping['f2d_id'],))
|
||||
|
||||
# 3. 如果有关联的file记录,删除它
|
||||
if file_id_to_delete:
|
||||
cursor.execute("DELETE FROM file WHERE id = %s", (file_id_to_delete,))
|
||||
|
||||
# 从MinIO删除文件
|
||||
try:
|
||||
# 检查bucket是否存在
|
||||
if minio_client.bucket_exists(storage_bucket):
|
||||
# 删除文件
|
||||
minio_client.remove_object(storage_bucket, storage_location)
|
||||
except Exception as e:
|
||||
# 即使MinIO删除失败,也不影响数据库操作的成功
|
||||
print(f"从MinIO删除文件失败: {str(e)}")
|
||||
|
||||
success_count += 1
|
||||
|
||||
# 提交事务
|
||||
conn.commit()
|
||||
|
||||
return success_count
|
||||
|
||||
except Exception as e:
|
||||
# 回滚事务
|
||||
conn.rollback()
|
||||
raise e
|
||||
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
|
@ -0,0 +1,93 @@
|
|||
import type { AxiosResponse } from "axios"
|
||||
import type { FileData, PageQuery, PageResult } from "./type"
|
||||
import { request } from "@/http/axios"
|
||||
import axios from "axios"
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function getFileListApi(params: PageQuery & { name?: string }) {
|
||||
return request<{ data: PageResult<FileData>, code: number, message: string }>({
|
||||
url: "/api/v1/files",
|
||||
method: "get",
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件 - 使用流式下载
|
||||
* @param fileId 文件ID
|
||||
* @param onDownloadProgress 下载进度回调
|
||||
*/
|
||||
export function downloadFileApi(
|
||||
fileId: string,
|
||||
onDownloadProgress?: (progressEvent: any) => void
|
||||
): Promise<AxiosResponse<Blob>> {
|
||||
console.log(`发起文件下载请求: ${fileId}`)
|
||||
const source = axios.CancelToken.source()
|
||||
|
||||
return request({
|
||||
url: `/api/v1/files/${fileId}/download`,
|
||||
method: "get",
|
||||
responseType: "blob",
|
||||
timeout: 300000,
|
||||
onDownloadProgress,
|
||||
cancelToken: source.token,
|
||||
validateStatus: (_status) => {
|
||||
// 允许所有状态码,以便在前端统一处理错误
|
||||
return true
|
||||
}
|
||||
}).then((response: unknown) => {
|
||||
const axiosResponse = response as AxiosResponse<Blob>
|
||||
console.log(`下载响应: ${axiosResponse.status}`, axiosResponse.data)
|
||||
// 确保响应对象包含必要的属性
|
||||
if (axiosResponse.data instanceof Blob && axiosResponse.data.size > 0) {
|
||||
// 如果是成功的Blob响应,确保状态码为200
|
||||
if (!axiosResponse.status) axiosResponse.status = 200
|
||||
return axiosResponse
|
||||
}
|
||||
return axiosResponse
|
||||
}).catch((error: any) => {
|
||||
console.error("下载请求失败:", error)
|
||||
// 将错误信息转换为统一格式
|
||||
if (error.response) {
|
||||
error.response.data = {
|
||||
message: error.response.data?.message || "服务器错误"
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}) as Promise<AxiosResponse<Blob>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载
|
||||
*/
|
||||
export function cancelDownload() {
|
||||
if (axios.isCancel(Error)) {
|
||||
axios.CancelToken.source().cancel("用户取消下载")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param fileId 文件ID
|
||||
*/
|
||||
export function deleteFileApi(fileId: string) {
|
||||
return request<{ code: number, message: string }>({
|
||||
url: `/api/v1/files/${fileId}`,
|
||||
method: "delete"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件
|
||||
* @param fileIds 文件ID数组
|
||||
*/
|
||||
export function batchDeleteFilesApi(fileIds: string[]) {
|
||||
return request<{ code: number, message: string }>({
|
||||
url: "/api/v1/files/batch",
|
||||
method: "delete",
|
||||
data: { ids: fileIds }
|
||||
})
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* 文件数据类型
|
||||
*/
|
||||
export interface FileData {
|
||||
/** 文件ID */
|
||||
id: string
|
||||
/** 文件名称 */
|
||||
name: string
|
||||
/** 文件大小(字节) */
|
||||
size: number
|
||||
/** 文件类型 */
|
||||
type: string
|
||||
/** 知识库ID */
|
||||
kb_id: string
|
||||
/** 存储位置 */
|
||||
location: string
|
||||
/** 创建时间 */
|
||||
create_time?: number
|
||||
/** 更新时间 */
|
||||
update_time?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表结果
|
||||
*/
|
||||
export interface FileListResult {
|
||||
/** 文件列表 */
|
||||
list: FileData[]
|
||||
/** 总条数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询参数
|
||||
*/
|
||||
export interface PageQuery {
|
||||
/** 当前页码 */
|
||||
currentPage: number
|
||||
/** 每页条数 */
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页结果
|
||||
*/
|
||||
export interface PageResult<T> {
|
||||
/** 数据列表 */
|
||||
list: T[]
|
||||
/** 总条数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应结构
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
/** 状态码 */
|
||||
code: number
|
||||
/** 响应数据 */
|
||||
data: T
|
||||
/** 响应消息 */
|
||||
message: string
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<!-- 文件夹主体 -->
|
||||
<path d="M896 320H576l-64-64H256c-35.3 0-64 28.7-64 64v576c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64V384c0-35.3-28.7-64-64-64z" fill="#4D90FE"></path>
|
||||
|
||||
<!-- 文件夹标签 -->
|
||||
<path d="M896 320H576l-32-32H256c0-35.3 28.7-64 64-64h256l64 64h256c35.3 0 64 28.7 64 64H896z" fill="#3B78E7"></path>
|
||||
|
||||
<!-- 文件1 -->
|
||||
<path d="M384 512h256v64H384z" fill="#FFFFFF"></path>
|
||||
|
||||
<!-- 文件2 -->
|
||||
<path d="M384 640h320v64H384z" fill="#FFFFFF"></path>
|
||||
|
||||
<!-- 文件3 -->
|
||||
<path d="M384 768h192v64H384z" fill="#FFFFFF"></path>
|
||||
|
||||
<!-- 文件图标1 -->
|
||||
<path d="M448 448h128v64H448z" fill="#F1F1F1"></path>
|
||||
|
||||
<!-- 文件图标2 -->
|
||||
<path d="M320 576h128v64H320z" fill="#F1F1F1"></path>
|
||||
|
||||
<!-- 文件图标3 -->
|
||||
<path d="M512 704h128v64H512z" fill="#F1F1F1"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 930 B |
|
@ -0,0 +1,437 @@
|
|||
<script lang="ts" setup>
|
||||
import type { FormInstance } from "element-plus"
|
||||
import { batchDeleteFilesApi, deleteFileApi, getFileListApi } from "@@/apis/files"
|
||||
import { usePagination } from "@@/composables/usePagination"
|
||||
import { Delete, Download, Refresh, Search } from "@element-plus/icons-vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import "element-plus/dist/index.css"
|
||||
import "element-plus/theme-chalk/el-message-box.css"
|
||||
import "element-plus/theme-chalk/el-message.css"
|
||||
|
||||
defineOptions({
|
||||
// 命名当前组件
|
||||
name: "File"
|
||||
})
|
||||
|
||||
const loading = ref<boolean>(false)
|
||||
const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
|
||||
|
||||
// 定义文件数据类型
|
||||
interface FileData {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
kb_id: string
|
||||
location: string
|
||||
create_time?: number
|
||||
}
|
||||
|
||||
// 查询文件列表
|
||||
const tableData = ref<FileData[]>([])
|
||||
const searchFormRef = ref<FormInstance | null>(null)
|
||||
const searchData = reactive({
|
||||
name: ""
|
||||
})
|
||||
|
||||
// 存储多选的表格数据
|
||||
const multipleSelection = ref<FileData[]>([])
|
||||
|
||||
// 获取文件列表数据
|
||||
function getTableData() {
|
||||
loading.value = true
|
||||
// 调用获取文件列表API
|
||||
getFileListApi({
|
||||
currentPage: paginationData.currentPage,
|
||||
size: paginationData.pageSize,
|
||||
name: searchData.name
|
||||
}).then(({ data }) => {
|
||||
paginationData.total = data.total
|
||||
tableData.value = data.list
|
||||
// 清空选中数据
|
||||
multipleSelection.value = []
|
||||
}).catch(() => {
|
||||
tableData.value = []
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
function handleSearch() {
|
||||
paginationData.currentPage === 1 ? getTableData() : (paginationData.currentPage = 1)
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
function resetSearch() {
|
||||
searchFormRef.value?.resetFields()
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
async function handleDownload(row: FileData) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
lock: true,
|
||||
text: "正在准备下载...",
|
||||
background: "rgba(0, 0, 0, 0.7)"
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`开始下载文件: ${row.id} ${row.name}`)
|
||||
|
||||
// 直接使用fetch API进行文件下载
|
||||
const response = await fetch(`/api/v1/files/${row.id}/download`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/octet-stream"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器返回错误: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
// 获取文件数据
|
||||
const blob = await response.blob()
|
||||
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("文件内容为空")
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = row.name
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// 清理资源
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`文件 "${row.name}" 下载成功`)
|
||||
}, 100)
|
||||
} catch (error: any) {
|
||||
console.error("下载文件时发生错误:", error)
|
||||
ElMessage.error(`文件下载失败: ${error?.message || "未知错误"}`)
|
||||
} finally {
|
||||
loadingInstance.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
function handleDelete(row: FileData) {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除文件 "${row.name}" 吗?`,
|
||||
"删除确认",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
center: true,
|
||||
customClass: "delete-confirm-dialog",
|
||||
distinguishCancelAndClose: true,
|
||||
showClose: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
roundButton: true,
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === "confirm") {
|
||||
instance.confirmButtonLoading = true
|
||||
instance.confirmButtonText = "删除中..."
|
||||
|
||||
loading.value = true
|
||||
deleteFileApi(row.id)
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功")
|
||||
getTableData() // 刷新表格数据
|
||||
done()
|
||||
})
|
||||
.catch((error) => {
|
||||
ElMessage.error(`删除失败: ${error?.message || "未知错误"}`)
|
||||
done()
|
||||
})
|
||||
.finally(() => {
|
||||
instance.confirmButtonLoading = false
|
||||
loading.value = false
|
||||
})
|
||||
} else {
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(() => {
|
||||
// 用户取消删除操作
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除文件
|
||||
function handleBatchDelete() {
|
||||
if (multipleSelection.value.length === 0) {
|
||||
ElMessage.warning("请至少选择一个文件")
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除选中的 <strong>${multipleSelection.value.length}</strong> 个文件吗?<br><span style="color: #F56C6C; font-size: 12px;">此操作不可恢复</span>`,
|
||||
"批量删除确认",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
center: true,
|
||||
customClass: "delete-confirm-dialog",
|
||||
distinguishCancelAndClose: true,
|
||||
showClose: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
roundButton: true,
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === "confirm") {
|
||||
instance.confirmButtonLoading = true
|
||||
instance.confirmButtonText = "删除中..."
|
||||
|
||||
loading.value = true
|
||||
const ids = multipleSelection.value.map(item => item.id)
|
||||
batchDeleteFilesApi(ids)
|
||||
.then(() => {
|
||||
ElMessage.success(`成功删除 ${multipleSelection.value.length} 个文件`)
|
||||
getTableData() // 刷新表格数据
|
||||
done()
|
||||
})
|
||||
.catch((error) => {
|
||||
ElMessage.error(`批量删除失败: ${error?.message || "未知错误"}`)
|
||||
done()
|
||||
})
|
||||
.finally(() => {
|
||||
instance.confirmButtonLoading = false
|
||||
loading.value = false
|
||||
})
|
||||
} else {
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(() => {
|
||||
// 用户取消删除操作
|
||||
})
|
||||
}
|
||||
|
||||
// 表格多选事件处理
|
||||
function handleSelectionChange(selection: FileData[]) {
|
||||
multipleSelection.value = selection
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(size: number) {
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
} else if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(2)} KB`
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(2)} MB`
|
||||
} else {
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
// 监听分页参数的变化
|
||||
watch([() => paginationData.currentPage, () => paginationData.pageSize], getTableData, { immediate: true })
|
||||
|
||||
// 确保页面挂载和激活时获取数据
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
// 当从其他页面切换回来时刷新数据
|
||||
onActivated(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card v-loading="loading" shadow="never" class="search-wrapper">
|
||||
<el-form ref="searchFormRef" :inline="true" :model="searchData">
|
||||
<el-form-item prop="name" label="文件名">
|
||||
<el-input v-model="searchData.name" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="resetSearch">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<div class="toolbar-wrapper">
|
||||
<div>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:disabled="multipleSelection.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="序号" align="center" width="80">
|
||||
<template #default="scope">
|
||||
{{ (paginationData.currentPage - 1) * paginationData.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="文档名" align="center" />
|
||||
<el-table-column label="大小" align="center" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatFileSize(scope.row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" align="center" width="120" />
|
||||
<el-table-column fixed="right" label="操作" width="180" align="center">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" text bg size="small" :icon="Download" @click="handleDownload(scope.row)">
|
||||
下载
|
||||
</el-button>
|
||||
<el-button type="danger" text bg size="small" :icon="Delete" @click="handleDelete(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pager-wrapper">
|
||||
<el-pagination
|
||||
background
|
||||
:layout="paginationData.layout"
|
||||
:page-sizes="paginationData.pageSizes"
|
||||
:total="paginationData.total"
|
||||
:page-size="paginationData.pageSize"
|
||||
:current-page="paginationData.currentPage"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
margin-bottom: 20px;
|
||||
:deep(.el-card__body) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pager-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 全局样式 - 确保弹窗样式正确 */
|
||||
.el-message-box {
|
||||
max-width: 500px !important;
|
||||
width: auto !important;
|
||||
min-width: 420px;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
.delete-confirm-dialog {
|
||||
max-width: 500px !important;
|
||||
width: auto !important;
|
||||
min-width: 420px;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__header {
|
||||
padding: 15px 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__content {
|
||||
padding: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__message {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__message p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__btns {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-button {
|
||||
padding: 9px 20px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-button--primary {
|
||||
background-color: #f56c6c;
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-button--primary:hover,
|
||||
.delete-confirm-dialog .el-button--primary:focus {
|
||||
background-color: #f78989;
|
||||
border-color: #f78989;
|
||||
}
|
||||
|
||||
.delete-confirm-dialog .el-message-box__status {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
|
@ -46,42 +46,6 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||
hidden: true
|
||||
}
|
||||
},
|
||||
// {
|
||||
// path: "/",
|
||||
// component: Layouts,
|
||||
// redirect: "/dashboard",
|
||||
// children: [
|
||||
// {
|
||||
// path: "dashboard",
|
||||
// component: () => import("@/pages/dashboard/index.vue"),
|
||||
// name: "Dashboard",
|
||||
// meta: {
|
||||
// title: "首页",
|
||||
// svgIcon: "dashboard",
|
||||
// affix: true
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
|
||||
// {
|
||||
// path: "/",
|
||||
// component: Layouts,
|
||||
// redirect: "/dashboard",
|
||||
// children: [
|
||||
// {
|
||||
// path: "dashboard",
|
||||
// component: () => import("@/pages/dashboard/index.vue"),
|
||||
// name: "Dashboard",
|
||||
// meta: {
|
||||
// title: "首页",
|
||||
// svgIcon: "dashboard",
|
||||
// affix: true
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
|
||||
{
|
||||
path: "/",
|
||||
component: Layouts,
|
||||
|
@ -134,6 +98,24 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/file",
|
||||
component: Layouts,
|
||||
redirect: "/file/index",
|
||||
children: [
|
||||
{
|
||||
path: "index",
|
||||
component: () => import("@/pages/file/index.vue"),
|
||||
name: "File",
|
||||
meta: {
|
||||
title: "文件管理",
|
||||
svgIcon: "file",
|
||||
affix: false,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
// {
|
||||
// path: "/",
|
||||
|
|
|
@ -9,18 +9,18 @@ declare module 'vue' {
|
|||
export interface GlobalComponents {
|
||||
SvgIcon: import("vue").DefineComponent<{
|
||||
name: {
|
||||
type: import("vue").PropType<"dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
type: import("vue").PropType<"dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
default: string;
|
||||
required: true;
|
||||
};
|
||||
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, Readonly<import("vue").ExtractPropTypes<{
|
||||
name: {
|
||||
type: import("vue").PropType<"dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
type: import("vue").PropType<"dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
default: string;
|
||||
required: true;
|
||||
};
|
||||
}>>, {
|
||||
name: "dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
name: "dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,20 +7,20 @@
|
|||
declare module '~virtual/svg-component' {
|
||||
const SvgIcon: import("vue").DefineComponent<{
|
||||
name: {
|
||||
type: import("vue").PropType<"dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
type: import("vue").PropType<"dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
default: string;
|
||||
required: true;
|
||||
};
|
||||
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, Readonly<import("vue").ExtractPropTypes<{
|
||||
name: {
|
||||
type: import("vue").PropType<"dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
type: import("vue").PropType<"dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management">;
|
||||
default: string;
|
||||
required: true;
|
||||
};
|
||||
}>>, {
|
||||
name: "dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
name: "dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
}>;
|
||||
export const svgNames: ["dashboard", "fullscreen-exit", "fullscreen", "keyboard-down", "keyboard-enter", "keyboard-esc", "keyboard-up", "search", "team-management", "user-config", "user-management"];
|
||||
export type SvgName = "dashboard" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
export const svgNames: ["dashboard", "file", "fullscreen-exit", "fullscreen", "keyboard-down", "keyboard-enter", "keyboard-esc", "keyboard-up", "search", "team-management", "user-config", "user-management"];
|
||||
export type SvgName = "dashboard" | "file" | "fullscreen-exit" | "fullscreen" | "keyboard-down" | "keyboard-enter" | "keyboard-esc" | "keyboard-up" | "search" | "team-management" | "user-config" | "user-management";
|
||||
export default SvgIcon;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue