refactor(files): harden storage and split frontend
Add transactional file storage workflows, typed API contracts, recursive category handling, frontend component separation, and Files API coverage.
This commit is contained in:
parent
867d4e3ff1
commit
d7514f4e65
1
backend/agenteval/services/__init__.py
Normal file
1
backend/agenteval/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Application services."""
|
||||||
191
backend/agenteval/services/files.py
Normal file
191
backend/agenteval/services/files.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"""Application service for file management workflows."""
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from agenteval.storage.db import FileCategoryDB, FileRecordDB
|
||||||
|
from agenteval.storage.file_repository import FileCategoryNode, FileCategoryRepository, FileRecordRepository
|
||||||
|
from agenteval.storage.file_storage import (
|
||||||
|
FileStorage,
|
||||||
|
FileTooLargeError,
|
||||||
|
InvalidStorageNameError,
|
||||||
|
StagedDeletion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileManagementError(Exception):
|
||||||
|
"""Base class for expected file-management errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileValidationError(FileManagementError):
|
||||||
|
"""Raised when user input is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryNotFoundError(FileManagementError):
|
||||||
|
"""Raised when a requested category does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileRecordNotFoundError(FileManagementError):
|
||||||
|
"""Raised when a requested file record does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicalFileNotFoundError(FileManagementError):
|
||||||
|
"""Raised when metadata exists but its physical file is missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class UploadTooLargeError(FileManagementError):
|
||||||
|
"""Raised when an upload exceeds its configured size limit."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileManagementService:
|
||||||
|
"""Coordinate database transactions and rollback-friendly disk operations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
storage: FileStorage,
|
||||||
|
allowed_extensions: set[str],
|
||||||
|
max_upload_size_mb: int,
|
||||||
|
):
|
||||||
|
self.session = session
|
||||||
|
self.storage = storage
|
||||||
|
self.allowed_extensions = {extension.lower() for extension in allowed_extensions}
|
||||||
|
self.max_upload_size_mb = max_upload_size_mb
|
||||||
|
self.categories = FileCategoryRepository(session)
|
||||||
|
self.files = FileRecordRepository(session)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_upload_bytes(self) -> int:
|
||||||
|
return self.max_upload_size_mb * 1024 * 1024
|
||||||
|
|
||||||
|
def list_categories(self) -> list[FileCategoryNode]:
|
||||||
|
return self.categories.get_tree()
|
||||||
|
|
||||||
|
def create_category(self, name: str, parent_id: str | None = None) -> FileCategoryDB:
|
||||||
|
normalized_name = name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise FileValidationError("分类名称不能为空")
|
||||||
|
if parent_id and not self.categories.get(parent_id):
|
||||||
|
raise FileValidationError("父分类不存在")
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = self.categories.create(normalized_name, parent_id)
|
||||||
|
self.session.commit()
|
||||||
|
return category
|
||||||
|
except Exception:
|
||||||
|
self.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_category(self, category_id: str, name: str) -> FileCategoryDB:
|
||||||
|
normalized_name = name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise FileValidationError("分类名称不能为空")
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = self.categories.update(category_id, normalized_name)
|
||||||
|
if not category:
|
||||||
|
raise CategoryNotFoundError
|
||||||
|
self.session.commit()
|
||||||
|
return category
|
||||||
|
except Exception:
|
||||||
|
self.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_category(self, category_id: str) -> None:
|
||||||
|
if not self.categories.get(category_id):
|
||||||
|
raise CategoryNotFoundError
|
||||||
|
|
||||||
|
category_ids = self.categories.get_subtree_ids(category_id)
|
||||||
|
records = self.files.list_by_category_ids(category_ids)
|
||||||
|
staged = self._stage_records(records)
|
||||||
|
try:
|
||||||
|
self.categories.delete(category_id)
|
||||||
|
self.session.commit()
|
||||||
|
except Exception:
|
||||||
|
self.session.rollback()
|
||||||
|
self.storage.restore_deletions(staged)
|
||||||
|
raise
|
||||||
|
self.storage.purge_deletions(staged)
|
||||||
|
|
||||||
|
def list_files(self, category_id: str | None = None) -> list[FileRecordDB]:
|
||||||
|
if category_id and not self.categories.get(category_id):
|
||||||
|
raise CategoryNotFoundError
|
||||||
|
return self.files.list_all(category_id)
|
||||||
|
|
||||||
|
async def upload(self, upload: UploadFile, category_id: str | None = None) -> FileRecordDB:
|
||||||
|
filename = upload.filename or ""
|
||||||
|
if not filename:
|
||||||
|
raise FileValidationError("文件名为空")
|
||||||
|
if category_id and not self.categories.get(category_id):
|
||||||
|
raise FileValidationError("分类不存在")
|
||||||
|
|
||||||
|
extension = Path(filename).suffix.lstrip(".").lower()
|
||||||
|
if not extension:
|
||||||
|
raise FileValidationError("文件没有扩展名")
|
||||||
|
if extension not in self.allowed_extensions:
|
||||||
|
allowed = ", ".join(sorted(self.allowed_extensions))
|
||||||
|
raise FileValidationError(f"不支持的文件类型 .{extension},允许的类型: {allowed}")
|
||||||
|
|
||||||
|
storage_name = f"{uuid.uuid4()}.{extension}"
|
||||||
|
try:
|
||||||
|
pending = await self.storage.write_upload(upload, storage_name, self.max_upload_bytes)
|
||||||
|
except FileTooLargeError as error:
|
||||||
|
raise UploadTooLargeError from error
|
||||||
|
|
||||||
|
final_path = None
|
||||||
|
try:
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename)
|
||||||
|
record = self.files.create(
|
||||||
|
original_name=filename,
|
||||||
|
storage_name=storage_name,
|
||||||
|
file_size=pending.file_size,
|
||||||
|
mime_type=mime_type or "application/octet-stream",
|
||||||
|
file_ext=extension,
|
||||||
|
category_id=category_id,
|
||||||
|
)
|
||||||
|
final_path = self.storage.promote(pending)
|
||||||
|
self.session.commit()
|
||||||
|
return record
|
||||||
|
except Exception:
|
||||||
|
self.session.rollback()
|
||||||
|
self.storage.discard(pending.temp_path)
|
||||||
|
if final_path:
|
||||||
|
self.storage.discard(final_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_download(self, file_id: str) -> tuple[FileRecordDB, Path]:
|
||||||
|
record = self.files.get(file_id)
|
||||||
|
if not record:
|
||||||
|
raise FileRecordNotFoundError
|
||||||
|
try:
|
||||||
|
file_path = self.storage.path_for(record.storage_name)
|
||||||
|
except InvalidStorageNameError as error:
|
||||||
|
raise PhysicalFileNotFoundError from error
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise PhysicalFileNotFoundError
|
||||||
|
return record, file_path
|
||||||
|
|
||||||
|
def delete_file(self, file_id: str) -> None:
|
||||||
|
record = self.files.get(file_id)
|
||||||
|
if not record:
|
||||||
|
raise FileRecordNotFoundError
|
||||||
|
|
||||||
|
staged = self._stage_records([record])
|
||||||
|
try:
|
||||||
|
self.files.delete(file_id)
|
||||||
|
self.session.commit()
|
||||||
|
except Exception:
|
||||||
|
self.session.rollback()
|
||||||
|
self.storage.restore_deletions(staged)
|
||||||
|
raise
|
||||||
|
self.storage.purge_deletions(staged)
|
||||||
|
|
||||||
|
def _stage_records(self, records: list[FileRecordDB]) -> list[StagedDeletion]:
|
||||||
|
try:
|
||||||
|
return self.storage.stage_deletions([record.storage_name for record in records])
|
||||||
|
except InvalidStorageNameError as error:
|
||||||
|
raise PhysicalFileNotFoundError from error
|
||||||
@ -1,22 +1,25 @@
|
|||||||
"""Repository layer for file management (categories + records)."""
|
"""Database repositories for file categories and uploaded file records."""
|
||||||
|
|
||||||
import os
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from agenteval.storage.db import (
|
from agenteval.storage.db import FileCategoryDB, FileRecordDB, get_session, new_uuid, utc_now
|
||||||
FILES_DIR,
|
|
||||||
FileCategoryDB,
|
|
||||||
FileRecordDB,
|
@dataclass
|
||||||
get_session,
|
class FileCategoryNode:
|
||||||
new_uuid,
|
"""Framework-independent category tree node."""
|
||||||
utc_now,
|
|
||||||
)
|
id: str
|
||||||
|
name: str
|
||||||
|
parent_id: Optional[str]
|
||||||
|
children: list["FileCategoryNode"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FileCategoryRepository:
|
class FileCategoryRepository:
|
||||||
"""Repository for file categories (tree structure)."""
|
"""Persistence operations for the file category tree."""
|
||||||
|
|
||||||
def __init__(self, session: Optional[Session] = None):
|
def __init__(self, session: Optional[Session] = None):
|
||||||
self.session = session or get_session()
|
self.session = session or get_session()
|
||||||
@ -28,87 +31,78 @@ class FileCategoryRepository:
|
|||||||
def get(self, category_id: str) -> Optional[FileCategoryDB]:
|
def get(self, category_id: str) -> Optional[FileCategoryDB]:
|
||||||
return self.session.get(FileCategoryDB, category_id)
|
return self.session.get(FileCategoryDB, category_id)
|
||||||
|
|
||||||
def get_tree(self) -> list[dict]:
|
def get_tree(self) -> list[FileCategoryNode]:
|
||||||
"""Return categories as a nested tree structure for frontend Tree component."""
|
"""Build the complete category tree with one database query."""
|
||||||
all_cats = self.list_all()
|
categories = self.list_all()
|
||||||
cat_map: dict[str, dict] = {}
|
node_map = {
|
||||||
roots: list[dict] = []
|
category.id: FileCategoryNode(
|
||||||
|
id=category.id,
|
||||||
for cat in all_cats:
|
name=category.name,
|
||||||
node = {
|
parent_id=category.parent_id,
|
||||||
"key": cat.id,
|
)
|
||||||
"title": cat.name,
|
for category in categories
|
||||||
"parent_id": cat.parent_id,
|
|
||||||
"children": [],
|
|
||||||
}
|
}
|
||||||
cat_map[cat.id] = node
|
roots: list[FileCategoryNode] = []
|
||||||
|
|
||||||
for cat in all_cats:
|
for category in categories:
|
||||||
node = cat_map[cat.id]
|
node = node_map[category.id]
|
||||||
if cat.parent_id and cat.parent_id in cat_map:
|
parent = node_map.get(category.parent_id) if category.parent_id else None
|
||||||
cat_map[cat.parent_id]["children"].append(node)
|
if parent:
|
||||||
|
parent.children.append(node)
|
||||||
else:
|
else:
|
||||||
roots.append(node)
|
roots.append(node)
|
||||||
|
|
||||||
return roots
|
return roots
|
||||||
|
|
||||||
|
def get_subtree_ids(self, category_id: str) -> list[str]:
|
||||||
|
"""Return the category and all descendant IDs with one database query."""
|
||||||
|
categories = self.list_all()
|
||||||
|
children_by_parent: dict[str, list[str]] = {}
|
||||||
|
for category in categories:
|
||||||
|
if category.parent_id:
|
||||||
|
children_by_parent.setdefault(category.parent_id, []).append(category.id)
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
pending = [category_id]
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
result.append(current)
|
||||||
|
pending.extend(children_by_parent.get(current, []))
|
||||||
|
return result
|
||||||
|
|
||||||
def create(self, name: str, parent_id: Optional[str] = None) -> FileCategoryDB:
|
def create(self, name: str, parent_id: Optional[str] = None) -> FileCategoryDB:
|
||||||
db = FileCategoryDB(
|
category = FileCategoryDB(
|
||||||
id=new_uuid(),
|
id=new_uuid(),
|
||||||
name=name,
|
name=name,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
created_at=utc_now(),
|
created_at=utc_now(),
|
||||||
updated_at=utc_now(),
|
updated_at=utc_now(),
|
||||||
)
|
)
|
||||||
self.session.add(db)
|
self.session.add(category)
|
||||||
self.session.commit()
|
self.session.flush()
|
||||||
self.session.refresh(db)
|
return category
|
||||||
return db
|
|
||||||
|
|
||||||
def update(self, category_id: str, name: str) -> Optional[FileCategoryDB]:
|
def update(self, category_id: str, name: str) -> Optional[FileCategoryDB]:
|
||||||
existing = self.session.get(FileCategoryDB, category_id)
|
category = self.session.get(FileCategoryDB, category_id)
|
||||||
if not existing:
|
if not category:
|
||||||
return None
|
return None
|
||||||
existing.name = name
|
category.name = name
|
||||||
existing.updated_at = utc_now()
|
category.updated_at = utc_now()
|
||||||
self.session.add(existing)
|
self.session.add(category)
|
||||||
self.session.commit()
|
self.session.flush()
|
||||||
self.session.refresh(existing)
|
return category
|
||||||
return existing
|
|
||||||
|
|
||||||
def delete(self, category_id: str) -> bool:
|
def delete(self, category_id: str) -> bool:
|
||||||
"""Delete a category and cascade-delete children + files.
|
category = self.session.get(FileCategoryDB, category_id)
|
||||||
|
if not category:
|
||||||
Physical files are cleaned up via FileRecordRepository.delete().
|
|
||||||
We must explicitly delete files first to trigger physical cleanup,
|
|
||||||
because the DB cascade only removes rows.
|
|
||||||
"""
|
|
||||||
existing = self.session.get(FileCategoryDB, category_id)
|
|
||||||
if not existing:
|
|
||||||
return False
|
return False
|
||||||
|
self.session.delete(category)
|
||||||
# Collect all file IDs to clean up physical files
|
self.session.flush()
|
||||||
file_ids = self._collect_file_ids(existing)
|
|
||||||
|
|
||||||
# Delete physical files
|
|
||||||
file_repo = FileRecordRepository(self.session)
|
|
||||||
for fid in file_ids:
|
|
||||||
file_repo._remove_physical_file(fid)
|
|
||||||
|
|
||||||
self.session.delete(existing)
|
|
||||||
self.session.commit()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _collect_file_ids(self, category: FileCategoryDB) -> list[str]:
|
|
||||||
"""Recursively collect all file IDs under a category and its children."""
|
|
||||||
file_ids = [f.id for f in category.files]
|
|
||||||
for child in category.children:
|
|
||||||
file_ids.extend(self._collect_file_ids(child))
|
|
||||||
return file_ids
|
|
||||||
|
|
||||||
|
|
||||||
class FileRecordRepository:
|
class FileRecordRepository:
|
||||||
"""Repository for uploaded file records."""
|
"""Persistence operations for uploaded file metadata."""
|
||||||
|
|
||||||
def __init__(self, session: Optional[Session] = None):
|
def __init__(self, session: Optional[Session] = None):
|
||||||
self.session = session or get_session()
|
self.session = session or get_session()
|
||||||
@ -116,29 +110,16 @@ class FileRecordRepository:
|
|||||||
def list_all(self, category_id: Optional[str] = None) -> list[FileRecordDB]:
|
def list_all(self, category_id: Optional[str] = None) -> list[FileRecordDB]:
|
||||||
statement = select(FileRecordDB)
|
statement = select(FileRecordDB)
|
||||||
if category_id:
|
if category_id:
|
||||||
# Also include files in subcategories
|
category_ids = FileCategoryRepository(self.session).get_subtree_ids(category_id)
|
||||||
cat_repo = FileCategoryRepository(self.session)
|
statement = statement.where(col(FileRecordDB.category_id).in_(category_ids))
|
||||||
cat_ids = self._get_descendant_ids(category_id, cat_repo)
|
|
||||||
cat_ids.append(category_id)
|
|
||||||
from sqlmodel import col
|
|
||||||
|
|
||||||
statement = statement.where(col(FileRecordDB.category_id).in_(cat_ids))
|
|
||||||
else:
|
|
||||||
# Only filter when category_id is explicitly provided;
|
|
||||||
# None means "all files" (no filter).
|
|
||||||
pass
|
|
||||||
statement = statement.order_by(FileRecordDB.created_at.desc())
|
statement = statement.order_by(FileRecordDB.created_at.desc())
|
||||||
return list(self.session.exec(statement).all())
|
return list(self.session.exec(statement).all())
|
||||||
|
|
||||||
def _get_descendant_ids(self, parent_id: str, cat_repo: FileCategoryRepository) -> list[str]:
|
def list_by_category_ids(self, category_ids: list[str]) -> list[FileRecordDB]:
|
||||||
"""Recursively collect IDs of all descendant categories."""
|
if not category_ids:
|
||||||
ids: list[str] = []
|
return []
|
||||||
all_cats = cat_repo.list_all()
|
statement = select(FileRecordDB).where(col(FileRecordDB.category_id).in_(category_ids))
|
||||||
children = [c for c in all_cats if c.parent_id == parent_id]
|
return list(self.session.exec(statement).all())
|
||||||
for child in children:
|
|
||||||
ids.append(child.id)
|
|
||||||
ids.extend(self._get_descendant_ids(child.id, cat_repo))
|
|
||||||
return ids
|
|
||||||
|
|
||||||
def get(self, file_id: str) -> Optional[FileRecordDB]:
|
def get(self, file_id: str) -> Optional[FileRecordDB]:
|
||||||
return self.session.get(FileRecordDB, file_id)
|
return self.session.get(FileRecordDB, file_id)
|
||||||
@ -152,7 +133,7 @@ class FileRecordRepository:
|
|||||||
file_ext: str,
|
file_ext: str,
|
||||||
category_id: Optional[str] = None,
|
category_id: Optional[str] = None,
|
||||||
) -> FileRecordDB:
|
) -> FileRecordDB:
|
||||||
db = FileRecordDB(
|
record = FileRecordDB(
|
||||||
id=new_uuid(),
|
id=new_uuid(),
|
||||||
original_name=original_name,
|
original_name=original_name,
|
||||||
storage_name=storage_name,
|
storage_name=storage_name,
|
||||||
@ -162,25 +143,14 @@ class FileRecordRepository:
|
|||||||
file_ext=file_ext,
|
file_ext=file_ext,
|
||||||
created_at=utc_now(),
|
created_at=utc_now(),
|
||||||
)
|
)
|
||||||
self.session.add(db)
|
self.session.add(record)
|
||||||
self.session.commit()
|
self.session.flush()
|
||||||
self.session.refresh(db)
|
return record
|
||||||
return db
|
|
||||||
|
|
||||||
def delete(self, file_id: str) -> bool:
|
def delete(self, file_id: str) -> bool:
|
||||||
record = self.session.get(FileRecordDB, file_id)
|
record = self.session.get(FileRecordDB, file_id)
|
||||||
if not record:
|
if not record:
|
||||||
return False
|
return False
|
||||||
self._remove_physical_file(file_id)
|
|
||||||
self.session.delete(record)
|
self.session.delete(record)
|
||||||
self.session.commit()
|
self.session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _remove_physical_file(self, file_id: str) -> None:
|
|
||||||
"""Remove the physical file from disk if it exists."""
|
|
||||||
record = self.session.get(FileRecordDB, file_id)
|
|
||||||
if not record:
|
|
||||||
return
|
|
||||||
file_path = FILES_DIR / record.storage_name
|
|
||||||
if file_path.exists():
|
|
||||||
os.remove(file_path)
|
|
||||||
|
|||||||
94
backend/agenteval/storage/file_storage.py
Normal file
94
backend/agenteval/storage/file_storage.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Physical storage operations for uploaded files."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class FileTooLargeError(Exception):
|
||||||
|
"""Raised when a streamed upload exceeds its configured limit."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidStorageNameError(Exception):
|
||||||
|
"""Raised when a storage name could escape the configured root."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PendingUpload:
|
||||||
|
temp_path: Path
|
||||||
|
storage_name: str
|
||||||
|
file_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StagedDeletion:
|
||||||
|
original_path: Path
|
||||||
|
staged_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class FileStorage:
|
||||||
|
"""Store files below one root and provide rollback-friendly operations."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def path_for(self, storage_name: str) -> Path:
|
||||||
|
if not storage_name or Path(storage_name).name != storage_name:
|
||||||
|
raise InvalidStorageNameError(storage_name)
|
||||||
|
return self.root / storage_name
|
||||||
|
|
||||||
|
async def write_upload(self, upload: UploadFile, storage_name: str, max_bytes: int) -> PendingUpload:
|
||||||
|
self.path_for(storage_name)
|
||||||
|
temp_path = self.root / f".upload-{uuid.uuid4()}.tmp"
|
||||||
|
file_size = 0
|
||||||
|
try:
|
||||||
|
with temp_path.open("xb") as output:
|
||||||
|
while chunk := await upload.read(UPLOAD_CHUNK_SIZE):
|
||||||
|
file_size += len(chunk)
|
||||||
|
if file_size > max_bytes:
|
||||||
|
raise FileTooLargeError
|
||||||
|
output.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await upload.close()
|
||||||
|
return PendingUpload(temp_path=temp_path, storage_name=storage_name, file_size=file_size)
|
||||||
|
|
||||||
|
def promote(self, pending: PendingUpload) -> Path:
|
||||||
|
final_path = self.path_for(pending.storage_name)
|
||||||
|
os.replace(pending.temp_path, final_path)
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
def discard(self, path: Path) -> None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def stage_deletions(self, storage_names: list[str]) -> list[StagedDeletion]:
|
||||||
|
staged: list[StagedDeletion] = []
|
||||||
|
try:
|
||||||
|
for storage_name in storage_names:
|
||||||
|
original_path = self.path_for(storage_name)
|
||||||
|
if not original_path.exists():
|
||||||
|
continue
|
||||||
|
staged_path = self.root / f".delete-{uuid.uuid4()}.tmp"
|
||||||
|
os.replace(original_path, staged_path)
|
||||||
|
staged.append(StagedDeletion(original_path=original_path, staged_path=staged_path))
|
||||||
|
except Exception:
|
||||||
|
self.restore_deletions(staged)
|
||||||
|
raise
|
||||||
|
return staged
|
||||||
|
|
||||||
|
def restore_deletions(self, staged: list[StagedDeletion]) -> None:
|
||||||
|
for item in reversed(staged):
|
||||||
|
if item.staged_path.exists():
|
||||||
|
os.replace(item.staged_path, item.original_path)
|
||||||
|
|
||||||
|
def purge_deletions(self, staged: list[StagedDeletion]) -> None:
|
||||||
|
for item in staged:
|
||||||
|
item.staged_path.unlink(missing_ok=True)
|
||||||
69
backend/agenteval/web/file_schemas.py
Normal file
69
backend/agenteval/web/file_schemas.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""HTTP schemas for the file management API."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
||||||
|
|
||||||
|
from agenteval.storage.db import FileCategoryDB, FileRecordDB, iso_utc
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileCategoryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
parent_id: str | None
|
||||||
|
children: list["FileCategoryResponse"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, category: FileCategoryDB) -> "FileCategoryResponse":
|
||||||
|
return cls(
|
||||||
|
id=category.id or "",
|
||||||
|
name=category.name,
|
||||||
|
parent_id=category.parent_id,
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileRecordResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
original_name: str
|
||||||
|
file_size: int
|
||||||
|
mime_type: str
|
||||||
|
file_ext: str
|
||||||
|
category_id: str | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
@field_serializer("created_at")
|
||||||
|
def serialize_created_at(self, value: datetime | None) -> str | None:
|
||||||
|
return iso_utc(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, record: FileRecordDB) -> "FileRecordResponse":
|
||||||
|
return cls(
|
||||||
|
id=record.id or "",
|
||||||
|
original_name=record.original_name,
|
||||||
|
file_size=record.file_size,
|
||||||
|
mime_type=record.mime_type,
|
||||||
|
file_ext=record.file_ext,
|
||||||
|
category_id=record.category_id,
|
||||||
|
created_at=record.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadConfigResponse(BaseModel):
|
||||||
|
allowed_extensions: list[str]
|
||||||
|
max_upload_size_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
class OkResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
@ -1,203 +1,148 @@
|
|||||||
"""API routes for file management (categories + file upload/download)."""
|
"""API routes for file management."""
|
||||||
|
|
||||||
import mimetypes
|
from typing import NoReturn
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.config import get_settings
|
from agenteval.config import get_settings
|
||||||
from agenteval.storage.db import FILES_DIR, iso_utc
|
from agenteval.services.files import (
|
||||||
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
|
CategoryNotFoundError,
|
||||||
|
FileManagementError,
|
||||||
|
FileManagementService,
|
||||||
|
FileRecordNotFoundError,
|
||||||
|
FileValidationError,
|
||||||
|
PhysicalFileNotFoundError,
|
||||||
|
UploadTooLargeError,
|
||||||
|
)
|
||||||
|
from agenteval.storage.db import FILES_DIR
|
||||||
|
from agenteval.storage.file_storage import FileStorage
|
||||||
from agenteval.web.deps import get_db
|
from agenteval.web.deps import get_db
|
||||||
|
from agenteval.web.file_schemas import (
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
FileCategoryResponse,
|
||||||
|
FileRecordResponse,
|
||||||
|
FileUploadConfigResponse,
|
||||||
|
OkResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ── helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
def _allowed_extensions() -> set[str]:
|
||||||
def _get_allowed_extensions() -> set[str]:
|
|
||||||
"""Return the set of allowed lowercase extensions from settings."""
|
|
||||||
raw = get_settings().allowed_extensions
|
raw = get_settings().allowed_extensions
|
||||||
return {ext.strip().lower() for ext in raw.split(",") if ext.strip()}
|
return {extension.strip().lower() for extension in raw.split(",") if extension.strip()}
|
||||||
|
|
||||||
|
|
||||||
def _get_max_bytes() -> int:
|
def get_file_service(session: Session = Depends(get_db)) -> FileManagementService:
|
||||||
return get_settings().max_upload_size_mb * 1024 * 1024
|
settings = get_settings()
|
||||||
|
return FileManagementService(
|
||||||
|
session=session,
|
||||||
def _validate_extension(filename: str) -> str:
|
storage=FileStorage(FILES_DIR),
|
||||||
"""Validate the file extension and return the lowercase extension."""
|
allowed_extensions=_allowed_extensions(),
|
||||||
ext = Path(filename).suffix.lstrip(".").lower()
|
max_upload_size_mb=settings.max_upload_size_mb,
|
||||||
if not ext:
|
|
||||||
raise HTTPException(status_code=400, detail="文件没有扩展名")
|
|
||||||
allowed = _get_allowed_extensions()
|
|
||||||
if ext not in allowed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"不支持的文件类型 .{ext},允许的类型: {', '.join(sorted(allowed))}",
|
|
||||||
)
|
)
|
||||||
return ext
|
|
||||||
|
|
||||||
|
|
||||||
# ── API models ─────────────────────────────────────────────────────────
|
def _raise_http_error(error: FileManagementError) -> NoReturn:
|
||||||
|
if isinstance(error, UploadTooLargeError):
|
||||||
|
|
||||||
class CategoryCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
parent_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
# ── Category endpoints ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories")
|
|
||||||
def list_categories(session: Session = Depends(get_db)) -> list[dict]:
|
|
||||||
"""List categories as a nested tree."""
|
|
||||||
return FileCategoryRepository(session).get_tree()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/categories")
|
|
||||||
def create_category(body: CategoryCreate, session: Session = Depends(get_db)) -> dict:
|
|
||||||
if not body.name.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="分类名称不能为空")
|
|
||||||
cat = FileCategoryRepository(session).create(
|
|
||||||
name=body.name.strip(),
|
|
||||||
parent_id=body.parent_id,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"key": cat.id,
|
|
||||||
"title": cat.name,
|
|
||||||
"parent_id": cat.parent_id,
|
|
||||||
"children": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/categories/{category_id}")
|
|
||||||
def update_category(category_id: str, body: CategoryUpdate, session: Session = Depends(get_db)) -> dict:
|
|
||||||
if not body.name.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="分类名称不能为空")
|
|
||||||
cat = FileCategoryRepository(session).update(category_id, body.name.strip())
|
|
||||||
if not cat:
|
|
||||||
raise HTTPException(status_code=404, detail="分类不存在")
|
|
||||||
return {"ok": True, "name": cat.name}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/categories/{category_id}")
|
|
||||||
def delete_category(category_id: str, session: Session = Depends(get_db)) -> dict:
|
|
||||||
if not FileCategoryRepository(session).delete(category_id):
|
|
||||||
raise HTTPException(status_code=404, detail="分类不存在")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ── File endpoints ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_files(category_id: str | None = None, session: Session = Depends(get_db)) -> list[dict]:
|
|
||||||
"""List file records, optionally filtered by category."""
|
|
||||||
records = FileRecordRepository(session).list_all(category_id=category_id)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"original_name": r.original_name,
|
|
||||||
"file_size": r.file_size,
|
|
||||||
"mime_type": r.mime_type,
|
|
||||||
"file_ext": r.file_ext,
|
|
||||||
"category_id": r.category_id,
|
|
||||||
"created_at": iso_utc(r.created_at),
|
|
||||||
}
|
|
||||||
for r in records
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
|
||||||
async def upload_file(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
category_id: str | None = Form(default=None),
|
|
||||||
session: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Upload a single file."""
|
|
||||||
if not file.filename:
|
|
||||||
raise HTTPException(status_code=400, detail="文件名为空")
|
|
||||||
|
|
||||||
ext = _validate_extension(file.filename)
|
|
||||||
|
|
||||||
# Read content and validate size
|
|
||||||
content = await file.read()
|
|
||||||
max_bytes = _get_max_bytes()
|
|
||||||
if len(content) > max_bytes:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
detail=f"文件大小超过限制 ({get_settings().max_upload_size_mb}MB)",
|
detail=f"文件大小超过限制 ({get_settings().max_upload_size_mb}MB)",
|
||||||
|
) from error
|
||||||
|
if isinstance(error, FileValidationError):
|
||||||
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||||
|
if isinstance(error, CategoryNotFoundError):
|
||||||
|
raise HTTPException(status_code=404, detail="分类不存在") from error
|
||||||
|
if isinstance(error, FileRecordNotFoundError):
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在") from error
|
||||||
|
if isinstance(error, PhysicalFileNotFoundError):
|
||||||
|
raise HTTPException(status_code=404, detail="物理文件不存在") from error
|
||||||
|
raise HTTPException(status_code=500, detail="文件操作失败") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=FileUploadConfigResponse)
|
||||||
|
def get_upload_config() -> FileUploadConfigResponse:
|
||||||
|
return FileUploadConfigResponse(
|
||||||
|
allowed_extensions=sorted(_allowed_extensions()),
|
||||||
|
max_upload_size_mb=get_settings().max_upload_size_mb,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate storage name
|
|
||||||
storage_name = f"{uuid.uuid4()}.{ext}"
|
|
||||||
|
|
||||||
# Guess MIME type
|
@router.get("/categories", response_model=list[FileCategoryResponse])
|
||||||
mime_type, _ = mimetypes.guess_type(file.filename)
|
def list_categories(service: FileManagementService = Depends(get_file_service)):
|
||||||
if not mime_type:
|
return service.list_categories()
|
||||||
mime_type = "application/octet-stream"
|
|
||||||
|
|
||||||
# Validate category exists if provided
|
|
||||||
if category_id:
|
|
||||||
cat = FileCategoryRepository(session).get(category_id)
|
|
||||||
if not cat:
|
|
||||||
raise HTTPException(status_code=400, detail="分类不存在")
|
|
||||||
|
|
||||||
# Write physical file
|
@router.post("/categories", response_model=FileCategoryResponse)
|
||||||
file_path = FILES_DIR / storage_name
|
def create_category(body: CategoryCreate, service: FileManagementService = Depends(get_file_service)):
|
||||||
file_path.write_bytes(content)
|
try:
|
||||||
|
return FileCategoryResponse.from_record(service.create_category(body.name, body.parent_id))
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
# Create DB record
|
|
||||||
record = FileRecordRepository(session).create(
|
|
||||||
original_name=file.filename,
|
|
||||||
storage_name=storage_name,
|
|
||||||
file_size=len(content),
|
|
||||||
mime_type=mime_type,
|
|
||||||
file_ext=ext,
|
|
||||||
category_id=category_id if category_id else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
@router.put("/categories/{category_id}", response_model=FileCategoryResponse)
|
||||||
"id": record.id,
|
def update_category(
|
||||||
"original_name": record.original_name,
|
category_id: str,
|
||||||
"file_size": record.file_size,
|
body: CategoryUpdate,
|
||||||
"mime_type": record.mime_type,
|
service: FileManagementService = Depends(get_file_service),
|
||||||
"file_ext": record.file_ext,
|
):
|
||||||
"category_id": record.category_id,
|
try:
|
||||||
"created_at": iso_utc(record.created_at),
|
return FileCategoryResponse.from_record(service.update_category(category_id, body.name))
|
||||||
}
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{category_id}", response_model=OkResponse)
|
||||||
|
def delete_category(category_id: str, service: FileManagementService = Depends(get_file_service)):
|
||||||
|
try:
|
||||||
|
service.delete_category(category_id)
|
||||||
|
return OkResponse()
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[FileRecordResponse])
|
||||||
|
def list_files(category_id: str | None = None, service: FileManagementService = Depends(get_file_service)):
|
||||||
|
try:
|
||||||
|
return [FileRecordResponse.from_record(record) for record in service.list_files(category_id)]
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FileRecordResponse)
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
category_id: str | None = Form(default=None),
|
||||||
|
service: FileManagementService = Depends(get_file_service),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record = await service.upload(file, category_id)
|
||||||
|
return FileRecordResponse.from_record(record)
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{file_id}/download")
|
@router.get("/{file_id}/download")
|
||||||
def download_file(file_id: str, session: Session = Depends(get_db)):
|
def download_file(file_id: str, service: FileManagementService = Depends(get_file_service)):
|
||||||
"""Download a file by its record ID."""
|
try:
|
||||||
record = FileRecordRepository(session).get(file_id)
|
record, file_path = service.get_download(file_id)
|
||||||
if not record:
|
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
|
||||||
|
|
||||||
file_path = FILES_DIR / record.storage_name
|
|
||||||
if not file_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="物理文件不存在")
|
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
filename=record.original_name,
|
filename=record.original_name,
|
||||||
media_type=record.mime_type or "application/octet-stream",
|
media_type=record.mime_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{file_id}")
|
@router.delete("/{file_id}", response_model=OkResponse)
|
||||||
def delete_file(file_id: str, session: Session = Depends(get_db)) -> dict:
|
def delete_file(file_id: str, service: FileManagementService = Depends(get_file_service)):
|
||||||
if not FileRecordRepository(session).delete(file_id):
|
try:
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
service.delete_file(file_id)
|
||||||
return {"ok": True}
|
return OkResponse()
|
||||||
|
except FileManagementError as error:
|
||||||
|
_raise_http_error(error)
|
||||||
|
|||||||
@ -153,8 +153,8 @@ export const statsApi = {
|
|||||||
// ── File Management ──────────────────────────────────────────────
|
// ── File Management ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FileCategory {
|
export interface FileCategory {
|
||||||
key: string
|
id: string
|
||||||
title: string
|
name: string
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
children: FileCategory[]
|
children: FileCategory[]
|
||||||
}
|
}
|
||||||
@ -169,8 +169,14 @@ export interface FileRecord {
|
|||||||
created_at: string | null
|
created_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileUploadConfig {
|
||||||
|
allowed_extensions: string[]
|
||||||
|
max_upload_size_mb: number
|
||||||
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
// Categories
|
// Categories
|
||||||
|
getConfig: () => api.get<FileUploadConfig>('/files/config'),
|
||||||
listCategories: () => api.get<FileCategory[]>('/files/categories'),
|
listCategories: () => api.get<FileCategory[]>('/files/categories'),
|
||||||
createCategory: (data: { name: string; parent_id?: string | null }) =>
|
createCategory: (data: { name: string; parent_id?: string | null }) =>
|
||||||
api.post<FileCategory>('/files/categories', data),
|
api.post<FileCategory>('/files/categories', data),
|
||||||
@ -191,6 +197,6 @@ export const filesApi = {
|
|||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
downloadUrl: (id: string) => `/api/files/${id}/download`,
|
download: (id: string) => api.get<Blob>(`/files/${id}/download`, { responseType: 'blob' }),
|
||||||
delete: (id: string) => api.delete(`/files/${id}`),
|
delete: (id: string) => api.delete(`/files/${id}`),
|
||||||
}
|
}
|
||||||
|
|||||||
127
frontend/web/src/components/files/FileCategoryTree.tsx
Normal file
127
frontend/web/src/components/files/FileCategoryTree.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
FolderAddOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Button, Popconfirm, Tooltip, Tree } from 'antd'
|
||||||
|
import type { DataNode } from 'antd/es/tree'
|
||||||
|
import type { FileCategory } from '../../api'
|
||||||
|
import { colors, fontSizes } from '../../tokens'
|
||||||
|
|
||||||
|
const ALL_FILES_KEY = '__all__'
|
||||||
|
|
||||||
|
interface FileCategoryTreeProps {
|
||||||
|
categories: FileCategory[]
|
||||||
|
selectedCategoryId: string | null
|
||||||
|
onSelect: (categoryId: string | null) => void
|
||||||
|
onCreate: (parentId?: string) => void
|
||||||
|
onRename: (category: FileCategory) => void
|
||||||
|
onDelete: (categoryId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileCategoryTree({
|
||||||
|
categories,
|
||||||
|
selectedCategoryId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: FileCategoryTreeProps) {
|
||||||
|
const mapCategory = (category: FileCategory): DataNode => ({
|
||||||
|
key: category.id,
|
||||||
|
icon: <FolderOutlined style={{ color: '#d48806' }} />,
|
||||||
|
title: (
|
||||||
|
<div className="cat-tree-node">
|
||||||
|
<span className="cat-tree-label">{category.name}</span>
|
||||||
|
<span className="cat-actions">
|
||||||
|
<Tooltip title="新建子分类">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
aria-label={`在${category.name}下新建分类`}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onCreate(category.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重命名">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
aria-label={`重命名${category.name}`}
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onRename(category)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="删除此分类及其所有子分类和文件?"
|
||||||
|
onConfirm={() => onDelete(category.id)}
|
||||||
|
>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
aria-label={`删除${category.name}`}
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: category.children.map(mapCategory),
|
||||||
|
})
|
||||||
|
|
||||||
|
const treeData: DataNode[] = [{
|
||||||
|
key: ALL_FILES_KEY,
|
||||||
|
icon: <FileOutlined />,
|
||||||
|
title: (
|
||||||
|
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}>
|
||||||
|
<FolderOutlined style={{ marginRight: 6 }} />
|
||||||
|
全部文件
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: categories.map(mapCategory),
|
||||||
|
}]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="files-category-panel">
|
||||||
|
<div className="files-panel-header">
|
||||||
|
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
|
||||||
|
文件分类
|
||||||
|
</span>
|
||||||
|
<Tooltip title="新建根分类">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
aria-label="新建根分类"
|
||||||
|
icon={<FolderAddOutlined />}
|
||||||
|
onClick={() => onCreate()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="files-category-scroll">
|
||||||
|
<Tree
|
||||||
|
showIcon
|
||||||
|
defaultExpandAll
|
||||||
|
treeData={treeData}
|
||||||
|
selectedKeys={[selectedCategoryId ?? ALL_FILES_KEY]}
|
||||||
|
onSelect={(keys) => {
|
||||||
|
const key = keys[0]?.toString()
|
||||||
|
onSelect(!key || key === ALL_FILES_KEY ? null : key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
frontend/web/src/components/files/FileTable.tsx
Normal file
182
frontend/web/src/components/files/FileTable.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Button, Popconfirm, Space, Table, Tag, Tooltip } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { FileRecord } from '../../api'
|
||||||
|
import { colors, fontSizes } from '../../tokens'
|
||||||
|
import { formatDateTime } from '../../utils/date'
|
||||||
|
import { extensionColor, formatFileSize } from '../../utils/fileFormat'
|
||||||
|
|
||||||
|
interface FileTableProps {
|
||||||
|
files: FileRecord[]
|
||||||
|
loading: boolean
|
||||||
|
categoryName: string
|
||||||
|
onRefresh: () => void
|
||||||
|
onOpenUpload: () => void
|
||||||
|
onDownload: (record: FileRecord) => Promise<void>
|
||||||
|
onDelete: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileTable({
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
categoryName,
|
||||||
|
onRefresh,
|
||||||
|
onOpenUpload,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
}: FileTableProps) {
|
||||||
|
const totalSize = useMemo(() => files.reduce((sum, file) => sum + file.file_size, 0), [files])
|
||||||
|
const extensionCounts = useMemo(() => files.reduce<Record<string, number>>((counts, file) => {
|
||||||
|
counts[file.file_ext] = (counts[file.file_ext] || 0) + 1
|
||||||
|
return counts
|
||||||
|
}, {}), [files])
|
||||||
|
|
||||||
|
const columns: ColumnsType<FileRecord> = [
|
||||||
|
{
|
||||||
|
title: '文件名',
|
||||||
|
dataIndex: 'original_name',
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (name: string, record) => (
|
||||||
|
<Space size={8} style={{ maxWidth: '100%' }}>
|
||||||
|
<FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} />
|
||||||
|
<span className="files-name-cell">{name}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '大小',
|
||||||
|
dataIndex: 'file_size',
|
||||||
|
key: 'size',
|
||||||
|
width: 100,
|
||||||
|
responsive: ['sm'],
|
||||||
|
render: (size: number) => (
|
||||||
|
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
|
||||||
|
{formatFileSize(size)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'file_ext',
|
||||||
|
key: 'type',
|
||||||
|
width: 90,
|
||||||
|
render: (extension: string) => (
|
||||||
|
<Tag color={extensionColor(extension)} style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>
|
||||||
|
{extension.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上传时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
responsive: ['md'],
|
||||||
|
render: (time: string | null) => (
|
||||||
|
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
|
||||||
|
{time ? formatDateTime(time) : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Tooltip title="下载">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
aria-label={`下载${record.original_name}`}
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => void onDownload(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm title="确认删除此文件?" onConfirm={() => onDelete(record.id)}>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
aria-label={`删除${record.original_name}`}
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const topExtensions = Object.entries(extensionCounts)
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="files-list-panel">
|
||||||
|
<div className="files-list-header">
|
||||||
|
<div className="files-current-category">
|
||||||
|
<FolderOutlined style={{ color: colors.primary, fontSize: 18 }} />
|
||||||
|
<span>{categoryName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="files-stat">{files.length} 个文件</span>
|
||||||
|
<span className="files-stat">{formatFileSize(totalSize)}</span>
|
||||||
|
<Space size={4} wrap className="files-extension-stats">
|
||||||
|
{topExtensions.map(([extension, count]) => (
|
||||||
|
<Tag key={extension} color={extensionColor(extension)} style={{ margin: 0, fontSize: 11 }}>
|
||||||
|
{extension.toUpperCase()} {count}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{Object.keys(extensionCounts).length > 5 && (
|
||||||
|
<span style={{ fontSize: 11, color: colors.textMuted }}>
|
||||||
|
+{Object.keys(extensionCounts).length - 5} 种
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<div className="files-list-actions">
|
||||||
|
<Tooltip title="刷新文件列表">
|
||||||
|
<Button icon={<ReloadOutlined />} aria-label="刷新文件列表" onClick={onRefresh} />
|
||||||
|
</Tooltip>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />} onClick={onOpenUpload}>
|
||||||
|
上传文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="files-table-scroll">
|
||||||
|
<Table<FileRecord>
|
||||||
|
dataSource={files}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 620 }}
|
||||||
|
pagination={files.length > 10 ? {
|
||||||
|
pageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['15', '30', '50'],
|
||||||
|
showTotal: (total) => `共 ${total} 个文件`,
|
||||||
|
} : false}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="files-empty">
|
||||||
|
<InboxOutlined />
|
||||||
|
<span>暂无文件</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
frontend/web/src/components/files/FileUploadModal.tsx
Normal file
121
frontend/web/src/components/files/FileUploadModal.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { InboxOutlined } from '@ant-design/icons'
|
||||||
|
import { message, Modal, Select, Upload } from 'antd'
|
||||||
|
import type { UploadFile, UploadProps } from 'antd'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { FileCategory, FileUploadConfig } from '../../api'
|
||||||
|
import { flattenCategoryOptions } from '../../utils/fileTree'
|
||||||
|
|
||||||
|
const { Dragger } = Upload
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
succeeded: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadModalProps {
|
||||||
|
open: boolean
|
||||||
|
categories: FileCategory[]
|
||||||
|
config: FileUploadConfig
|
||||||
|
initialCategoryId: string | null
|
||||||
|
onClose: () => void
|
||||||
|
onUpload: (files: File[], categoryId: string | null) => Promise<UploadResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileUploadModal({
|
||||||
|
open,
|
||||||
|
categories,
|
||||||
|
config,
|
||||||
|
initialCategoryId,
|
||||||
|
onClose,
|
||||||
|
onUpload,
|
||||||
|
}: FileUploadModalProps) {
|
||||||
|
const [targetCategoryId, setTargetCategoryId] = useState<string | undefined>()
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTargetCategoryId(initialCategoryId ?? undefined)
|
||||||
|
setFileList([])
|
||||||
|
}
|
||||||
|
}, [initialCategoryId, open])
|
||||||
|
|
||||||
|
const options = useMemo(() => flattenCategoryOptions(categories), [categories])
|
||||||
|
const accept = config.allowed_extensions.map((extension) => `.${extension}`).join(',')
|
||||||
|
|
||||||
|
const uploadProps: UploadProps = {
|
||||||
|
multiple: true,
|
||||||
|
accept: accept || undefined,
|
||||||
|
fileList,
|
||||||
|
customRequest: () => undefined,
|
||||||
|
beforeUpload: (file) => {
|
||||||
|
const maxBytes = config.max_upload_size_mb * 1024 * 1024
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
message.error(`${file.name} 超过 ${config.max_upload_size_mb}MB 限制`)
|
||||||
|
return Upload.LIST_IGNORE
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
onChange: ({ fileList: nextFileList }) => setFileList(nextFileList),
|
||||||
|
onRemove: (file) => {
|
||||||
|
setFileList((current) => current.filter((item) => item.uid !== file.uid))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const files = fileList.flatMap((item) => item.originFileObj ? [item.originFileObj] : [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const result = await onUpload(files, targetCategoryId ?? null)
|
||||||
|
if (result.succeeded > 0 && result.failed === 0) {
|
||||||
|
message.success(`成功上传 ${result.succeeded} 个文件`)
|
||||||
|
} else if (result.succeeded > 0) {
|
||||||
|
message.warning(`成功 ${result.succeeded} 个,失败 ${result.failed} 个`)
|
||||||
|
} else {
|
||||||
|
message.error('上传失败,请检查文件格式和大小')
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = config.allowed_extensions.length > 0
|
||||||
|
? config.allowed_extensions.map((extension) => `.${extension}`).join(' ')
|
||||||
|
: '正在读取支持格式'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="上传文件"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void handleUpload()}
|
||||||
|
okText="开始上传"
|
||||||
|
confirmLoading={uploading}
|
||||||
|
okButtonProps={{ disabled: fileList.length === 0 }}
|
||||||
|
width={520}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<div className="files-upload-category">
|
||||||
|
<span>目标分类:</span>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="选择分类(可选)"
|
||||||
|
value={targetCategoryId}
|
||||||
|
onChange={setTargetCategoryId}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Dragger {...uploadProps}>
|
||||||
|
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
{formats},单文件最大 {config.max_upload_size_mb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
frontend/web/src/hooks/useFiles.ts
Normal file
136
frontend/web/src/hooks/useFiles.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
filesApi,
|
||||||
|
type FileCategory,
|
||||||
|
type FileRecord,
|
||||||
|
type FileUploadConfig,
|
||||||
|
} from '../api'
|
||||||
|
import { categoryContains, findCategory } from '../utils/fileTree'
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: FileUploadConfig = {
|
||||||
|
allowed_extensions: [],
|
||||||
|
max_upload_size_mb: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFiles() {
|
||||||
|
const [categories, setCategories] = useState<FileCategory[]>([])
|
||||||
|
const [files, setFiles] = useState<FileRecord[]>([])
|
||||||
|
const [config, setConfig] = useState<FileUploadConfig>(DEFAULT_CONFIG)
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const fileRequestId = useRef(0)
|
||||||
|
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
const response = await filesApi.listCategories()
|
||||||
|
setCategories(response.data)
|
||||||
|
return response.data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async (categoryId: string | null = null) => {
|
||||||
|
const requestId = ++fileRequestId.current
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await filesApi.list(categoryId)
|
||||||
|
if (requestId === fileRequestId.current) setFiles(response.data)
|
||||||
|
} finally {
|
||||||
|
if (requestId === fileRequestId.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
const response = await filesApi.getConfig()
|
||||||
|
setConfig(response.data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.allSettled([loadCategories(), loadFiles(), loadConfig()])
|
||||||
|
}, [loadCategories, loadConfig, loadFiles])
|
||||||
|
|
||||||
|
const selectCategory = useCallback((categoryId: string | null) => {
|
||||||
|
setSelectedCategoryId(categoryId)
|
||||||
|
void loadFiles(categoryId)
|
||||||
|
}, [loadFiles])
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadCategories(), loadFiles(selectedCategoryId), loadConfig()])
|
||||||
|
}, [loadCategories, loadConfig, loadFiles, selectedCategoryId])
|
||||||
|
|
||||||
|
const createCategory = useCallback(async (name: string, parentId?: string) => {
|
||||||
|
await filesApi.createCategory({ name, parent_id: parentId })
|
||||||
|
await loadCategories()
|
||||||
|
}, [loadCategories])
|
||||||
|
|
||||||
|
const renameCategory = useCallback(async (id: string, name: string) => {
|
||||||
|
await filesApi.updateCategory(id, { name })
|
||||||
|
await loadCategories()
|
||||||
|
}, [loadCategories])
|
||||||
|
|
||||||
|
const deleteCategory = useCallback(async (id: string) => {
|
||||||
|
const deletedRoot = findCategory(categories, id)
|
||||||
|
const resetsSelection = Boolean(
|
||||||
|
selectedCategoryId && deletedRoot && categoryContains(deletedRoot, selectedCategoryId),
|
||||||
|
)
|
||||||
|
const nextCategoryId = resetsSelection ? null : selectedCategoryId
|
||||||
|
|
||||||
|
await filesApi.deleteCategory(id)
|
||||||
|
setSelectedCategoryId(nextCategoryId)
|
||||||
|
await Promise.all([loadCategories(), loadFiles(nextCategoryId)])
|
||||||
|
}, [categories, loadCategories, loadFiles, selectedCategoryId])
|
||||||
|
|
||||||
|
const uploadFiles = useCallback(async (items: File[], categoryId: string | null) => {
|
||||||
|
let succeeded = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const file of items) {
|
||||||
|
try {
|
||||||
|
await filesApi.upload(file, categoryId)
|
||||||
|
succeeded += 1
|
||||||
|
} catch {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (succeeded > 0) {
|
||||||
|
setSelectedCategoryId(categoryId)
|
||||||
|
await loadFiles(categoryId)
|
||||||
|
}
|
||||||
|
return { succeeded, failed }
|
||||||
|
}, [loadFiles])
|
||||||
|
|
||||||
|
const deleteFile = useCallback(async (id: string) => {
|
||||||
|
await filesApi.delete(id)
|
||||||
|
await loadFiles(selectedCategoryId)
|
||||||
|
}, [loadFiles, selectedCategoryId])
|
||||||
|
|
||||||
|
const downloadFile = useCallback(async (record: FileRecord) => {
|
||||||
|
const response = await filesApi.download(record.id)
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = record.original_name
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedCategoryName = useMemo(() => {
|
||||||
|
if (!selectedCategoryId) return '全部文件'
|
||||||
|
return findCategory(categories, selectedCategoryId)?.name ?? '全部文件'
|
||||||
|
}, [categories, selectedCategoryId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
files,
|
||||||
|
config,
|
||||||
|
selectedCategoryId,
|
||||||
|
selectedCategoryName,
|
||||||
|
loading,
|
||||||
|
selectCategory,
|
||||||
|
refresh,
|
||||||
|
createCategory,
|
||||||
|
renameCategory,
|
||||||
|
deleteCategory,
|
||||||
|
uploadFiles,
|
||||||
|
deleteFile,
|
||||||
|
downloadFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -102,7 +102,194 @@ body {
|
|||||||
40% { opacity: 1; transform: translateY(-2px); }
|
40% { opacity: 1; transform: translateY(-2px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Category tree node hover — reveal action buttons */
|
/* File management */
|
||||||
.cat-tree-node:hover .cat-actions {
|
.files-layout {
|
||||||
opacity: 1 !important;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-panel,
|
||||||
|
.files-list-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-panel {
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-panel-header,
|
||||||
|
.files-list-header {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-scroll,
|
||||||
|
.files-table-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-scroll {
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-scroll .ant-tree,
|
||||||
|
.files-category-scroll .ant-tree-list,
|
||||||
|
.files-category-scroll .ant-tree-list-holder-inner {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-scroll .ant-tree-node-content-wrapper {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-tree-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-actions .ant-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-tree-node:hover .cat-actions,
|
||||||
|
.cat-tree-node:focus-within .cat-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-current-category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-current-category span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-stat {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-extension-stats {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-name-cell {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-empty .anticon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-upload-category {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.files-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(150px, 30%) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-category-panel {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-extension-stats {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,544 +1,159 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Input, message, Modal } from 'antd'
|
||||||
import {
|
import { useState } from 'react'
|
||||||
Button,
|
import type { FileCategory } from '../api'
|
||||||
Input,
|
import FileCategoryTree from '../components/files/FileCategoryTree'
|
||||||
message,
|
import FileTable from '../components/files/FileTable'
|
||||||
Modal,
|
import FileUploadModal from '../components/files/FileUploadModal'
|
||||||
Popconfirm,
|
import PageWrapper from '../components/PageWrapper'
|
||||||
Select,
|
import { useFiles } from '../hooks/useFiles'
|
||||||
Space,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Tree,
|
|
||||||
Upload,
|
|
||||||
} from 'antd'
|
|
||||||
import type { DataNode } from 'antd/es/tree'
|
|
||||||
import {
|
|
||||||
PlusOutlined,
|
|
||||||
UploadOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
FolderAddOutlined,
|
|
||||||
FileOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import type { UploadFile, UploadProps } from 'antd'
|
|
||||||
import { filesApi, type FileCategory, type FileRecord } from '../api'
|
|
||||||
import { colors, fontSizes } from '../tokens'
|
|
||||||
import { formatDateTime } from '../utils/date'
|
|
||||||
|
|
||||||
const { Dragger } = Upload
|
interface CategoryDialogState {
|
||||||
|
open: boolean
|
||||||
/** Format bytes to human-readable string. */
|
mode: 'create' | 'rename'
|
||||||
function formatSize(bytes: number): string {
|
name: string
|
||||||
if (bytes === 0) return '0 B'
|
parentId?: string
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
categoryId?: string
|
||||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map extension to a vibrant color. */
|
const CLOSED_CATEGORY_DIALOG: CategoryDialogState = {
|
||||||
const extColorMap: Record<string, string> = {
|
open: false,
|
||||||
txt: '#8c8c8c', md: '#1677ff', json: '#52c41a', yaml: '#13c2c2', yml: '#13c2c2',
|
mode: 'create',
|
||||||
csv: '#fa8c16', xml: '#722ed1', xlsx: '#fa541c', xls: '#fa541c',
|
name: '',
|
||||||
png: '#eb2f96', jpg: '#eb2f96', jpeg: '#eb2f96', gif: '#eb2f96', svg: '#eb2f96',
|
|
||||||
zip: '#f5222d', py: '#2f54eb', js: '#fadb14', ts: '#1677ff',
|
|
||||||
}
|
|
||||||
|
|
||||||
function extTagColor(ext: string): string {
|
|
||||||
return extColorMap[ext] || '#8c8c8c'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取选中的分类名称 */
|
|
||||||
function findCategoryName(cats: FileCategory[], id: string): string {
|
|
||||||
for (const c of cats) {
|
|
||||||
if (c.key === id) return c.title
|
|
||||||
for (const ch of c.children) {
|
|
||||||
if (ch.key === id) return ch.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const [categories, setCategories] = useState<FileCategory[]>([])
|
const {
|
||||||
const [files, setFiles] = useState<FileRecord[]>([])
|
categories,
|
||||||
const [loading, setLoading] = useState(false)
|
files,
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
config,
|
||||||
const [selectedCatName, setSelectedCatName] = useState('全部文件')
|
selectedCategoryId,
|
||||||
|
selectedCategoryName,
|
||||||
// Upload dialog
|
loading,
|
||||||
|
selectCategory,
|
||||||
|
refresh,
|
||||||
|
createCategory,
|
||||||
|
renameCategory,
|
||||||
|
deleteCategory,
|
||||||
|
uploadFiles,
|
||||||
|
deleteFile,
|
||||||
|
downloadFile,
|
||||||
|
} = useFiles()
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
const [uploadCategory, setUploadCategory] = useState<string | undefined>(undefined)
|
const [categoryDialog, setCategoryDialog] = useState<CategoryDialogState>(CLOSED_CATEGORY_DIALOG)
|
||||||
const [uploadFileList, setUploadFileList] = useState<UploadFile[]>([])
|
const [categorySaving, setCategorySaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
|
|
||||||
// Category dialog (create / rename)
|
const openCreateCategory = (parentId?: string) => {
|
||||||
const [catDialogOpen, setCatDialogOpen] = useState(false)
|
setCategoryDialog({ open: true, mode: 'create', name: '', parentId })
|
||||||
const [catDialogMode, setCatDialogMode] = useState<'create' | 'rename'>('create')
|
}
|
||||||
const [catDialogName, setCatDialogName] = useState('')
|
|
||||||
const [catDialogParentId, setCatDialogParentId] = useState<string | undefined>(undefined)
|
|
||||||
const [catDialogEditId, setCatDialogEditId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// ── Data loading ─────────────────────────────────────────────────
|
const openRenameCategory = (category: FileCategory) => {
|
||||||
|
setCategoryDialog({
|
||||||
|
open: true,
|
||||||
|
mode: 'rename',
|
||||||
|
name: category.name,
|
||||||
|
categoryId: category.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadCategories = useCallback(async () => {
|
const saveCategory = async () => {
|
||||||
|
const name = categoryDialog.name.trim()
|
||||||
|
if (!name) return
|
||||||
|
setCategorySaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await filesApi.listCategories()
|
if (categoryDialog.mode === 'create') {
|
||||||
setCategories(res.data)
|
await createCategory(name, categoryDialog.parentId)
|
||||||
} catch { /* error handled by interceptor */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadFiles = useCallback(async (categoryId?: string | null) => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await filesApi.list(categoryId)
|
|
||||||
setFiles(res.data)
|
|
||||||
} catch { /* error handled by interceptor */ }
|
|
||||||
finally { setLoading(false) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCategories()
|
|
||||||
loadFiles()
|
|
||||||
}, [loadCategories, loadFiles])
|
|
||||||
|
|
||||||
const handleCategorySelect = (keys: React.Key[]) => {
|
|
||||||
const key = keys.length > 0 ? (keys[0] as string) : null
|
|
||||||
setSelectedCategory(key)
|
|
||||||
const name = key ? findCategoryName(categories, key) : '全部文件'
|
|
||||||
setSelectedCatName(name || '全部文件')
|
|
||||||
loadFiles(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Total stats ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const totalSize = files.reduce((sum, f) => sum + f.file_size, 0)
|
|
||||||
const extCounts = files.reduce<Record<string, number>>((acc, f) => {
|
|
||||||
acc[f.file_ext] = (acc[f.file_ext] || 0) + 1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
// ── Tree helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const buildTree = (cats: FileCategory[]): DataNode[] => {
|
|
||||||
const allNode: DataNode = {
|
|
||||||
key: '__all__',
|
|
||||||
title: (
|
|
||||||
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}>
|
|
||||||
<FolderOutlined style={{ marginRight: 6 }} />全部文件
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
icon: <FileOutlined />,
|
|
||||||
children: cats.length > 0 ? cats.map(mapCategory) : [],
|
|
||||||
selectable: true,
|
|
||||||
}
|
|
||||||
return [allNode]
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapCategory = (cat: FileCategory): DataNode => {
|
|
||||||
const titleNode = (
|
|
||||||
<div className="cat-tree-node" style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
width: '100%', paddingRight: 4,
|
|
||||||
}}>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{cat.title}
|
|
||||||
</span>
|
|
||||||
<span className="cat-actions" style={{
|
|
||||||
display: 'inline-flex', gap: 0, flexShrink: 0, opacity: 0.55,
|
|
||||||
transition: 'opacity 0.15s',
|
|
||||||
}}>
|
|
||||||
<Button size="small" type="text" icon={<PlusOutlined style={{ fontSize: 11 }} />}
|
|
||||||
onClick={(e) => { e.stopPropagation(); openCreateCat(cat.key) }}
|
|
||||||
style={{ width: 24, height: 24 }} />
|
|
||||||
<Button size="small" type="text" icon={<EditOutlined style={{ fontSize: 11 }} />}
|
|
||||||
onClick={(e) => { e.stopPropagation(); openRenameCat(cat.key, cat.title) }}
|
|
||||||
style={{ width: 24, height: 24 }} />
|
|
||||||
<Popconfirm title="删除此分类及其所有子分类和文件?" onConfirm={() => handleDeleteCategory(cat.key)}>
|
|
||||||
<Button size="small" type="text" danger icon={<DeleteOutlined style={{ fontSize: 11 }} />}
|
|
||||||
onClick={(e) => { e.stopPropagation() }}
|
|
||||||
style={{ width: 24, height: 24 }} />
|
|
||||||
</Popconfirm>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
key: cat.key,
|
|
||||||
title: titleNode,
|
|
||||||
icon: <FolderOutlined style={{ color: '#faad14' }} />,
|
|
||||||
children: cat.children.length > 0 ? cat.children.map(mapCategory) : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Category actions ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
const openCreateCat = (parentId?: string) => {
|
|
||||||
setCatDialogMode('create')
|
|
||||||
setCatDialogName('')
|
|
||||||
setCatDialogParentId(parentId)
|
|
||||||
setCatDialogEditId(null)
|
|
||||||
setCatDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRenameCat = (id: string, currentName: string) => {
|
|
||||||
setCatDialogMode('rename')
|
|
||||||
setCatDialogName(currentName)
|
|
||||||
setCatDialogParentId(undefined)
|
|
||||||
setCatDialogEditId(id)
|
|
||||||
setCatDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCatDialogOk = async () => {
|
|
||||||
if (!catDialogName.trim()) return
|
|
||||||
try {
|
|
||||||
if (catDialogMode === 'create') {
|
|
||||||
await filesApi.createCategory({ name: catDialogName.trim(), parent_id: catDialogParentId })
|
|
||||||
message.success('分类已创建')
|
message.success('分类已创建')
|
||||||
} else if (catDialogEditId) {
|
} else if (categoryDialog.categoryId) {
|
||||||
await filesApi.updateCategory(catDialogEditId, { name: catDialogName.trim() })
|
await renameCategory(categoryDialog.categoryId, name)
|
||||||
message.success('分类已更新')
|
message.success('分类已更新')
|
||||||
}
|
}
|
||||||
setCatDialogOpen(false)
|
setCategoryDialog(CLOSED_CATEGORY_DIALOG)
|
||||||
loadCategories()
|
} catch {
|
||||||
} catch { /* handled */ }
|
// The shared API interceptor displays the server error.
|
||||||
|
} finally {
|
||||||
|
setCategorySaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteCategory = async (id: string) => {
|
const handleDeleteCategory = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await filesApi.deleteCategory(id)
|
await deleteCategory(id)
|
||||||
message.success('分类已删除')
|
message.success('分类已删除')
|
||||||
if (selectedCategory === id) {
|
} catch {
|
||||||
setSelectedCategory(null)
|
// The shared API interceptor displays the server error.
|
||||||
setSelectedCatName('全部文件')
|
|
||||||
loadFiles(null)
|
|
||||||
}
|
}
|
||||||
loadCategories()
|
|
||||||
} catch { /* handled */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File actions ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (uploadFileList.length === 0) return
|
|
||||||
setUploading(true)
|
|
||||||
let success = 0
|
|
||||||
let fail = 0
|
|
||||||
for (const item of uploadFileList) {
|
|
||||||
try {
|
|
||||||
await filesApi.upload(item.originFileObj as File, uploadCategory || null)
|
|
||||||
success++
|
|
||||||
} catch { fail++ }
|
|
||||||
}
|
|
||||||
setUploading(false)
|
|
||||||
setUploadOpen(false)
|
|
||||||
setUploadFileList([])
|
|
||||||
if (success > 0) {
|
|
||||||
const msg = fail > 0 ? `成功 ${success} 个,失败 ${fail} 个` : `成功上传 ${success} 个文件`
|
|
||||||
message[fail > 0 ? 'warning' : 'success'](msg)
|
|
||||||
// 刷新上传目标分类的文件列表
|
|
||||||
loadFiles(uploadCategory || null)
|
|
||||||
if (uploadCategory && uploadCategory !== selectedCategory) {
|
|
||||||
setSelectedCategory(uploadCategory)
|
|
||||||
setSelectedCatName(findCategoryName(categories, uploadCategory) || '全部文件')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error('上传失败,请检查文件格式和大小')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownload = (record: FileRecord) => {
|
|
||||||
window.open(filesApi.downloadUrl(record.id), '_blank')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteFile = async (id: string) => {
|
const handleDeleteFile = async (id: string) => {
|
||||||
await filesApi.delete(id)
|
try {
|
||||||
|
await deleteFile(id)
|
||||||
message.success('文件已删除')
|
message.success('文件已删除')
|
||||||
loadFiles(selectedCategory)
|
} catch {
|
||||||
|
// The shared API interceptor displays the server error.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openUpload = () => {
|
const handleDownload = async (record: Parameters<typeof downloadFile>[0]) => {
|
||||||
setUploadCategory(selectedCategory || undefined)
|
try {
|
||||||
setUploadFileList([])
|
await downloadFile(record)
|
||||||
setUploadOpen(true)
|
} catch {
|
||||||
|
// The shared API interceptor displays the server error.
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadProps: UploadProps = {
|
|
||||||
multiple: true,
|
|
||||||
fileList: uploadFileList,
|
|
||||||
customRequest: () => {
|
|
||||||
// 不自动上传,由「开始上传」按钮统一处理
|
|
||||||
},
|
|
||||||
onChange: (info) => {
|
|
||||||
setUploadFileList(info.fileList)
|
|
||||||
},
|
|
||||||
onRemove: (file) => {
|
|
||||||
setUploadFileList((prev) => prev.filter((f) => f.uid !== file.uid))
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Table columns ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '文件名', dataIndex: 'original_name', key: 'name',
|
|
||||||
render: (name: string, record: FileRecord) => (
|
|
||||||
<Space size={8}>
|
|
||||||
<FileOutlined style={{ color: extTagColor(record.file_ext), fontSize: 16 }} />
|
|
||||||
<span style={{ fontWeight: 500 }}>{name}</span>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '大小', dataIndex: 'file_size', key: 'size', width: 100,
|
|
||||||
render: (size: number) => (
|
|
||||||
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
|
|
||||||
{formatSize(size)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型', dataIndex: 'file_ext', key: 'type', width: 90,
|
|
||||||
render: (ext: string) => (
|
|
||||||
<Tag color={extTagColor(ext)} style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>
|
|
||||||
{ext.toUpperCase()}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '上传时间', dataIndex: 'created_at', key: 'created_at', width: 170,
|
|
||||||
render: (t: string) => (
|
|
||||||
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
|
|
||||||
{formatDateTime(t)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作', key: 'action', width: 100,
|
|
||||||
render: (_: any, record: FileRecord) => (
|
|
||||||
<Space size={0}>
|
|
||||||
<Tooltip title="下载">
|
|
||||||
<Button size="small" type="text" icon={<DownloadOutlined />}
|
|
||||||
onClick={() => handleDownload(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Popconfirm title="确认删除此文件?" onConfirm={() => handleDeleteFile(record.id)}>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
|
||||||
</Tooltip>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const treeData = buildTree(categories)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<PageWrapper title="原始文件" description="上传和管理评测相关的原始文件" inline fullHeight>
|
||||||
{/* 页头 */}
|
<div className="files-layout">
|
||||||
<div style={{
|
<FileCategoryTree
|
||||||
padding: '10px 16px 8px', flexShrink: 0,
|
categories={categories}
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
selectedCategoryId={selectedCategoryId}
|
||||||
}}>
|
onSelect={selectCategory}
|
||||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>原始文件</h2>
|
onCreate={openCreateCategory}
|
||||||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
onRename={openRenameCategory}
|
||||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>上传和管理评测相关的原始文件</span>
|
onDelete={handleDeleteCategory}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主体 grid — 贴边铺满 */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'minmax(240px, 22vw) 1fr',
|
|
||||||
gridTemplateRows: '1fr',
|
|
||||||
gap: 0,
|
|
||||||
height: '100%',
|
|
||||||
minHeight: 480,
|
|
||||||
}}>
|
|
||||||
{/* LEFT: 分类面板 */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRight: `1px solid ${colors.border}`,
|
|
||||||
background: '#fff',
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* 分类头部 */}
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderBottom: `1px solid ${colors.border}`,
|
|
||||||
background: colors.bgSubtle,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
|
|
||||||
文件分类
|
|
||||||
</span>
|
|
||||||
<Tooltip title="新建根分类">
|
|
||||||
<Button size="small" type="text" icon={<FolderAddOutlined />}
|
|
||||||
onClick={() => openCreateCat(undefined)} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分类树 — 可滚动 */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 4px' }}>
|
|
||||||
<Tree
|
|
||||||
showIcon
|
|
||||||
defaultExpandAll
|
|
||||||
treeData={treeData}
|
|
||||||
selectedKeys={selectedCategory ? [selectedCategory] : ['__all__']}
|
|
||||||
onSelect={handleCategorySelect}
|
|
||||||
style={{ background: 'transparent' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<FileTable
|
||||||
</div>
|
files={files}
|
||||||
|
|
||||||
{/* RIGHT: 文件列表 */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: '#fff',
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* 文件头部 — 统计 + 操作 */}
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderBottom: `1px solid ${colors.border}`,
|
|
||||||
background: colors.bgSubtle,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 20,
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<FolderOutlined style={{ color: colors.primary, fontSize: 18 }} />
|
|
||||||
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
|
|
||||||
{selectedCatName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: colors.textMuted, fontSize: fontSizes.body }}>
|
|
||||||
{files.length} 个文件
|
|
||||||
</span>
|
|
||||||
<span style={{ color: colors.textMuted, fontSize: fontSizes.body }}>
|
|
||||||
{formatSize(totalSize)}
|
|
||||||
</span>
|
|
||||||
{Object.keys(extCounts).length > 0 && (
|
|
||||||
<Space size={4} wrap style={{ flex: 1 }}>
|
|
||||||
{Object.entries(extCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([ext, count]) => (
|
|
||||||
<Tag key={ext} color={extTagColor(ext)} style={{ margin: 0, fontSize: 11 }}>
|
|
||||||
{ext.toUpperCase()} {count}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{Object.keys(extCounts).length > 5 && (
|
|
||||||
<span style={{ fontSize: 11, color: colors.textMuted }}>
|
|
||||||
+{Object.keys(extCounts).length - 5} 种
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', gap: 8 }}>
|
|
||||||
<Tooltip title="刷新文件列表">
|
|
||||||
<Button size="middle" icon={<ReloadOutlined />}
|
|
||||||
onClick={() => loadFiles(selectedCategory)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Button type="primary" icon={<UploadOutlined />} onClick={openUpload} size="middle">
|
|
||||||
上传文件
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文件表格 — 可滚动 */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
||||||
<Table
|
|
||||||
dataSource={files}
|
|
||||||
columns={columns}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
showHeader={true}
|
categoryName={selectedCategoryName}
|
||||||
pagination={files.length > 10 ? {
|
onRefresh={() => void refresh().catch(() => undefined)}
|
||||||
pageSize: 15,
|
onOpenUpload={() => setUploadOpen(true)}
|
||||||
showSizeChanger: true,
|
onDownload={handleDownload}
|
||||||
pageSizeOptions: ['15', '30', '50'],
|
onDelete={handleDeleteFile}
|
||||||
showTotal: (t) => `共 ${t} 个文件`,
|
|
||||||
} : false}
|
|
||||||
locale={{ emptyText: (
|
|
||||||
<div style={{ padding: 40 }}>
|
|
||||||
<InboxOutlined style={{ fontSize: 48, color: '#d9d9d9', marginBottom: 12 }} />
|
|
||||||
<div style={{ color: colors.textMuted }}>
|
|
||||||
暂无文件,点击右上角「上传文件」开始
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) }}
|
|
||||||
style={{ marginTop: 0 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Dialog */}
|
<FileUploadModal
|
||||||
<Modal
|
|
||||||
title="上传文件"
|
|
||||||
open={uploadOpen}
|
open={uploadOpen}
|
||||||
onCancel={() => { setUploadOpen(false); setUploadFileList([]) }}
|
categories={categories}
|
||||||
onOk={handleUpload}
|
config={config}
|
||||||
okText="开始上传"
|
initialCategoryId={selectedCategoryId}
|
||||||
confirmLoading={uploading}
|
onClose={() => setUploadOpen(false)}
|
||||||
okButtonProps={{ disabled: uploadFileList.length === 0 }}
|
onUpload={uploadFiles}
|
||||||
width={520}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<span style={{ marginRight: 8 }}>目标分类:</span>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="选择分类(可选)"
|
|
||||||
style={{ width: 300 }}
|
|
||||||
value={uploadCategory}
|
|
||||||
onChange={(v) => setUploadCategory(v)}
|
|
||||||
options={categories.flatMap((c) => [
|
|
||||||
{ value: c.key, label: c.title },
|
|
||||||
...c.children.map((ch) => ({ value: ch.key, label: ` └ ${ch.title}` })),
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<Dragger {...uploadProps}>
|
|
||||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
|
||||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
|
||||||
<p className="ant-upload-hint">
|
|
||||||
支持 .txt .md .json .yaml .csv .xml .xlsx .png .jpg .gif .svg .zip .py .js .ts 格式,单文件最大 50MB
|
|
||||||
</p>
|
|
||||||
</Dragger>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Category Dialog */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={catDialogMode === 'create' ? '新建分类' : '重命名分类'}
|
title={categoryDialog.mode === 'create' ? '新建分类' : '重命名分类'}
|
||||||
open={catDialogOpen}
|
open={categoryDialog.open}
|
||||||
onOk={handleCatDialogOk}
|
onOk={() => void saveCategory()}
|
||||||
onCancel={() => setCatDialogOpen(false)}
|
onCancel={() => setCategoryDialog(CLOSED_CATEGORY_DIALOG)}
|
||||||
okText={catDialogMode === 'create' ? '创建' : '保存'}
|
okText={categoryDialog.mode === 'create' ? '创建' : '保存'}
|
||||||
okButtonProps={{ disabled: !catDialogName.trim() }}
|
confirmLoading={categorySaving}
|
||||||
|
okButtonProps={{ disabled: !categoryDialog.name.trim() }}
|
||||||
width={360}
|
width={360}
|
||||||
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="分类名称"
|
placeholder="分类名称"
|
||||||
value={catDialogName}
|
value={categoryDialog.name}
|
||||||
onChange={(e) => setCatDialogName(e.target.value)}
|
onChange={(event) => setCategoryDialog((current) => ({
|
||||||
onPressEnter={handleCatDialogOk}
|
...current,
|
||||||
|
name: event.target.value,
|
||||||
|
}))}
|
||||||
|
onPressEnter={() => void saveCategory()}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
31
frontend/web/src/utils/fileFormat.ts
Normal file
31
frontend/web/src/utils/fileFormat.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionColors: Record<string, string> = {
|
||||||
|
txt: '#666666',
|
||||||
|
md: '#1677ff',
|
||||||
|
json: '#389e0d',
|
||||||
|
yaml: '#08979c',
|
||||||
|
yml: '#08979c',
|
||||||
|
csv: '#d46b08',
|
||||||
|
xml: '#531dab',
|
||||||
|
xlsx: '#237804',
|
||||||
|
xls: '#237804',
|
||||||
|
png: '#c41d7f',
|
||||||
|
jpg: '#c41d7f',
|
||||||
|
jpeg: '#c41d7f',
|
||||||
|
gif: '#c41d7f',
|
||||||
|
svg: '#c41d7f',
|
||||||
|
zip: '#cf1322',
|
||||||
|
py: '#1d39c4',
|
||||||
|
js: '#ad8b00',
|
||||||
|
ts: '#0958d9',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extensionColor(extension: string): string {
|
||||||
|
return extensionColors[extension.toLowerCase()] || '#595959'
|
||||||
|
}
|
||||||
32
frontend/web/src/utils/fileTree.ts
Normal file
32
frontend/web/src/utils/fileTree.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { FileCategory } from '../api'
|
||||||
|
|
||||||
|
export interface CategoryOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCategory(categories: FileCategory[], id: string): FileCategory | undefined {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.id === id) return category
|
||||||
|
const nested = findCategory(category.children, id)
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryContains(category: FileCategory, id: string): boolean {
|
||||||
|
return category.id === id || category.children.some((child) => categoryContains(child, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenCategoryOptions(
|
||||||
|
categories: FileCategory[],
|
||||||
|
depth = 0,
|
||||||
|
): CategoryOption[] {
|
||||||
|
return categories.flatMap((category) => [
|
||||||
|
{
|
||||||
|
value: category.id,
|
||||||
|
label: `${' '.repeat(depth)}${depth > 0 ? '└ ' : ''}${category.name}`,
|
||||||
|
},
|
||||||
|
...flattenCategoryOptions(category.children, depth + 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
@ -85,6 +85,7 @@ run rsync -az --delete \
|
|||||||
--exclude='__pycache__' \
|
--exclude='__pycache__' \
|
||||||
--exclude='.pytest_cache' \
|
--exclude='.pytest_cache' \
|
||||||
--exclude='.ruff_cache' \
|
--exclude='.ruff_cache' \
|
||||||
|
--exclude='AGENTS.md' \
|
||||||
--exclude='data' \
|
--exclude='data' \
|
||||||
--exclude='.env' \
|
--exclude='.env' \
|
||||||
--exclude='config/config.json' \
|
--exclude='config/config.json' \
|
||||||
|
|||||||
198
tests/integration/test_files_api.py
Normal file
198
tests/integration/test_files_api.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"""Integration tests for the file management API."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agenteval.config import get_settings
|
||||||
|
from agenteval.web.app import app
|
||||||
|
from agenteval.web.deps import get_db
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def files_client(tmp_path: Path, monkeypatch):
|
||||||
|
from agenteval.storage.db import ( # noqa: F401
|
||||||
|
EvalResultDB,
|
||||||
|
EvalRunDB,
|
||||||
|
EvalTargetDB,
|
||||||
|
FileCategoryDB,
|
||||||
|
FileRecordDB,
|
||||||
|
ScenarioDB,
|
||||||
|
TurnDB,
|
||||||
|
)
|
||||||
|
from agenteval.web.routers import files as files_module
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{tmp_path / 'files_api.db'}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
files_dir = tmp_path / "files"
|
||||||
|
files_dir.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(files_module, "FILES_DIR", files_dir)
|
||||||
|
settings = get_settings()
|
||||||
|
monkeypatch.setattr(settings, "allowed_extensions", "txt,json")
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 1)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client, session, files_dir
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_category(client: TestClient, name: str, parent_id: str | None = None) -> dict:
|
||||||
|
response = client.post(
|
||||||
|
"/api/files/categories",
|
||||||
|
json={"name": name, "parent_id": parent_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_config_uses_server_settings(files_client):
|
||||||
|
client, _, _ = files_client
|
||||||
|
|
||||||
|
response = client.get("/api/files/config")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"allowed_extensions": ["json", "txt"],
|
||||||
|
"max_upload_size_mb": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_crud_returns_domain_fields_and_deep_tree(files_client):
|
||||||
|
client, _, _ = files_client
|
||||||
|
root = _create_category(client, "根分类")
|
||||||
|
child = _create_category(client, "二级", root["id"])
|
||||||
|
leaf = _create_category(client, "三级", child["id"])
|
||||||
|
|
||||||
|
tree = client.get("/api/files/categories").json()
|
||||||
|
|
||||||
|
assert tree[0]["id"] == root["id"]
|
||||||
|
assert tree[0]["name"] == "根分类"
|
||||||
|
assert tree[0]["children"][0]["id"] == child["id"]
|
||||||
|
assert tree[0]["children"][0]["children"][0]["id"] == leaf["id"]
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/files/categories/{leaf['id']}",
|
||||||
|
json={"name": "三级分类"},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["name"] == "三级分类"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_category_rejects_missing_parent(files_client):
|
||||||
|
client, _, _ = files_client
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/files/categories",
|
||||||
|
json={"name": "孤立分类", "parent_id": "missing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "父分类不存在"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_files_includes_all_descendant_categories(files_client):
|
||||||
|
client, _, _ = files_client
|
||||||
|
root = _create_category(client, "根分类")
|
||||||
|
child = _create_category(client, "二级", root["id"])
|
||||||
|
leaf = _create_category(client, "三级", child["id"])
|
||||||
|
|
||||||
|
upload = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
data={"category_id": leaf["id"]},
|
||||||
|
files={"file": ("deep.txt", b"deep content", "text/plain")},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
|
||||||
|
response = client.get("/api/files", params={"category_id": root["id"]})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert [item["original_name"] for item in response.json()] == ["deep.txt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_download_and_delete_file(files_client):
|
||||||
|
client, _, files_dir = files_client
|
||||||
|
|
||||||
|
uploaded = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uploaded.status_code == 200
|
||||||
|
record = uploaded.json()
|
||||||
|
assert record["original_name"] == "notes.txt"
|
||||||
|
assert record["file_size"] == 5
|
||||||
|
assert len(list(files_dir.iterdir())) == 1
|
||||||
|
|
||||||
|
downloaded = client.get(f"/api/files/{record['id']}/download")
|
||||||
|
assert downloaded.status_code == 200
|
||||||
|
assert downloaded.content == b"hello"
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/files/{record['id']}")
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert list(files_dir.iterdir()) == []
|
||||||
|
assert client.get("/api/files").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_invalid_category_and_extension(files_client):
|
||||||
|
client, _, files_dir = files_client
|
||||||
|
|
||||||
|
invalid_category = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
data={"category_id": "missing"},
|
||||||
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
||||||
|
)
|
||||||
|
invalid_extension = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
files={"file": ("notes.exe", b"hello", "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert invalid_category.status_code == 400
|
||||||
|
assert invalid_category.json()["detail"] == "分类不存在"
|
||||||
|
assert invalid_extension.status_code == 400
|
||||||
|
assert list(files_dir.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_oversized_file_without_disk_artifact(files_client):
|
||||||
|
client, _, files_dir = files_client
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
files={"file": ("large.txt", b"x" * (1024 * 1024 + 1), "text/plain")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 413
|
||||||
|
assert list(files_dir.iterdir()) == []
|
||||||
|
assert client.get("/api/files").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_category_removes_descendant_files(files_client):
|
||||||
|
client, _, files_dir = files_client
|
||||||
|
root = _create_category(client, "根分类")
|
||||||
|
child = _create_category(client, "子分类", root["id"])
|
||||||
|
uploaded = client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
data={"category_id": child["id"]},
|
||||||
|
files={"file": ("child.txt", b"content", "text/plain")},
|
||||||
|
)
|
||||||
|
assert uploaded.status_code == 200
|
||||||
|
assert len(list(files_dir.iterdir())) == 1
|
||||||
|
|
||||||
|
response = client.delete(f"/api/files/categories/{root['id']}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert client.get("/api/files/categories").json() == []
|
||||||
|
assert client.get("/api/files").json() == []
|
||||||
|
assert list(files_dir.iterdir()) == []
|
||||||
@ -1,19 +1,23 @@
|
|||||||
"""Unit tests for FileCategoryRepository and FileRecordRepository."""
|
"""Unit tests for FileCategoryRepository and FileRecordRepository."""
|
||||||
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
|
|
||||||
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
|
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def file_db_session(tmp_path: Path):
|
def file_db_session(tmp_path: Path):
|
||||||
"""Isolated SQLite session with file management tables."""
|
"""Isolated SQLite session with file management tables."""
|
||||||
from agenteval.storage.db import ( # noqa: F401
|
from agenteval.storage.db import ( # noqa: F401
|
||||||
EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
|
EvalResultDB,
|
||||||
|
EvalRunDB,
|
||||||
|
EvalTargetDB,
|
||||||
|
FileCategoryDB,
|
||||||
|
FileRecordDB,
|
||||||
|
ScenarioDB,
|
||||||
|
TurnDB,
|
||||||
)
|
)
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
f"sqlite:///{tmp_path / 'file_test.db'}",
|
f"sqlite:///{tmp_path / 'file_test.db'}",
|
||||||
@ -28,19 +32,6 @@ def file_db_session(tmp_path: Path):
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fake_files_dir(tmp_path: Path, monkeypatch):
|
|
||||||
"""Redirect FILES_DIR to a temp directory so tests don't touch data/files."""
|
|
||||||
import agenteval.storage.db as db_mod
|
|
||||||
import agenteval.storage.file_repository as repo_mod
|
|
||||||
|
|
||||||
fake_dir = tmp_path / "files"
|
|
||||||
fake_dir.mkdir()
|
|
||||||
monkeypatch.setattr(db_mod, "FILES_DIR", fake_dir)
|
|
||||||
monkeypatch.setattr(repo_mod, "FILES_DIR", fake_dir)
|
|
||||||
return fake_dir
|
|
||||||
|
|
||||||
|
|
||||||
# ── FileCategoryRepository ────────────────────────────────────────────────
|
# ── FileCategoryRepository ────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_create_root_category(file_db_session):
|
def test_create_root_category(file_db_session):
|
||||||
@ -73,8 +64,8 @@ def test_get_tree_structure(file_db_session):
|
|||||||
repo.create("child2", parent_id=root.id)
|
repo.create("child2", parent_id=root.id)
|
||||||
tree = repo.get_tree()
|
tree = repo.get_tree()
|
||||||
assert len(tree) == 1
|
assert len(tree) == 1
|
||||||
assert tree[0]["key"] == root.id
|
assert tree[0].id == root.id
|
||||||
assert len(tree[0]["children"]) == 2
|
assert len(tree[0].children) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_get_tree_flat(file_db_session):
|
def test_get_tree_flat(file_db_session):
|
||||||
@ -83,7 +74,7 @@ def test_get_tree_flat(file_db_session):
|
|||||||
repo.create("B")
|
repo.create("B")
|
||||||
tree = repo.get_tree()
|
tree = repo.get_tree()
|
||||||
assert len(tree) == 2
|
assert len(tree) == 2
|
||||||
assert all(len(node["children"]) == 0 for node in tree)
|
assert all(len(node.children) == 0 for node in tree)
|
||||||
|
|
||||||
|
|
||||||
def test_update_category_name(file_db_session):
|
def test_update_category_name(file_db_session):
|
||||||
@ -116,7 +107,7 @@ def test_delete_nonexistent_returns_false(file_db_session):
|
|||||||
assert repo.delete("ghost-id") is False
|
assert repo.delete("ghost-id") is False
|
||||||
|
|
||||||
|
|
||||||
def test_delete_cascades_to_children(file_db_session, fake_files_dir):
|
def test_delete_cascades_to_children(file_db_session):
|
||||||
cat_repo = FileCategoryRepository(file_db_session)
|
cat_repo = FileCategoryRepository(file_db_session)
|
||||||
file_repo = FileRecordRepository(file_db_session)
|
file_repo = FileRecordRepository(file_db_session)
|
||||||
|
|
||||||
@ -203,16 +194,13 @@ def test_delete_file_record(file_db_session):
|
|||||||
assert repo.get(rec.id) is None
|
assert repo.get(rec.id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_delete_removes_physical_file(file_db_session, fake_files_dir):
|
def test_get_subtree_ids(file_db_session):
|
||||||
repo = FileRecordRepository(file_db_session)
|
repo = FileCategoryRepository(file_db_session)
|
||||||
storage_name = f"{uuid.uuid4()}.txt"
|
root = repo.create("root")
|
||||||
physical = fake_files_dir / storage_name
|
child = repo.create("child", parent_id=root.id)
|
||||||
physical.write_text("content")
|
leaf = repo.create("leaf", parent_id=child.id)
|
||||||
assert physical.exists()
|
|
||||||
|
|
||||||
rec = repo.create("orig.txt", storage_name, 7, "text/plain", "txt")
|
assert set(repo.get_subtree_ids(root.id)) == {root.id, child.id, leaf.id}
|
||||||
repo.delete(rec.id)
|
|
||||||
assert not physical.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_nonexistent_file_returns_false(file_db_session):
|
def test_delete_nonexistent_file_returns_false(file_db_session):
|
||||||
|
|||||||
92
tests/unit/test_file_service.py
Normal file
92
tests/unit/test_file_service.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for file workflow transaction compensation and path safety."""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agenteval.services.files import FileManagementService
|
||||||
|
from agenteval.storage.file_repository import FileRecordRepository
|
||||||
|
from agenteval.storage.file_storage import FileStorage, InvalidStorageNameError
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
from starlette.datastructures import Headers, UploadFile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def service_context(tmp_path: Path):
|
||||||
|
from agenteval.storage.db import ( # noqa: F401
|
||||||
|
EvalResultDB,
|
||||||
|
EvalRunDB,
|
||||||
|
EvalTargetDB,
|
||||||
|
FileCategoryDB,
|
||||||
|
FileRecordDB,
|
||||||
|
ScenarioDB,
|
||||||
|
TurnDB,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{tmp_path / 'file_service.db'}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
storage = FileStorage(tmp_path / "files")
|
||||||
|
service = FileManagementService(session, storage, {"txt"}, 1)
|
||||||
|
yield service, session, storage
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(content: bytes = b"content") -> UploadFile:
|
||||||
|
return UploadFile(
|
||||||
|
filename="test.txt",
|
||||||
|
file=BytesIO(content),
|
||||||
|
headers=Headers({"content-type": "text/plain"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_commit_failure_removes_physical_file(service_context, monkeypatch):
|
||||||
|
service, session, storage = service_context
|
||||||
|
|
||||||
|
def fail_commit():
|
||||||
|
raise RuntimeError("commit failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(session, "commit", fail_commit)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="commit failed"):
|
||||||
|
await service.upload(_upload())
|
||||||
|
|
||||||
|
assert list(storage.root.iterdir()) == []
|
||||||
|
assert FileRecordRepository(session).list_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_commit_failure_restores_physical_file(service_context, monkeypatch):
|
||||||
|
service, session, storage = service_context
|
||||||
|
record = FileRecordRepository(session).create(
|
||||||
|
original_name="test.txt",
|
||||||
|
storage_name="stored.txt",
|
||||||
|
file_size=7,
|
||||||
|
mime_type="text/plain",
|
||||||
|
file_ext="txt",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
physical_file = storage.path_for("stored.txt")
|
||||||
|
physical_file.write_bytes(b"content")
|
||||||
|
|
||||||
|
def fail_commit():
|
||||||
|
raise RuntimeError("commit failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(session, "commit", fail_commit)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="commit failed"):
|
||||||
|
service.delete_file(record.id)
|
||||||
|
|
||||||
|
assert physical_file.read_bytes() == b"content"
|
||||||
|
assert FileRecordRepository(session).get(record.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("storage_name", ["../escape.txt", "folder/file.txt", ""])
|
||||||
|
def test_storage_rejects_names_outside_root(service_context, storage_name):
|
||||||
|
_, _, storage = service_context
|
||||||
|
|
||||||
|
with pytest.raises(InvalidStorageNameError):
|
||||||
|
storage.path_for(storage_name)
|
||||||
Loading…
Reference in New Issue
Block a user