mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 15:47:00 +08:00
feat(FileExplorer): add move and copy functionality with task queuing
This commit is contained in:
+27
-13
@@ -219,31 +219,41 @@ async def api_mkdir(
|
|||||||
@router.post("/move")
|
@router.post("/move")
|
||||||
async def api_move(
|
async def api_move(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
body: MoveRequest
|
body: MoveRequest,
|
||||||
|
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
||||||
):
|
):
|
||||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||||
await move_path(src, dst)
|
debug_info = await move_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
|
||||||
return success({"moved": True, "src": src, "dst": dst})
|
queued = bool(debug_info.get("queued"))
|
||||||
|
response = {
|
||||||
|
"moved": not queued,
|
||||||
|
"queued": queued,
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
"overwrite": overwrite,
|
||||||
|
}
|
||||||
|
if queued:
|
||||||
|
response["task_id"] = debug_info.get("task_id")
|
||||||
|
response["task_name"] = debug_info.get("task_name")
|
||||||
|
return success(response)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rename")
|
@router.post("/rename")
|
||||||
async def api_rename(
|
async def api_rename(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
overwrite: bool = Query(False, description="是否允许覆盖已存在目标")
|
||||||
debug: bool = Query(False, description="返回调试信息")
|
|
||||||
):
|
):
|
||||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||||
from services.virtual_fs import rename_path
|
from services.virtual_fs import rename_path
|
||||||
debug_info = await rename_path(src, dst, overwrite=overwrite, return_debug=debug)
|
await rename_path(src, dst, overwrite=overwrite, return_debug=False)
|
||||||
return success({
|
return success({
|
||||||
"renamed": True,
|
"renamed": True,
|
||||||
"src": src,
|
"src": src,
|
||||||
"dst": dst,
|
"dst": dst,
|
||||||
"overwrite": overwrite,
|
"overwrite": overwrite,
|
||||||
**({"debug": debug_info} if debug else {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -252,19 +262,23 @@ async def api_copy(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
overwrite: bool = Query(False, description="是否覆盖已存在目标"),
|
overwrite: bool = Query(False, description="是否覆盖已存在目标"),
|
||||||
debug: bool = Query(False, description="返回调试信息")
|
|
||||||
):
|
):
|
||||||
from services.virtual_fs import copy_path
|
from services.virtual_fs import copy_path
|
||||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||||
debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=debug)
|
debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
|
||||||
return success({
|
queued = bool(debug_info.get("queued"))
|
||||||
"copied": True,
|
response = {
|
||||||
|
"copied": not queued,
|
||||||
|
"queued": queued,
|
||||||
"src": src,
|
"src": src,
|
||||||
"dst": dst,
|
"dst": dst,
|
||||||
"overwrite": overwrite,
|
"overwrite": overwrite,
|
||||||
**({"debug": debug_info} if debug else {})
|
}
|
||||||
})
|
if queued:
|
||||||
|
response["task_id"] = debug_info.get("task_id")
|
||||||
|
response["task_name"] = debug_info.get("task_name")
|
||||||
|
return success(response)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload/{full_path:path}")
|
@router.post("/upload/{full_path:path}")
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ class TaskQueueService:
|
|||||||
|
|
||||||
result_path = await run_http_download(task)
|
result_path = await run_http_download(task)
|
||||||
task.result = {"path": result_path}
|
task.result = {"path": result_path}
|
||||||
|
elif task.name == "cross_mount_transfer":
|
||||||
|
from services.virtual_fs import run_cross_mount_transfer_task
|
||||||
|
|
||||||
|
result = await run_cross_mount_transfer_task(task)
|
||||||
|
task.result = result
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown task name: {task.name}")
|
raise ValueError(f"Unknown task name: {task.name}")
|
||||||
|
|
||||||
|
|||||||
+394
-9
@@ -1,4 +1,6 @@
|
|||||||
from typing import Dict, Tuple, Any, Union, AsyncIterator, List
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Tuple, Any, Union, AsyncIterator, List, TYPE_CHECKING
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -6,6 +8,9 @@ import time
|
|||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
from models import StorageAdapter
|
from models import StorageAdapter
|
||||||
from .adapters.registry import runtime_registry
|
from .adapters.registry import runtime_registry
|
||||||
@@ -17,6 +22,36 @@ from services.logging import LogService
|
|||||||
from services.config import ConfigCenter
|
from services.config import ConfigCenter
|
||||||
|
|
||||||
|
|
||||||
|
CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer")
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from services.task_queue import Task
|
||||||
|
|
||||||
|
|
||||||
|
def _build_absolute_path(mount_path: str, rel_path: str) -> str:
|
||||||
|
rel_norm = rel_path.lstrip('/')
|
||||||
|
mount_norm = mount_path.rstrip('/')
|
||||||
|
if not mount_norm:
|
||||||
|
return '/' + rel_norm if rel_norm else '/'
|
||||||
|
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
|
||||||
|
|
||||||
|
|
||||||
|
def _join_rel(base: str, name: str) -> str:
|
||||||
|
if not base:
|
||||||
|
return name.lstrip('/')
|
||||||
|
if not name:
|
||||||
|
return base
|
||||||
|
return f"{base.rstrip('/')}/{name.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_rel(rel: str) -> str:
|
||||||
|
if not rel:
|
||||||
|
return ''
|
||||||
|
if '/' not in rel:
|
||||||
|
return ''
|
||||||
|
return rel.rsplit('/', 1)[0]
|
||||||
|
|
||||||
|
|
||||||
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
|
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
|
||||||
norm = path if path.startswith('/') else '/' + path
|
norm = path if path.startswith('/') else '/' + path
|
||||||
adapters = await StorageAdapter.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
@@ -239,22 +274,40 @@ async def delete_path(path: str):
|
|||||||
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
|
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
|
||||||
|
|
||||||
|
|
||||||
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
async def move_path(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
overwrite: bool = False,
|
||||||
|
return_debug: bool = True,
|
||||||
|
allow_cross: bool = False,
|
||||||
|
):
|
||||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite,
|
||||||
|
"operation": "move",
|
||||||
|
"queued": False,
|
||||||
}
|
}
|
||||||
if adapter_model_s.id != adapter_model_d.id:
|
|
||||||
raise HTTPException(400, detail="Cross-adapter move not supported")
|
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
raise HTTPException(400, detail="Invalid destination")
|
raise HTTPException(400, detail="Invalid destination")
|
||||||
|
|
||||||
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
|
if not allow_cross:
|
||||||
|
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||||
|
queue_info = await _enqueue_cross_mount_transfer(
|
||||||
|
operation="move",
|
||||||
|
src=src,
|
||||||
|
dst=dst,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
debug_info.update(queue_info)
|
||||||
|
return debug_info if return_debug else None
|
||||||
|
|
||||||
exists_func = getattr(adapter_s, "exists", None)
|
exists_func = getattr(adapter_s, "exists", None)
|
||||||
stat_func = getattr(adapter_s, "stat_path", None)
|
stat_func = getattr(adapter_s, "stat_path", None)
|
||||||
delete_func = await _ensure_method(adapter_s, "delete")
|
delete_func = await _ensure_method(adapter_s, "delete")
|
||||||
@@ -433,22 +486,40 @@ async def stat_file(path: str):
|
|||||||
return await stat_func(root, rel)
|
return await stat_func(root, rel)
|
||||||
|
|
||||||
|
|
||||||
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
async def copy_path(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
overwrite: bool = False,
|
||||||
|
return_debug: bool = True,
|
||||||
|
allow_cross: bool = False,
|
||||||
|
):
|
||||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite,
|
||||||
|
"operation": "copy",
|
||||||
|
"queued": False,
|
||||||
}
|
}
|
||||||
if adapter_model_s.id != adapter_model_d.id:
|
|
||||||
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot copy mount root")
|
raise HTTPException(400, detail="Cannot copy mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
raise HTTPException(400, detail="Invalid destination")
|
raise HTTPException(400, detail="Invalid destination")
|
||||||
|
|
||||||
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
|
if not allow_cross:
|
||||||
|
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||||
|
queue_info = await _enqueue_cross_mount_transfer(
|
||||||
|
operation="copy",
|
||||||
|
src=src,
|
||||||
|
dst=dst,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
debug_info.update(queue_info)
|
||||||
|
return debug_info if return_debug else None
|
||||||
|
|
||||||
exists_func = getattr(adapter_s, "exists", None)
|
exists_func = getattr(adapter_s, "exists", None)
|
||||||
stat_func = getattr(adapter_s, "stat_path", None)
|
stat_func = getattr(adapter_s, "stat_path", None)
|
||||||
delete_func = getattr(adapter_s, "delete", None)
|
delete_func = getattr(adapter_s, "delete", None)
|
||||||
@@ -494,6 +565,320 @@ async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: b
|
|||||||
return debug_info if return_debug else None
|
return debug_info if return_debug else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_cross_mount_transfer(operation: str, src: str, dst: str, overwrite: bool) -> Dict[str, Any]:
|
||||||
|
if operation not in {"move", "copy"}:
|
||||||
|
raise HTTPException(400, detail="Unsupported transfer operation")
|
||||||
|
|
||||||
|
adapter_s, adapter_model_s, _, _ = await resolve_adapter_and_rel(src)
|
||||||
|
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
|
if adapter_model_s.id == adapter_model_d.id:
|
||||||
|
raise HTTPException(400, detail="Cross-adapter transfer requested but adapters are identical")
|
||||||
|
|
||||||
|
dst_exists = False
|
||||||
|
exists_func = getattr(adapter_d, "exists", None)
|
||||||
|
if callable(exists_func):
|
||||||
|
dst_exists = await exists_func(root_d, rel_d)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await stat_file(dst)
|
||||||
|
dst_exists = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
dst_exists = False
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
dst_exists = False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if dst_exists and not overwrite:
|
||||||
|
raise HTTPException(409, detail="Destination already exists")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"operation": operation,
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
"overwrite": overwrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
from services.task_queue import task_queue_service
|
||||||
|
|
||||||
|
task = await task_queue_service.add_task("cross_mount_transfer", payload)
|
||||||
|
return {
|
||||||
|
"queued": True,
|
||||||
|
"task_id": task.id,
|
||||||
|
"task_name": "cross_mount_transfer",
|
||||||
|
"dst_exists": dst_exists,
|
||||||
|
"cross_adapter": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_cross_mount_transfer_task(task: "Task") -> Dict[str, Any]:
|
||||||
|
from services.task_queue import task_queue_service
|
||||||
|
|
||||||
|
params = task.task_info or {}
|
||||||
|
operation = params.get("operation")
|
||||||
|
src = params.get("src")
|
||||||
|
dst = params.get("dst")
|
||||||
|
overwrite = bool(params.get("overwrite", False))
|
||||||
|
|
||||||
|
if operation not in {"move", "copy"}:
|
||||||
|
raise ValueError(f"Unsupported cross mount operation: {operation}")
|
||||||
|
if not src or not dst:
|
||||||
|
raise ValueError("Missing src or dst for cross mount transfer")
|
||||||
|
|
||||||
|
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||||
|
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
|
|
||||||
|
await task_queue_service.update_meta(task.id, {
|
||||||
|
"operation": operation,
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
})
|
||||||
|
|
||||||
|
if adapter_model_s.id == adapter_model_d.id:
|
||||||
|
if operation == "move":
|
||||||
|
await move_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
|
||||||
|
else:
|
||||||
|
await copy_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
|
||||||
|
return {
|
||||||
|
"mode": "direct",
|
||||||
|
"operation": operation,
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
"files": 0,
|
||||||
|
"bytes": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not rel_s:
|
||||||
|
raise ValueError("Cannot transfer mount root")
|
||||||
|
if not rel_d:
|
||||||
|
raise ValueError("Invalid destination")
|
||||||
|
|
||||||
|
dst_exists = False
|
||||||
|
exists_func = getattr(adapter_d, "exists", None)
|
||||||
|
if callable(exists_func):
|
||||||
|
dst_exists = await exists_func(root_d, rel_d)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await stat_file(dst)
|
||||||
|
dst_exists = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
dst_exists = False
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if dst_exists and not overwrite:
|
||||||
|
raise ValueError("Destination already exists")
|
||||||
|
if dst_exists and overwrite:
|
||||||
|
await delete_path(dst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
src_stat = await stat_file(src)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
raise FileNotFoundError(src) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
src_is_dir = bool(src_stat.get("is_dir"))
|
||||||
|
|
||||||
|
files_to_transfer: List[Dict[str, Any]] = []
|
||||||
|
dirs_to_create: List[str] = []
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(task.id, {
|
||||||
|
"stage": "preparing",
|
||||||
|
"percent": 0.0,
|
||||||
|
"detail": "Collecting source entries",
|
||||||
|
})
|
||||||
|
|
||||||
|
if src_is_dir:
|
||||||
|
if rel_d:
|
||||||
|
dirs_to_create.append(rel_d)
|
||||||
|
list_dir = await _ensure_method(adapter_s, "list_dir")
|
||||||
|
stack: List[Tuple[str, str, str]] = [(rel_s, rel_d, '')]
|
||||||
|
page_size = 200
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
current_rel, current_dst_rel, current_relative = stack.pop()
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
entries, total = await list_dir(root_s, current_rel, page, page_size, "name", "asc")
|
||||||
|
if not entries and (total or 0) == 0:
|
||||||
|
break
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
child_rel = _join_rel(current_rel, name)
|
||||||
|
child_dst_rel = _join_rel(current_dst_rel, name)
|
||||||
|
child_relative = _join_rel(current_relative, name)
|
||||||
|
if entry.get("is_dir"):
|
||||||
|
dirs_to_create.append(child_dst_rel)
|
||||||
|
stack.append((child_rel, child_dst_rel, child_relative))
|
||||||
|
else:
|
||||||
|
files_to_transfer.append({
|
||||||
|
"src_rel": child_rel,
|
||||||
|
"dst_rel": child_dst_rel,
|
||||||
|
"relative_rel": child_relative or name,
|
||||||
|
"size": entry.get("size"),
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
if total is None or page * page_size >= (total or 0):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
else:
|
||||||
|
relative_rel = rel_s or (src_stat.get("name") or "file")
|
||||||
|
files_to_transfer.append({
|
||||||
|
"src_rel": rel_s,
|
||||||
|
"dst_rel": rel_d,
|
||||||
|
"relative_rel": relative_rel,
|
||||||
|
"size": src_stat.get("size"),
|
||||||
|
"name": src_stat.get("name") or rel_s.split('/')[-1],
|
||||||
|
})
|
||||||
|
parent_dir = _parent_rel(rel_d)
|
||||||
|
if parent_dir:
|
||||||
|
dirs_to_create.append(parent_dir)
|
||||||
|
|
||||||
|
CROSS_TRANSFER_TEMP_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_dir = CROSS_TRANSFER_TEMP_ROOT / task.id
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
bytes_downloaded = 0
|
||||||
|
total_dynamic_bytes = sum((f["size"] or 0) for f in files_to_transfer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for job in files_to_transfer:
|
||||||
|
src_abs = _build_absolute_path(adapter_model_s.path, job["src_rel"])
|
||||||
|
data = await read_file(src_abs)
|
||||||
|
temp_path = temp_dir / job["relative_rel"]
|
||||||
|
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with aiofiles.open(temp_path, "wb") as f:
|
||||||
|
await f.write(data)
|
||||||
|
actual_size = len(data)
|
||||||
|
job["temp_path"] = temp_path
|
||||||
|
prev_size = job.get("size") or 0
|
||||||
|
if prev_size <= 0:
|
||||||
|
total_dynamic_bytes += actual_size
|
||||||
|
job_size = actual_size
|
||||||
|
else:
|
||||||
|
job_size = prev_size
|
||||||
|
job["size"] = job_size
|
||||||
|
bytes_downloaded += actual_size
|
||||||
|
percent = None
|
||||||
|
total_for_percent = total_dynamic_bytes if total_dynamic_bytes else bytes_downloaded
|
||||||
|
if total_for_percent:
|
||||||
|
percent = min(100.0, round(bytes_downloaded / total_for_percent * 100, 2))
|
||||||
|
await task_queue_service.update_progress(task.id, {
|
||||||
|
"stage": "downloading",
|
||||||
|
"percent": percent,
|
||||||
|
"bytes_done": bytes_downloaded,
|
||||||
|
"bytes_total": total_dynamic_bytes or None,
|
||||||
|
"detail": f"Downloaded {job['name']}",
|
||||||
|
})
|
||||||
|
|
||||||
|
mkdir_func = await _ensure_method(adapter_d, "mkdir")
|
||||||
|
ensured_dirs: set[str] = set()
|
||||||
|
|
||||||
|
async def ensure_dir(rel_path: str):
|
||||||
|
if not rel_path or rel_path in ensured_dirs:
|
||||||
|
return
|
||||||
|
parent = _parent_rel(rel_path)
|
||||||
|
if parent:
|
||||||
|
await ensure_dir(parent)
|
||||||
|
try:
|
||||||
|
await mkdir_func(root_d, rel_path)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code not in {409, 400}:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Assume directory already exists
|
||||||
|
pass
|
||||||
|
ensured_dirs.add(rel_path)
|
||||||
|
|
||||||
|
for dir_rel in sorted({d for d in dirs_to_create if d}, key=lambda x: x.count('/')):
|
||||||
|
await ensure_dir(dir_rel)
|
||||||
|
|
||||||
|
uploaded_bytes = 0
|
||||||
|
total_bytes = sum((f["size"] or 0) for f in files_to_transfer)
|
||||||
|
|
||||||
|
async def iter_temp_file(path: Path, chunk_size: int = 512 * 1024):
|
||||||
|
async with aiofiles.open(path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
for job in files_to_transfer:
|
||||||
|
parent_dir = _parent_rel(job["dst_rel"])
|
||||||
|
if parent_dir:
|
||||||
|
await ensure_dir(parent_dir)
|
||||||
|
dst_abs = _build_absolute_path(adapter_model_d.path, job["dst_rel"])
|
||||||
|
temp_path: Path = job["temp_path"]
|
||||||
|
await write_file_stream(dst_abs, iter_temp_file(temp_path), overwrite=overwrite)
|
||||||
|
uploaded_bytes += job["size"] or 0
|
||||||
|
percent = None
|
||||||
|
if total_bytes:
|
||||||
|
percent = min(100.0, round(uploaded_bytes / total_bytes * 100, 2))
|
||||||
|
await task_queue_service.update_progress(task.id, {
|
||||||
|
"stage": "uploading",
|
||||||
|
"percent": percent,
|
||||||
|
"bytes_done": uploaded_bytes,
|
||||||
|
"bytes_total": total_bytes or None,
|
||||||
|
"detail": f"Uploaded {job['name']}",
|
||||||
|
})
|
||||||
|
|
||||||
|
if operation == "move":
|
||||||
|
await delete_path(src)
|
||||||
|
|
||||||
|
await task_queue_service.update_progress(task.id, {
|
||||||
|
"stage": "completed",
|
||||||
|
"percent": 100.0,
|
||||||
|
"bytes_done": total_bytes,
|
||||||
|
"bytes_total": total_bytes,
|
||||||
|
"detail": "Completed",
|
||||||
|
})
|
||||||
|
|
||||||
|
await task_queue_service.update_meta(task.id, {
|
||||||
|
"files": len(files_to_transfer),
|
||||||
|
"directories": len({d for d in dirs_to_create if d}),
|
||||||
|
"bytes": total_bytes,
|
||||||
|
"operation": operation,
|
||||||
|
})
|
||||||
|
|
||||||
|
await LogService.action(
|
||||||
|
"virtual_fs",
|
||||||
|
f"Cross-adapter {operation} from {src} to {dst}",
|
||||||
|
details={
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
"operation": operation,
|
||||||
|
"files": len(files_to_transfer),
|
||||||
|
"bytes": total_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "cross",
|
||||||
|
"operation": operation,
|
||||||
|
"src": src,
|
||||||
|
"dst": dst,
|
||||||
|
"files": len(files_to_transfer),
|
||||||
|
"bytes": total_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if temp_dir.exists():
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
except Exception:
|
||||||
|
await LogService.info(
|
||||||
|
"virtual_fs",
|
||||||
|
"Failed to cleanup cross transfer temp dir",
|
||||||
|
details={"task_id": task.id, "temp_dir": str(temp_dir)},
|
||||||
|
)
|
||||||
async def process_file(
|
async def process_file(
|
||||||
path: str,
|
path: str,
|
||||||
processor_type: str,
|
processor_type: str,
|
||||||
|
|||||||
+12
-1
@@ -50,7 +50,18 @@ export const vfsApi = {
|
|||||||
},
|
},
|
||||||
mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }),
|
mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }),
|
||||||
deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }),
|
deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }),
|
||||||
move: (src: string, dst: string) => request('/fs/move', { method: 'POST', json: { src, dst } }),
|
move: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||||
|
const query = params.toString();
|
||||||
|
return request(`/fs/move${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||||
|
},
|
||||||
|
copy: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||||
|
const query = params.toString();
|
||||||
|
return request(`/fs/copy${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||||
|
},
|
||||||
rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }),
|
rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }),
|
||||||
thumb: (path: string, w=256, h=256, fit='cover') =>
|
thumb: (path: string, w=256, h=256, fit='cover') =>
|
||||||
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ export const en = {
|
|||||||
'Grid': 'Grid',
|
'Grid': 'Grid',
|
||||||
'List': 'List',
|
'List': 'List',
|
||||||
'Mount Point': 'Mount Point',
|
'Mount Point': 'Mount Point',
|
||||||
|
'Move': 'Move',
|
||||||
|
'Move to': 'Move to',
|
||||||
|
'Copy to': 'Copy to',
|
||||||
|
'Destination path': 'Destination path',
|
||||||
|
'Move task queued': 'Move task queued',
|
||||||
|
'Move completed': 'Move completed',
|
||||||
|
'Copy task queued': 'Copy task queued',
|
||||||
|
'Copy completed': 'Copy completed',
|
||||||
|
'Please input destination path': 'Please input destination path',
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': 'Upload File',
|
'Upload File': 'Upload File',
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ export const zh = {
|
|||||||
'Grid': '网格',
|
'Grid': '网格',
|
||||||
'List': '列表',
|
'List': '列表',
|
||||||
'Mount Point': '挂载点',
|
'Mount Point': '挂载点',
|
||||||
|
'Move': '移动',
|
||||||
|
'Move to': '移动到',
|
||||||
|
'Copy to': '复制到',
|
||||||
|
'Destination path': '目标路径',
|
||||||
|
'Move task queued': '移动任务已排队',
|
||||||
|
'Move completed': '移动完成',
|
||||||
|
'Copy task queued': '复制任务已排队',
|
||||||
|
'Copy completed': '复制完成',
|
||||||
|
'Please input destination path': '请输入目标路径',
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': '上传文件',
|
'Upload File': '上传文件',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { theme, Pagination } from 'antd';
|
import { theme, Pagination } from 'antd';
|
||||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||||
@@ -22,6 +22,7 @@ import UploadModal from './components/Modals/UploadModal';
|
|||||||
import { ShareModal } from './components/Modals/ShareModal';
|
import { ShareModal } from './components/Modals/ShareModal';
|
||||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||||
import { FileDetailModal } from './components/FileDetailModal';
|
import { FileDetailModal } from './components/FileDetailModal';
|
||||||
|
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
@@ -51,6 +52,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||||
const [detailData, setDetailData] = useState<any>(null);
|
const [detailData, setDetailData] = useState<any>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [movingEntry, setMovingEntry] = useState<VfsEntry | null>(null);
|
||||||
|
const [copyingEntry, setCopyingEntry] = useState<VfsEntry | null>(null);
|
||||||
|
|
||||||
// --- Effects ---
|
// --- Effects ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,6 +85,17 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildDefaultDestination = useCallback((entry: VfsEntry | null) => {
|
||||||
|
if (!entry) return '';
|
||||||
|
const base = path === '/' ? '' : path;
|
||||||
|
const segments = [base, entry.name].filter(Boolean);
|
||||||
|
const joined = segments.join('/');
|
||||||
|
if (!joined) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -189,6 +203,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||||
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
||||||
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
||||||
|
<MoveCopyModal
|
||||||
|
mode="move"
|
||||||
|
entry={movingEntry}
|
||||||
|
open={!!movingEntry}
|
||||||
|
defaultPath={buildDefaultDestination(movingEntry)}
|
||||||
|
onOk={async (destination) => {
|
||||||
|
const target = movingEntry;
|
||||||
|
if (target) {
|
||||||
|
await doMove(target, destination);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setMovingEntry(null)}
|
||||||
|
/>
|
||||||
|
<MoveCopyModal
|
||||||
|
mode="copy"
|
||||||
|
entry={copyingEntry}
|
||||||
|
open={!!copyingEntry}
|
||||||
|
defaultPath={buildDefaultDestination(copyingEntry)}
|
||||||
|
onOk={async (destination) => {
|
||||||
|
const target = copyingEntry;
|
||||||
|
if (target) {
|
||||||
|
await doCopy(target, destination);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setCopyingEntry(null)}
|
||||||
|
/>
|
||||||
{sharingEntries.length > 0 && (
|
{sharingEntries.length > 0 && (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
path={path}
|
path={path}
|
||||||
@@ -244,6 +284,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onShare={doShare}
|
onShare={doShare}
|
||||||
onGetDirectLink={doGetDirectLink}
|
onGetDirectLink={doGetDirectLink}
|
||||||
|
onMove={(entryToMove) => setMovingEntry(entryToMove)}
|
||||||
|
onCopy={(entryToCopy) => setCopyingEntry(entryToCopy)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<UploadModal
|
<UploadModal
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
|||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
import {
|
import {
|
||||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
|
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
|
||||||
|
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
@@ -27,6 +28,8 @@ interface ContextMenuProps {
|
|||||||
onCreateDir: () => void;
|
onCreateDir: () => void;
|
||||||
onShare: (entries: VfsEntry[]) => void;
|
onShare: (entries: VfsEntry[]) => void;
|
||||||
onGetDirectLink: (entry: VfsEntry) => void;
|
onGetDirectLink: (entry: VfsEntry) => void;
|
||||||
|
onMove: (entry: VfsEntry) => void;
|
||||||
|
onCopy: (entry: VfsEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||||
@@ -110,6 +113,20 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||||
onClick: () => actions.onRename(targetEntries[0]),
|
onClick: () => actions.onRename(targetEntries[0]),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'move',
|
||||||
|
label: t('Move'),
|
||||||
|
icon: <SwapOutlined />,
|
||||||
|
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||||
|
onClick: () => actions.onMove(targetEntries[0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'copy',
|
||||||
|
label: t('Copy'),
|
||||||
|
icon: <CopyOutlined />,
|
||||||
|
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||||
|
onClick: () => actions.onCopy(targetEntries[0]),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: t('Delete'),
|
label: t('Delete'),
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Modal, Input, message, Space } from 'antd';
|
||||||
|
import { useI18n } from '../../../../i18n';
|
||||||
|
import type { VfsEntry } from '../../../../api/client';
|
||||||
|
import PathSelectorModal from '../../../../components/PathSelectorModal';
|
||||||
|
|
||||||
|
interface MoveCopyModalProps {
|
||||||
|
mode: 'move' | 'copy';
|
||||||
|
entry: VfsEntry | null;
|
||||||
|
open: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
onOk: (destination: string) => Promise<void> | void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveCopyModal({ mode, entry, open, defaultPath, onOk, onCancel }: MoveCopyModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [value, setValue] = useState(defaultPath);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
const entryName = useMemo(() => entry?.name ?? '', [entry]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setValue(defaultPath);
|
||||||
|
} else {
|
||||||
|
setValue('');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setSelectorOpen(false);
|
||||||
|
}, [open, defaultPath]);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
message.warning(t('Please input destination path'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk(trimmed);
|
||||||
|
onCancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 上层已处理提示,这里只需保持对话框
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = mode === 'move' ? t('Move to') : t('Copy to');
|
||||||
|
const okText = mode === 'move' ? t('Move') : t('Copy');
|
||||||
|
|
||||||
|
const normalizeSelectedPath = (dirPath: string) => {
|
||||||
|
const collapse = (p: string) => p.replace(/\/+/g, '/');
|
||||||
|
const ensureLeading = (p: string) => (p.startsWith('/') ? p : `/${p}`);
|
||||||
|
const normalizeDir = (() => {
|
||||||
|
if (!dirPath || dirPath === '/') return '/';
|
||||||
|
const replaced = dirPath.replace(/\\/g, '/');
|
||||||
|
const trimmed = replaced.endsWith('/') ? replaced.replace(/\/+$/, '') : replaced;
|
||||||
|
return ensureLeading(collapse(trimmed));
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!entryName) {
|
||||||
|
return normalizeDir || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalizeDir === '/' ? '' : normalizeDir;
|
||||||
|
const combined = `${base}/${entryName}`;
|
||||||
|
return ensureLeading(collapse(combined));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowse = (selectedPath: string) => {
|
||||||
|
const finalPath = normalizeSelectedPath(selectedPath);
|
||||||
|
setValue(finalPath);
|
||||||
|
setSelectorOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectorInitialPath = useMemo(() => {
|
||||||
|
if (!value) return '/';
|
||||||
|
const normalized = value.replace(/\\/g, '/');
|
||||||
|
if (!normalized || normalized === '/') return '/';
|
||||||
|
const trimmed = normalized.endsWith('/') ? normalized.replace(/\/+$/, '') : normalized;
|
||||||
|
const parts = trimmed.split('/');
|
||||||
|
if (parts.length <= 1) return '/';
|
||||||
|
parts.pop();
|
||||||
|
const parent = parts.join('/') || '/';
|
||||||
|
return parent.startsWith('/') ? parent : `/${parent}`;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open && !!entry}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
confirmLoading={loading}
|
||||||
|
okText={okText}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Space.Compact style={{ width: '100%', marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
placeholder={t('Destination path')}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onPressEnter={handleOk}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setSelectorOpen(true)}>{t('Select destination')}</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
<PathSelectorModal
|
||||||
|
open={selectorOpen}
|
||||||
|
mode="directory"
|
||||||
|
initialPath={selectorInitialPath}
|
||||||
|
onOk={handleBrowse}
|
||||||
|
onCancel={() => setSelectorOpen(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,17 @@ interface FileActionsParams {
|
|||||||
|
|
||||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const normalizeFullPath = useCallback((name: string) => {
|
||||||
|
const base = path === '/' ? '' : path;
|
||||||
|
return `${base}/${name}`.replace(/\/{2,}/g, '/');
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
const normalizeDestination = useCallback((dest: string) => {
|
||||||
|
const trimmed = dest.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||||
|
return normalized.replace(/\/{2,}/g, '/');
|
||||||
|
}, []);
|
||||||
const doCreateDir = useCallback(async (name: string) => {
|
const doCreateDir = useCallback(async (name: string) => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
message.warning(t('Please input name'));
|
message.warning(t('Please input name'));
|
||||||
@@ -57,6 +68,49 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
|||||||
}
|
}
|
||||||
}, [path, refresh]);
|
}, [path, refresh]);
|
||||||
|
|
||||||
|
const doMove = useCallback(async (entry: VfsEntry, destination: string, overwrite: boolean = false) => {
|
||||||
|
const normalized = normalizeDestination(destination);
|
||||||
|
if (!normalized) {
|
||||||
|
message.warning(t('Please input destination path'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = normalizeFullPath(entry.name);
|
||||||
|
try {
|
||||||
|
const result = await vfsApi.move(src, normalized, { overwrite });
|
||||||
|
if (result?.queued) {
|
||||||
|
message.info(t('Move task queued'));
|
||||||
|
} else {
|
||||||
|
message.success(t('Move completed'));
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, [normalizeDestination, normalizeFullPath, t, refresh, clearSelection]);
|
||||||
|
|
||||||
|
const doCopy = useCallback(async (entry: VfsEntry, destination: string, overwrite: boolean = false) => {
|
||||||
|
const normalized = normalizeDestination(destination);
|
||||||
|
if (!normalized) {
|
||||||
|
message.warning(t('Please input destination path'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = normalizeFullPath(entry.name);
|
||||||
|
try {
|
||||||
|
const result = await vfsApi.copy(src, normalized, { overwrite });
|
||||||
|
if (result?.queued) {
|
||||||
|
message.info(t('Copy task queued'));
|
||||||
|
} else {
|
||||||
|
message.success(t('Copy completed'));
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, [normalizeDestination, normalizeFullPath, t, refresh]);
|
||||||
|
|
||||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
const doDownload = useCallback(async (entry: VfsEntry) => {
|
||||||
if (entry.is_dir) {
|
if (entry.is_dir) {
|
||||||
message.warning(t('Downloading folders is not supported'));
|
message.warning(t('Downloading folders is not supported'));
|
||||||
@@ -101,5 +155,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
|||||||
doDownload,
|
doDownload,
|
||||||
doShare,
|
doShare,
|
||||||
doGetDirectLink,
|
doGetDirectLink,
|
||||||
|
doMove,
|
||||||
|
doCopy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user