mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-26 01:31:42 +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 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.auth import get_current_active_user, User
|
||||
from api.response import success
|
||||
from pydantic import BaseModel
|
||||
from services.virtual_fs import path_is_directory
|
||||
|
||||
router = APIRouter(prefix="/api/processors", tags=["processors"])
|
||||
|
||||
@@ -22,6 +29,7 @@ async def list_processors(
|
||||
"supported_exts": meta.get("supported_exts", []),
|
||||
"config_schema": meta["config_schema"],
|
||||
"produces_file": meta.get("produces_file", False),
|
||||
"module_path": meta.get("module_path"),
|
||||
})
|
||||
return success(out)
|
||||
|
||||
@@ -34,12 +42,20 @@ class ProcessRequest(BaseModel):
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateSourceRequest(BaseModel):
|
||||
source: str
|
||||
|
||||
|
||||
@router.post("/process")
|
||||
async def process_file_with_processor(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
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(
|
||||
"process_file",
|
||||
{
|
||||
@@ -47,6 +63,54 @@ async def process_file_with_processor(
|
||||
"processor_type": req.processor_type,
|
||||
"config": req.config,
|
||||
"save_to": save_to,
|
||||
"overwrite": req.overwrite,
|
||||
},
|
||||
)
|
||||
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
|
||||
from importlib import import_module
|
||||
from typing import Dict, Callable
|
||||
import pkgutil
|
||||
from importlib import import_module, reload
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from .base import BaseProcessor
|
||||
|
||||
ProcessorFactory = Callable[[], BaseProcessor]
|
||||
TYPE_MAP: Dict[str, ProcessorFactory] = {}
|
||||
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
|
||||
TYPE_MAP.clear()
|
||||
CONFIG_SCHEMAS.clear()
|
||||
MODULE_MAP.clear()
|
||||
|
||||
global LAST_DISCOVERY_ERRORS
|
||||
LAST_DISCOVERY_ERRORS = []
|
||||
|
||||
for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
|
||||
if modinfo.name.startswith("_"):
|
||||
continue
|
||||
|
||||
full_name = f"{processors_pkg.__name__}.{modinfo.name}"
|
||||
try:
|
||||
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
|
||||
|
||||
processor_type = getattr(module, "PROCESSOR_TYPE", None)
|
||||
processor_name = getattr(module, "PROCESSOR_NAME", None)
|
||||
supported_exts = getattr(module, "SUPPORTED_EXTS", None)
|
||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
||||
|
||||
if not processor_type:
|
||||
continue
|
||||
|
||||
if factory is None:
|
||||
for attr in module.__dict__.values():
|
||||
if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
|
||||
@@ -35,31 +55,85 @@ def discover_processors():
|
||||
return lambda: cls()
|
||||
factory = _mk()
|
||||
break
|
||||
|
||||
if not callable(factory):
|
||||
LAST_DISCOVERY_ERRORS.append(f"Processor {full_name} missing factory")
|
||||
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
|
||||
MODULE_MAP[processor_type] = module
|
||||
|
||||
produces_file = getattr(module, "produces_file", None)
|
||||
if produces_file is None and hasattr(factory(), "produces_file"):
|
||||
produces_file = getattr(factory(), "produces_file")
|
||||
if produces_file is None and hasattr(sample, "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):
|
||||
CONFIG_SCHEMAS[processor_type] = {
|
||||
"type": processor_type,
|
||||
"name": processor_name or processor_type,
|
||||
"supported_exts": supported_exts or [],
|
||||
"supported_exts": normalized_exts,
|
||||
"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]:
|
||||
return CONFIG_SCHEMAS
|
||||
|
||||
|
||||
def get_config_schema(processor_type: str):
|
||||
return CONFIG_SCHEMAS.get(processor_type)
|
||||
|
||||
|
||||
def get(processor_type: str) -> BaseProcessor:
|
||||
factory = TYPE_MAP.get(processor_type)
|
||||
if factory:
|
||||
return factory()
|
||||
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()
|
||||
|
||||
@@ -54,7 +54,8 @@ class TaskQueueService:
|
||||
path=params["path"],
|
||||
processor_type=params["processor_type"],
|
||||
config=params["config"],
|
||||
save_to=params["save_to"]
|
||||
save_to=params.get("save_to"),
|
||||
overwrite=params.get("overwrite", False),
|
||||
)
|
||||
task.result = result
|
||||
elif task.name == "automation_task":
|
||||
@@ -119,4 +120,4 @@ class TaskQueueService:
|
||||
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
|
||||
import mimetypes
|
||||
from fastapi.responses import Response
|
||||
@@ -59,6 +59,24 @@ async def _ensure_method(adapter: Any, method: str):
|
||||
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:
|
||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||
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
|
||||
|
||||
|
||||
async def process_file(path: str, processor_type: str, config: dict, save_to: str = None):
|
||||
"""
|
||||
使用指定处理器处理文件,并可选择保存到新路径
|
||||
:param path: 源文件路径
|
||||
:param processor_type: 处理器类型
|
||||
:param config: 处理器配置
|
||||
:param save_to: 保存路径(可选),不指定则只返回处理结果
|
||||
:return: 处理后的文件内容或保存结果
|
||||
"""
|
||||
data = await read_file(path)
|
||||
async def process_file(
|
||||
path: str,
|
||||
processor_type: str,
|
||||
config: dict,
|
||||
save_to: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Any:
|
||||
"""处理指定路径(文件或目录)。目录会递归处理其下所有文件。"""
|
||||
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise HTTPException(
|
||||
400, detail=f"Processor {processor_type} not found")
|
||||
result = await processor.process(data, path, config)
|
||||
if save_to and getattr(processor, "produces_file", False):
|
||||
raise HTTPException(400, detail=f"Processor {processor_type} not found")
|
||||
|
||||
actual_is_dir = await path_is_directory(path)
|
||||
|
||||
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):
|
||||
result_bytes = result.body
|
||||
else:
|
||||
result_bytes = result
|
||||
await write_file(save_to, result_bytes)
|
||||
return {"saved_to": save_to}
|
||||
return result.body
|
||||
if isinstance(result, (bytes, bytearray)):
|
||||
return bytes(result)
|
||||
if isinstance(result, str):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface ProcessorTypeMeta {
|
||||
name: string;
|
||||
supported_exts: string[];
|
||||
config_schema: ProcessorTypeField[];
|
||||
produces_file:boolean;
|
||||
produces_file: boolean;
|
||||
module_path?: string | null;
|
||||
}
|
||||
|
||||
export const processorsApi = {
|
||||
@@ -29,11 +30,21 @@ export const processorsApi = {
|
||||
save_to?: string;
|
||||
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',
|
||||
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 .name { font-weight:600; font-size:13px; }
|
||||
.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
|
||||
'Processing finished': 'Processing finished',
|
||||
'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
|
||||
'Select File': 'Select File',
|
||||
|
||||
@@ -344,6 +344,35 @@ export const zh = {
|
||||
// Processor flow
|
||||
'Processing finished': '处理完成',
|
||||
'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
|
||||
'Select File': '选择文件',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BugOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
CodeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -27,6 +28,7 @@ export const navGroups: NavGroup[] = [
|
||||
key: 'manage',
|
||||
title: 'Manage',
|
||||
children: [
|
||||
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
|
||||
|
||||
@@ -49,8 +49,8 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
||||
overwrite: overwrite ? true : undefined,
|
||||
};
|
||||
|
||||
await processorsApi.process(params);
|
||||
message.success(t('Processing finished'));
|
||||
const resp = await processorsApi.process(params);
|
||||
message.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
setModal({ entry: null, visible: false });
|
||||
if (overwrite || savingPath) refresh();
|
||||
} 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 SharePage from '../pages/SharePage.tsx';
|
||||
import TasksPage from '../pages/TasksPage.tsx';
|
||||
import ProcessorsPage from '../pages/ProcessorsPage.tsx';
|
||||
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
|
||||
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
|
||||
import LogsPage from '../pages/LogsPage.tsx';
|
||||
@@ -37,6 +38,7 @@ const ShellBody = memo(function ShellBody() {
|
||||
{navKey === 'files' && <FileExplorerPage />}
|
||||
{navKey === 'share' && <SharePage />}
|
||||
{navKey === 'tasks' && <TasksPage />}
|
||||
{navKey === 'processors' && <ProcessorsPage />}
|
||||
{navKey === 'offline' && <OfflineDownloadPage />}
|
||||
{navKey === 'plugins' && <PluginsPage />}
|
||||
{navKey === 'settings' && <SystemSettingsPage />}
|
||||
|
||||
Reference in New Issue
Block a user