mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-07 23:31:52 +08:00
feat(processors): add processor management
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
from fastapi import APIRouter, Depends, Body
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, Body, HTTPException
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from services.processors.registry import get_config_schemas
|
from services.processors.registry import (
|
||||||
|
get_config_schemas,
|
||||||
|
get_module_path,
|
||||||
|
reload_processors,
|
||||||
|
)
|
||||||
from services.task_queue import task_queue_service
|
from services.task_queue import task_queue_service
|
||||||
from services.auth import get_current_active_user, User
|
from services.auth import get_current_active_user, User
|
||||||
from api.response import success
|
from api.response import success
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from services.virtual_fs import path_is_directory
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/processors", tags=["processors"])
|
router = APIRouter(prefix="/api/processors", tags=["processors"])
|
||||||
|
|
||||||
@@ -22,6 +29,7 @@ async def list_processors(
|
|||||||
"supported_exts": meta.get("supported_exts", []),
|
"supported_exts": meta.get("supported_exts", []),
|
||||||
"config_schema": meta["config_schema"],
|
"config_schema": meta["config_schema"],
|
||||||
"produces_file": meta.get("produces_file", False),
|
"produces_file": meta.get("produces_file", False),
|
||||||
|
"module_path": meta.get("module_path"),
|
||||||
})
|
})
|
||||||
return success(out)
|
return success(out)
|
||||||
|
|
||||||
@@ -34,12 +42,20 @@ class ProcessRequest(BaseModel):
|
|||||||
overwrite: bool = False
|
overwrite: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSourceRequest(BaseModel):
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process")
|
@router.post("/process")
|
||||||
async def process_file_with_processor(
|
async def process_file_with_processor(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
req: ProcessRequest = Body(...)
|
req: ProcessRequest = Body(...)
|
||||||
):
|
):
|
||||||
save_to = req.path if req.overwrite else req.save_to
|
is_dir = await path_is_directory(req.path)
|
||||||
|
if is_dir and not req.overwrite:
|
||||||
|
raise HTTPException(400, detail="Directory processing requires overwrite")
|
||||||
|
|
||||||
|
save_to = None if is_dir else (req.path if req.overwrite else req.save_to)
|
||||||
task = await task_queue_service.add_task(
|
task = await task_queue_service.add_task(
|
||||||
"process_file",
|
"process_file",
|
||||||
{
|
{
|
||||||
@@ -47,6 +63,54 @@ async def process_file_with_processor(
|
|||||||
"processor_type": req.processor_type,
|
"processor_type": req.processor_type,
|
||||||
"config": req.config,
|
"config": req.config,
|
||||||
"save_to": save_to,
|
"save_to": save_to,
|
||||||
|
"overwrite": req.overwrite,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return success({"task_id": task.id})
|
return success({"task_id": task.id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source/{processor_type}")
|
||||||
|
async def get_processor_source(
|
||||||
|
processor_type: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
module_path = get_module_path(processor_type)
|
||||||
|
if not module_path:
|
||||||
|
raise HTTPException(404, detail="Processor not found")
|
||||||
|
path_obj = Path(module_path)
|
||||||
|
if not path_obj.exists():
|
||||||
|
raise HTTPException(404, detail="Processor source not found")
|
||||||
|
try:
|
||||||
|
content = await run_in_threadpool(path_obj.read_text, encoding='utf-8')
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, detail=f"Failed to read source: {exc}")
|
||||||
|
return success({"source": content, "module_path": str(path_obj)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/source/{processor_type}")
|
||||||
|
async def update_processor_source(
|
||||||
|
processor_type: str,
|
||||||
|
req: UpdateSourceRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
module_path = get_module_path(processor_type)
|
||||||
|
if not module_path:
|
||||||
|
raise HTTPException(404, detail="Processor not found")
|
||||||
|
path_obj = Path(module_path)
|
||||||
|
if not path_obj.exists():
|
||||||
|
raise HTTPException(404, detail="Processor source not found")
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(path_obj.write_text, req.source, encoding='utf-8')
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, detail=f"Failed to write source: {exc}")
|
||||||
|
return success(True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reload")
|
||||||
|
async def reload_processor_modules(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
errors = reload_processors()
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(500, detail="; ".join(errors))
|
||||||
|
return success(True)
|
||||||
|
|||||||
@@ -1,33 +1,53 @@
|
|||||||
import pkgutil
|
|
||||||
import inspect
|
import inspect
|
||||||
from importlib import import_module
|
import pkgutil
|
||||||
from typing import Dict, Callable
|
from importlib import import_module, reload
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Callable, Dict, Optional
|
||||||
|
|
||||||
from .base import BaseProcessor
|
from .base import BaseProcessor
|
||||||
|
|
||||||
ProcessorFactory = Callable[[], BaseProcessor]
|
ProcessorFactory = Callable[[], BaseProcessor]
|
||||||
TYPE_MAP: Dict[str, ProcessorFactory] = {}
|
TYPE_MAP: Dict[str, ProcessorFactory] = {}
|
||||||
CONFIG_SCHEMAS: Dict[str, dict] = {}
|
CONFIG_SCHEMAS: Dict[str, dict] = {}
|
||||||
|
MODULE_MAP: Dict[str, ModuleType] = {}
|
||||||
|
LAST_DISCOVERY_ERRORS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def discover_processors(force_reload: bool = False) -> list[str]:
|
||||||
|
"""Discover available processor modules and cache their metadata."""
|
||||||
|
import services.processors # 延迟导入以避免循环
|
||||||
|
|
||||||
def discover_processors():
|
|
||||||
import services.processors
|
|
||||||
processors_pkg = services.processors
|
processors_pkg = services.processors
|
||||||
TYPE_MAP.clear()
|
TYPE_MAP.clear()
|
||||||
CONFIG_SCHEMAS.clear()
|
CONFIG_SCHEMAS.clear()
|
||||||
|
MODULE_MAP.clear()
|
||||||
|
|
||||||
|
global LAST_DISCOVERY_ERRORS
|
||||||
|
LAST_DISCOVERY_ERRORS = []
|
||||||
|
|
||||||
for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
|
for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
|
||||||
if modinfo.name.startswith("_"):
|
if modinfo.name.startswith("_"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
full_name = f"{processors_pkg.__name__}.{modinfo.name}"
|
full_name = f"{processors_pkg.__name__}.{modinfo.name}"
|
||||||
try:
|
try:
|
||||||
module = import_module(full_name)
|
module = import_module(full_name)
|
||||||
except Exception:
|
if force_reload:
|
||||||
|
module = reload(module)
|
||||||
|
except Exception as exc:
|
||||||
|
LAST_DISCOVERY_ERRORS.append(f"Failed to import {full_name}: {exc}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
processor_type = getattr(module, "PROCESSOR_TYPE", None)
|
processor_type = getattr(module, "PROCESSOR_TYPE", None)
|
||||||
processor_name = getattr(module, "PROCESSOR_NAME", None)
|
processor_name = getattr(module, "PROCESSOR_NAME", None)
|
||||||
supported_exts = getattr(module, "SUPPORTED_EXTS", None)
|
supported_exts = getattr(module, "SUPPORTED_EXTS", None)
|
||||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||||
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
||||||
|
|
||||||
if not processor_type:
|
if not processor_type:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if factory is None:
|
if factory is None:
|
||||||
for attr in module.__dict__.values():
|
for attr in module.__dict__.values():
|
||||||
if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
|
if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
|
||||||
@@ -35,31 +55,85 @@ def discover_processors():
|
|||||||
return lambda: cls()
|
return lambda: cls()
|
||||||
factory = _mk()
|
factory = _mk()
|
||||||
break
|
break
|
||||||
|
|
||||||
if not callable(factory):
|
if not callable(factory):
|
||||||
|
LAST_DISCOVERY_ERRORS.append(f"Processor {full_name} missing factory")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sample = factory()
|
||||||
|
except Exception as exc:
|
||||||
|
LAST_DISCOVERY_ERRORS.append(f"Failed to instantiate processor {processor_type}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
TYPE_MAP[processor_type] = factory
|
TYPE_MAP[processor_type] = factory
|
||||||
|
MODULE_MAP[processor_type] = module
|
||||||
|
|
||||||
produces_file = getattr(module, "produces_file", None)
|
produces_file = getattr(module, "produces_file", None)
|
||||||
if produces_file is None and hasattr(factory(), "produces_file"):
|
if produces_file is None and hasattr(sample, "produces_file"):
|
||||||
produces_file = getattr(factory(), "produces_file")
|
produces_file = getattr(sample, "produces_file")
|
||||||
|
|
||||||
|
module_file = getattr(module, "__file__", None)
|
||||||
|
module_path: Optional[str] = None
|
||||||
|
if module_file:
|
||||||
|
try:
|
||||||
|
module_path = str(Path(module_file).resolve())
|
||||||
|
except Exception:
|
||||||
|
module_path = module_file
|
||||||
|
|
||||||
|
if isinstance(supported_exts, list):
|
||||||
|
normalized_exts = [str(ext) for ext in supported_exts]
|
||||||
|
elif supported_exts:
|
||||||
|
normalized_exts = [str(supported_exts)]
|
||||||
|
else:
|
||||||
|
normalized_exts = []
|
||||||
|
|
||||||
|
if not normalized_exts and hasattr(sample, "supported_exts"):
|
||||||
|
sample_exts = getattr(sample, "supported_exts") or []
|
||||||
|
if isinstance(sample_exts, list):
|
||||||
|
normalized_exts = [str(ext) for ext in sample_exts]
|
||||||
|
|
||||||
if isinstance(schema, list):
|
if isinstance(schema, list):
|
||||||
CONFIG_SCHEMAS[processor_type] = {
|
CONFIG_SCHEMAS[processor_type] = {
|
||||||
"type": processor_type,
|
"type": processor_type,
|
||||||
"name": processor_name or processor_type,
|
"name": processor_name or processor_type,
|
||||||
"supported_exts": supported_exts or [],
|
"supported_exts": normalized_exts,
|
||||||
"config_schema": schema,
|
"config_schema": schema,
|
||||||
"produces_file": produces_file if produces_file is not None else False
|
"produces_file": produces_file if produces_file is not None else False,
|
||||||
|
"module_path": module_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return LAST_DISCOVERY_ERRORS
|
||||||
|
|
||||||
|
|
||||||
def get_config_schemas() -> Dict[str, dict]:
|
def get_config_schemas() -> Dict[str, dict]:
|
||||||
return CONFIG_SCHEMAS
|
return CONFIG_SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
def get_config_schema(processor_type: str):
|
def get_config_schema(processor_type: str):
|
||||||
return CONFIG_SCHEMAS.get(processor_type)
|
return CONFIG_SCHEMAS.get(processor_type)
|
||||||
|
|
||||||
|
|
||||||
def get(processor_type: str) -> BaseProcessor:
|
def get(processor_type: str) -> BaseProcessor:
|
||||||
factory = TYPE_MAP.get(processor_type)
|
factory = TYPE_MAP.get(processor_type)
|
||||||
if factory:
|
if factory:
|
||||||
return factory()
|
return factory()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_path(processor_type: str) -> Optional[str]:
|
||||||
|
meta = CONFIG_SCHEMAS.get(processor_type)
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
return meta.get("module_path")
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_discovery_errors() -> list[str]:
|
||||||
|
return LAST_DISCOVERY_ERRORS
|
||||||
|
|
||||||
|
|
||||||
|
def reload_processors() -> list[str]:
|
||||||
|
return discover_processors(force_reload=True)
|
||||||
|
|
||||||
|
|
||||||
discover_processors()
|
discover_processors()
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class TaskQueueService:
|
|||||||
path=params["path"],
|
path=params["path"],
|
||||||
processor_type=params["processor_type"],
|
processor_type=params["processor_type"],
|
||||||
config=params["config"],
|
config=params["config"],
|
||||||
save_to=params["save_to"]
|
save_to=params.get("save_to"),
|
||||||
|
overwrite=params.get("overwrite", False),
|
||||||
)
|
)
|
||||||
task.result = result
|
task.result = result
|
||||||
elif task.name == "automation_task":
|
elif task.name == "automation_task":
|
||||||
@@ -119,4 +120,4 @@ class TaskQueueService:
|
|||||||
await LogService.info("task_queue", "Task worker has been stopped.")
|
await LogService.info("task_queue", "Task worker has been stopped.")
|
||||||
|
|
||||||
|
|
||||||
task_queue_service = TaskQueueService()
|
task_queue_service = TaskQueueService()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
from typing import Dict, Tuple, Any, Union, AsyncIterator, List
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -59,6 +59,24 @@ async def _ensure_method(adapter: Any, method: str):
|
|||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
async def path_is_directory(path: str) -> bool:
|
||||||
|
"""判断给定路径是否为目录。"""
|
||||||
|
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||||
|
rel = rel.rstrip('/')
|
||||||
|
if rel == '':
|
||||||
|
return True
|
||||||
|
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||||
|
if not callable(stat_func):
|
||||||
|
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||||
|
try:
|
||||||
|
info = await stat_func(root, rel)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(404, detail="Path not found")
|
||||||
|
if isinstance(info, dict):
|
||||||
|
return bool(info.get("is_dir"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
|
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
|
||||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||||
adapters = await StorageAdapter.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
@@ -476,28 +494,110 @@ 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 process_file(path: str, processor_type: str, config: dict, save_to: str = None):
|
async def process_file(
|
||||||
"""
|
path: str,
|
||||||
使用指定处理器处理文件,并可选择保存到新路径
|
processor_type: str,
|
||||||
:param path: 源文件路径
|
config: dict,
|
||||||
:param processor_type: 处理器类型
|
save_to: str | None = None,
|
||||||
:param config: 处理器配置
|
overwrite: bool = False,
|
||||||
:param save_to: 保存路径(可选),不指定则只返回处理结果
|
) -> Any:
|
||||||
:return: 处理后的文件内容或保存结果
|
"""处理指定路径(文件或目录)。目录会递归处理其下所有文件。"""
|
||||||
"""
|
|
||||||
data = await read_file(path)
|
|
||||||
processor = get_processor(processor_type)
|
processor = get_processor(processor_type)
|
||||||
if not processor:
|
if not processor:
|
||||||
raise HTTPException(
|
raise HTTPException(400, detail=f"Processor {processor_type} not found")
|
||||||
400, detail=f"Processor {processor_type} not found")
|
|
||||||
result = await processor.process(data, path, config)
|
actual_is_dir = await path_is_directory(path)
|
||||||
if save_to and getattr(processor, "produces_file", False):
|
|
||||||
|
supported_exts = getattr(processor, "supported_exts", None) or []
|
||||||
|
allowed_exts = {
|
||||||
|
str(ext).lower().lstrip('.')
|
||||||
|
for ext in supported_exts
|
||||||
|
if isinstance(ext, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
def matches_extension(rel_path: str) -> bool:
|
||||||
|
if not allowed_exts:
|
||||||
|
return True
|
||||||
|
if '.' not in rel_path:
|
||||||
|
return '' in allowed_exts
|
||||||
|
ext = rel_path.rsplit('.', 1)[-1].lower()
|
||||||
|
return ext in allowed_exts or f'.{ext}' in allowed_exts
|
||||||
|
|
||||||
|
def coerce_result_bytes(result: Any) -> bytes:
|
||||||
if isinstance(result, Response):
|
if isinstance(result, Response):
|
||||||
result_bytes = result.body
|
return result.body
|
||||||
else:
|
if isinstance(result, (bytes, bytearray)):
|
||||||
result_bytes = result
|
return bytes(result)
|
||||||
await write_file(save_to, result_bytes)
|
if isinstance(result, str):
|
||||||
return {"saved_to": save_to}
|
return result.encode('utf-8')
|
||||||
|
raise HTTPException(500, detail="Processor must return bytes/Response when produces_file=True")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if actual_is_dir:
|
||||||
|
if save_to:
|
||||||
|
raise HTTPException(400, detail="Directory processing does not support custom save_to path")
|
||||||
|
if not overwrite:
|
||||||
|
raise HTTPException(400, detail="Directory processing requires overwrite")
|
||||||
|
|
||||||
|
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(path)
|
||||||
|
rel = rel.rstrip('/')
|
||||||
|
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||||
|
processed_count = 0
|
||||||
|
stack: List[str] = [rel]
|
||||||
|
page_size = 200
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
entries, total = await list_dir(root, current, 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 = f"{current}/{name}" if current else name
|
||||||
|
if entry.get("is_dir"):
|
||||||
|
stack.append(child_rel)
|
||||||
|
continue
|
||||||
|
if not matches_extension(child_rel):
|
||||||
|
continue
|
||||||
|
absolute_path = build_absolute_path(adapter_model.path, child_rel)
|
||||||
|
data = await read_file(absolute_path)
|
||||||
|
result = await processor.process(data, absolute_path, config)
|
||||||
|
if getattr(processor, "produces_file", False):
|
||||||
|
result_bytes = coerce_result_bytes(result)
|
||||||
|
await write_file(absolute_path, result_bytes)
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
if total is None or page * page_size >= total:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return {"processed_files": processed_count}
|
||||||
|
|
||||||
|
# 单文件处理
|
||||||
|
data = await read_file(path)
|
||||||
|
result = await processor.process(data, path, config)
|
||||||
|
|
||||||
|
target_path = save_to
|
||||||
|
if overwrite and not target_path:
|
||||||
|
target_path = path
|
||||||
|
|
||||||
|
if target_path and getattr(processor, "produces_file", False):
|
||||||
|
result_bytes = coerce_result_bytes(result)
|
||||||
|
await write_file(target_path, result_bytes)
|
||||||
|
return {"saved_to": target_path}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export interface ProcessorTypeMeta {
|
|||||||
name: string;
|
name: string;
|
||||||
supported_exts: string[];
|
supported_exts: string[];
|
||||||
config_schema: ProcessorTypeField[];
|
config_schema: ProcessorTypeField[];
|
||||||
produces_file:boolean;
|
produces_file: boolean;
|
||||||
|
module_path?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processorsApi = {
|
export const processorsApi = {
|
||||||
@@ -29,11 +30,21 @@ export const processorsApi = {
|
|||||||
save_to?: string;
|
save_to?: string;
|
||||||
overwrite?: boolean;
|
overwrite?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
request<any>('/processors/process', {
|
request<{ task_id: string }>('/processors/process', {
|
||||||
|
method: 'POST',
|
||||||
|
json: params,
|
||||||
|
}),
|
||||||
|
getSource: (type: string) =>
|
||||||
|
request<{ source: string; module_path: string }>('/processors/source/' + encodeURIComponent(type), {
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
updateSource: (type: string, source: string) =>
|
||||||
|
request<boolean>('/processors/source/' + encodeURIComponent(type), {
|
||||||
|
method: 'PUT',
|
||||||
|
json: { source },
|
||||||
|
}),
|
||||||
|
reload: () =>
|
||||||
|
request<boolean>('/processors/reload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(params),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,3 +40,29 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
|
|||||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
|
||||||
|
.processors-tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.processors-tabs .ant-tabs-content-holder,
|
||||||
|
.processors-tabs .ant-tabs-content {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.processors-tabs .ant-tabs-tabpane {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.processors-tabs .ant-tabs-tabpane-active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|||||||
@@ -342,6 +342,35 @@ export const en = {
|
|||||||
// Processor flow
|
// Processor flow
|
||||||
'Processing finished': 'Processing finished',
|
'Processing finished': 'Processing finished',
|
||||||
'Processing failed': 'Processing failed',
|
'Processing failed': 'Processing failed',
|
||||||
|
'Processors': 'Processors',
|
||||||
|
'Processor List': 'Processor List',
|
||||||
|
'Reload': 'Reload',
|
||||||
|
'Run Processor': 'Run Processor',
|
||||||
|
'Target Path': 'Target Path',
|
||||||
|
'Please select a path': 'Please select a path',
|
||||||
|
'Select Directory': 'Select Directory',
|
||||||
|
'Overwrite original': 'Overwrite original',
|
||||||
|
'Save To': 'Save To',
|
||||||
|
'Optional output path': 'Optional output path',
|
||||||
|
'Run': 'Run',
|
||||||
|
'Select a processor': 'Select a processor',
|
||||||
|
'No module path': 'No module path',
|
||||||
|
'Source saved': 'Source saved',
|
||||||
|
'Processors reloaded': 'Processors reloaded',
|
||||||
|
'Unsaved changes': 'Unsaved changes',
|
||||||
|
'Switching processor will discard unsaved changes. Continue?': 'Switching processor will discard unsaved changes. Continue?',
|
||||||
|
'Task submitted': 'Task submitted',
|
||||||
|
'Supported Extensions': 'Supported Extensions',
|
||||||
|
'All': 'All',
|
||||||
|
'Produces File': 'Produces File',
|
||||||
|
'Yes': 'Yes',
|
||||||
|
'No': 'No',
|
||||||
|
'Please select a processor': 'Please select a processor',
|
||||||
|
'Select a path': 'Select a path',
|
||||||
|
'Source Editor': 'Source Editor',
|
||||||
|
'Module Path': 'Module Path',
|
||||||
|
'Directory processing always overwrites original files': 'Directory processing always overwrites original files',
|
||||||
|
'No data': 'No data',
|
||||||
|
|
||||||
// Path selector
|
// Path selector
|
||||||
'Select File': 'Select File',
|
'Select File': 'Select File',
|
||||||
|
|||||||
@@ -344,6 +344,35 @@ export const zh = {
|
|||||||
// Processor flow
|
// Processor flow
|
||||||
'Processing finished': '处理完成',
|
'Processing finished': '处理完成',
|
||||||
'Processing failed': '处理失败',
|
'Processing failed': '处理失败',
|
||||||
|
'Processors': '处理器',
|
||||||
|
'Processor List': '处理器列表',
|
||||||
|
'Reload': '重载',
|
||||||
|
'Run Processor': '运行处理器',
|
||||||
|
'Target Path': '目标路径',
|
||||||
|
'Please select a path': '请选择路径',
|
||||||
|
'Select Directory': '选择目录',
|
||||||
|
'Overwrite original': '覆盖原文件',
|
||||||
|
'Save To': '保存到',
|
||||||
|
'Optional output path': '可选输出路径',
|
||||||
|
'Run': '运行',
|
||||||
|
'Select a processor': '选择处理器',
|
||||||
|
'No module path': '未检测到模块路径',
|
||||||
|
'Source saved': '源码已保存',
|
||||||
|
'Processors reloaded': '处理器已重载',
|
||||||
|
'Unsaved changes': '存在未保存的修改',
|
||||||
|
'Switching processor will discard unsaved changes. Continue?': '切换处理器会丢失未保存的修改,确认继续?',
|
||||||
|
'Task submitted': '任务已提交',
|
||||||
|
'Supported Extensions': '支持的扩展名',
|
||||||
|
'All': '全部',
|
||||||
|
'Produces File': '生成文件',
|
||||||
|
'Yes': '是',
|
||||||
|
'No': '否',
|
||||||
|
'Please select a processor': '请选择处理器',
|
||||||
|
'Select a path': '请选择路径',
|
||||||
|
'Source Editor': '源码编辑',
|
||||||
|
'Module Path': '模块路径',
|
||||||
|
'Directory processing always overwrites original files': '选择目录时会强制覆盖原文件',
|
||||||
|
'No data': '暂无数据',
|
||||||
|
|
||||||
// Path selector
|
// Path selector
|
||||||
'Select File': '选择文件',
|
'Select File': '选择文件',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
BugOutlined,
|
BugOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
CodeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
key: 'manage',
|
key: 'manage',
|
||||||
title: 'Manage',
|
title: 'Manage',
|
||||||
children: [
|
children: [
|
||||||
|
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
|
||||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
||||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
|
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
|
||||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
|
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
|||||||
overwrite: overwrite ? true : undefined,
|
overwrite: overwrite ? true : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await processorsApi.process(params);
|
const resp = await processorsApi.process(params);
|
||||||
message.success(t('Processing finished'));
|
message.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||||
setModal({ entry: null, visible: false });
|
setModal({ entry: null, visible: false });
|
||||||
if (overwrite || savingPath) refresh();
|
if (overwrite || savingPath) refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
482
web/src/pages/ProcessorsPage.tsx
Normal file
482
web/src/pages/ProcessorsPage.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import PageCard from '../components/PageCard';
|
||||||
|
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||||
|
import PathSelectorModal, { type PathSelectorMode } from '../components/PathSelectorModal';
|
||||||
|
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type TabKey = 'editor' | 'runner';
|
||||||
|
|
||||||
|
const ProcessorsPage = memo(function ProcessorsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [processors, setProcessors] = useState<ProcessorTypeMeta[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('');
|
||||||
|
const [source, setSource] = useState('');
|
||||||
|
const [initialSource, setInitialSource] = useState('');
|
||||||
|
const [modulePath, setModulePath] = useState('');
|
||||||
|
const [sourceLoading, setSourceLoading] = useState(false);
|
||||||
|
const [savingSource, setSavingSource] = useState(false);
|
||||||
|
const [reloading, setReloading] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [isDirectory, setIsDirectory] = useState(false);
|
||||||
|
const [pathModalOpen, setPathModalOpen] = useState(false);
|
||||||
|
const [pathModalMode, setPathModalMode] = useState<PathSelectorMode>('file');
|
||||||
|
const [pathModalField, setPathModalField] = useState<'path' | 'save_to'>('path');
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('editor');
|
||||||
|
|
||||||
|
const isDirty = source !== initialSource;
|
||||||
|
|
||||||
|
const selectedProcessorMeta = useMemo(
|
||||||
|
() => processors.find(p => p.type === selectedType),
|
||||||
|
[processors, selectedType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const list = await processorsApi.list();
|
||||||
|
setProcessors(list);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processors.length) {
|
||||||
|
setSelectedType('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedType) {
|
||||||
|
setSelectedType(processors[0].type);
|
||||||
|
} else if (!processors.some(p => p.type === selectedType)) {
|
||||||
|
setSelectedType(processors[0].type);
|
||||||
|
}
|
||||||
|
}, [processors, selectedType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedType) {
|
||||||
|
setSource('');
|
||||||
|
setInitialSource('');
|
||||||
|
setModulePath('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
setSource('');
|
||||||
|
setInitialSource('');
|
||||||
|
setModulePath('');
|
||||||
|
setSourceLoading(true);
|
||||||
|
processorsApi.getSource(selectedType)
|
||||||
|
.then(resp => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setSource(resp.source ?? '');
|
||||||
|
setInitialSource(resp.source ?? '');
|
||||||
|
setModulePath(resp.module_path ?? '');
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
message.error(err?.message || t('Load failed'));
|
||||||
|
setSource('');
|
||||||
|
setInitialSource('');
|
||||||
|
setModulePath('');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setSourceLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [selectedType, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.resetFields();
|
||||||
|
const defaults: Record<string, any> = {};
|
||||||
|
selectedProcessorMeta?.config_schema?.forEach(field => {
|
||||||
|
if (field.default !== undefined) {
|
||||||
|
defaults[field.key] = field.default;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.setFieldsValue({
|
||||||
|
path: '',
|
||||||
|
overwrite: !!selectedProcessorMeta?.produces_file,
|
||||||
|
save_to: undefined,
|
||||||
|
config: defaults,
|
||||||
|
});
|
||||||
|
setIsDirectory(false);
|
||||||
|
}, [selectedProcessorMeta, form]);
|
||||||
|
|
||||||
|
const overwriteValue = Form.useWatch('overwrite', form) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (overwriteValue) {
|
||||||
|
form.setFieldsValue({ save_to: undefined });
|
||||||
|
}
|
||||||
|
}, [overwriteValue, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDirectory) {
|
||||||
|
form.setFieldsValue({ overwrite: true, save_to: undefined });
|
||||||
|
}
|
||||||
|
}, [isDirectory, form]);
|
||||||
|
|
||||||
|
const handleSelectProcessor = useCallback((type: string) => {
|
||||||
|
if (type === selectedType) return;
|
||||||
|
if (isDirty) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('Unsaved changes'),
|
||||||
|
content: t('Switching processor will discard unsaved changes. Continue?'),
|
||||||
|
okText: t('Confirm'),
|
||||||
|
cancelText: t('Cancel'),
|
||||||
|
onOk: () => {
|
||||||
|
setSelectedType(type);
|
||||||
|
setActiveTab('editor');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSelectedType(type);
|
||||||
|
setActiveTab('editor');
|
||||||
|
}
|
||||||
|
}, [isDirty, selectedType, t]);
|
||||||
|
|
||||||
|
const handleSaveSource = useCallback(async () => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
try {
|
||||||
|
setSavingSource(true);
|
||||||
|
await processorsApi.updateSource(selectedType, source);
|
||||||
|
setInitialSource(source);
|
||||||
|
message.success(t('Source saved'));
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setSavingSource(false);
|
||||||
|
}
|
||||||
|
}, [selectedType, source, t]);
|
||||||
|
|
||||||
|
const handleReloadProcessors = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setReloading(true);
|
||||||
|
await processorsApi.reload();
|
||||||
|
message.success(t('Processors reloaded'));
|
||||||
|
await loadList();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setReloading(false);
|
||||||
|
}
|
||||||
|
}, [loadList, t]);
|
||||||
|
|
||||||
|
const openPathSelector = useCallback((field: 'path' | 'save_to', mode: PathSelectorMode) => {
|
||||||
|
setPathModalField(field);
|
||||||
|
setPathModalMode(mode);
|
||||||
|
setPathModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePathSelected = useCallback((selectedPath: string) => {
|
||||||
|
if (pathModalField === 'path') {
|
||||||
|
form.setFieldsValue({ path: selectedPath });
|
||||||
|
setIsDirectory(pathModalMode === 'directory');
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue({ save_to: selectedPath });
|
||||||
|
}
|
||||||
|
setPathModalOpen(false);
|
||||||
|
}, [form, pathModalField, pathModalMode]);
|
||||||
|
|
||||||
|
const handleRun = useCallback(async () => {
|
||||||
|
if (!selectedType) {
|
||||||
|
message.warning(t('Please select a processor'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const schema = selectedProcessorMeta?.config_schema || [];
|
||||||
|
const finalConfig: Record<string, any> = {};
|
||||||
|
schema.forEach(field => {
|
||||||
|
const value = values.config?.[field.key];
|
||||||
|
if (value === undefined) {
|
||||||
|
finalConfig[field.key] = field.default;
|
||||||
|
} else {
|
||||||
|
finalConfig[field.key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setRunning(true);
|
||||||
|
const payload: any = {
|
||||||
|
path: values.path,
|
||||||
|
processor_type: selectedType,
|
||||||
|
config: finalConfig,
|
||||||
|
overwrite: !!values.overwrite,
|
||||||
|
};
|
||||||
|
if (values.save_to && !values.overwrite) {
|
||||||
|
payload.save_to = values.save_to;
|
||||||
|
}
|
||||||
|
const resp = await processorsApi.process(payload);
|
||||||
|
message.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(err?.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}, [form, selectedProcessorMeta, selectedType, t]);
|
||||||
|
|
||||||
|
const selectedConfigPath = pathModalField === 'path'
|
||||||
|
? form.getFieldValue('path') || '/'
|
||||||
|
: form.getFieldValue('save_to') || '/';
|
||||||
|
|
||||||
|
const renderProcessorList = () => {
|
||||||
|
if (loadingList) {
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||||
|
<Spin />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!processors.length) {
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||||
|
<Empty description={t('No data')} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8, overflowY: 'auto', height: '100%' }}>
|
||||||
|
{processors.map(item => {
|
||||||
|
const selected = item.type === selectedType;
|
||||||
|
const onClick = () => handleSelectProcessor(item.type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.type}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||||
|
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space size={8} align="center">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: selected ? token.colorPrimary : token.colorBorderSecondary,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text strong>{item.name}</Text>
|
||||||
|
</Space>
|
||||||
|
<Tag color={selected ? token.colorPrimary : token.colorBorderSecondary}>{item.type}</Tag>
|
||||||
|
</Flex>
|
||||||
|
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ marginRight: 8 }}>{t('Supported Extensions')}:</Text>
|
||||||
|
{item.supported_exts?.length ? (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{item.supported_exts.map(ext => (
|
||||||
|
<Tag key={ext}>{ext}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Tag>{t('All')}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{t('Produces File')}: {item.produces_file ? t('Yes') : t('No')}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'editor',
|
||||||
|
label: t('Source Editor'),
|
||||||
|
children: selectedType ? (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
{modulePath ? (
|
||||||
|
<Space size={8}>
|
||||||
|
<Text type="secondary">{t('Module Path')}:</Text>
|
||||||
|
<Text code>{modulePath}</Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">{t('No module path')}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
{sourceLoading ? (
|
||||||
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
||||||
|
<Spin />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
language="python"
|
||||||
|
value={source}
|
||||||
|
onChange={(val) => setSource(val ?? '')}
|
||||||
|
height="100%"
|
||||||
|
options={{
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runner',
|
||||||
|
label: t('Run Processor'),
|
||||||
|
children: selectedType ? (
|
||||||
|
<Form form={form} layout="vertical" disabled={!selectedType} style={{ padding: '12px 0' }}>
|
||||||
|
{isDirectory && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
{t('Directory processing always overwrites original files')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="path"
|
||||||
|
label={t('Target Path')}
|
||||||
|
rules={[{ required: true, message: t('Please select a path') }]}
|
||||||
|
>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Input placeholder={t('Select a path')} style={{ flex: 1 }} />
|
||||||
|
<Button onClick={() => openPathSelector('path', 'file')}>{t('Select File')}</Button>
|
||||||
|
<Button onClick={() => openPathSelector('path', 'directory')}>{t('Select Directory')}</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="overwrite"
|
||||||
|
label={t('Overwrite original')}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch disabled={isDirectory} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{selectedProcessorMeta?.produces_file && !overwriteValue && (
|
||||||
|
<Form.Item
|
||||||
|
name="save_to"
|
||||||
|
label={t('Save To')}
|
||||||
|
>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Input placeholder={t('Optional output path')} style={{ flex: 1 }} />
|
||||||
|
<Button onClick={() => openPathSelector('save_to', 'any')}>{t('Select')}</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProcessorConfigForm
|
||||||
|
processorMeta={selectedProcessorMeta}
|
||||||
|
form={form}
|
||||||
|
configPath={['config']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={handleRun} loading={running} disabled={!selectedType}>
|
||||||
|
{t('Run')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageCard title={t('Processors')}>
|
||||||
|
<Flex gap={16} style={{ height: '100%' }}>
|
||||||
|
<Card
|
||||||
|
style={{ flex: '0 0 320px', minWidth: 280, display: 'flex', flexDirection: 'column' }}
|
||||||
|
title={t('Processor List')}
|
||||||
|
extra={
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" onClick={loadList} loading={loadingList}>{t('Refresh')}</Button>
|
||||||
|
<Button size="small" onClick={handleReloadProcessors} loading={reloading}>{t('Reload')}</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex' } }}
|
||||||
|
>
|
||||||
|
{renderProcessorList()}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}
|
||||||
|
title={selectedProcessorMeta ? `${selectedProcessorMeta.name} (${selectedProcessorMeta.type})` : t('Select a processor')}
|
||||||
|
extra={
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" onClick={handleSaveSource} loading={savingSource} disabled={!selectedType || !isDirty}>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleReloadProcessors} loading={reloading} disabled={!selectedType}>
|
||||||
|
{t('Reload')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={key => setActiveTab(key as TabKey)}
|
||||||
|
items={tabs as any}
|
||||||
|
className="processors-tabs"
|
||||||
|
tabBarGutter={32}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<PathSelectorModal
|
||||||
|
open={pathModalOpen}
|
||||||
|
mode={pathModalMode}
|
||||||
|
initialPath={selectedConfigPath}
|
||||||
|
onOk={handlePathSelected}
|
||||||
|
onCancel={() => setPathModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</PageCard>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProcessorsPage;
|
||||||
@@ -7,6 +7,7 @@ import FileExplorerPage from '../pages/FileExplorerPage/FileExplorerPage.tsx';
|
|||||||
import AdaptersPage from '../pages/AdaptersPage.tsx';
|
import AdaptersPage from '../pages/AdaptersPage.tsx';
|
||||||
import SharePage from '../pages/SharePage.tsx';
|
import SharePage from '../pages/SharePage.tsx';
|
||||||
import TasksPage from '../pages/TasksPage.tsx';
|
import TasksPage from '../pages/TasksPage.tsx';
|
||||||
|
import ProcessorsPage from '../pages/ProcessorsPage.tsx';
|
||||||
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
|
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
|
||||||
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
|
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
|
||||||
import LogsPage from '../pages/LogsPage.tsx';
|
import LogsPage from '../pages/LogsPage.tsx';
|
||||||
@@ -37,6 +38,7 @@ const ShellBody = memo(function ShellBody() {
|
|||||||
{navKey === 'files' && <FileExplorerPage />}
|
{navKey === 'files' && <FileExplorerPage />}
|
||||||
{navKey === 'share' && <SharePage />}
|
{navKey === 'share' && <SharePage />}
|
||||||
{navKey === 'tasks' && <TasksPage />}
|
{navKey === 'tasks' && <TasksPage />}
|
||||||
|
{navKey === 'processors' && <ProcessorsPage />}
|
||||||
{navKey === 'offline' && <OfflineDownloadPage />}
|
{navKey === 'offline' && <OfflineDownloadPage />}
|
||||||
{navKey === 'plugins' && <PluginsPage />}
|
{navKey === 'plugins' && <PluginsPage />}
|
||||||
{navKey === 'settings' && <SystemSettingsPage />}
|
{navKey === 'settings' && <SystemSettingsPage />}
|
||||||
|
|||||||
Reference in New Issue
Block a user