diff --git a/apps/jingrow/frontend/src/shared/stores/tools.ts b/apps/jingrow/frontend/src/shared/stores/tools.ts index 9810461..588222a 100644 --- a/apps/jingrow/frontend/src/shared/stores/tools.ts +++ b/apps/jingrow/frontend/src/shared/stores/tools.ts @@ -136,7 +136,8 @@ export const useToolsStore = defineStore('tools', () => { saveUserTools(userTools.value) // 如果是路由类型工具,注册路由(如果提供了 router) - if (tool.type === 'route' && tool.routeName && router) { + // routeName 会在 registerToolRoute 中自动生成 + if (tool.type === 'route' && router) { registerToolRoute(router, tool, componentPath) } } diff --git a/apps/jingrow/frontend/src/shared/utils/dynamicRoutes.ts b/apps/jingrow/frontend/src/shared/utils/dynamicRoutes.ts index d536e1d..6fdb910 100644 --- a/apps/jingrow/frontend/src/shared/utils/dynamicRoutes.ts +++ b/apps/jingrow/frontend/src/shared/utils/dynamicRoutes.ts @@ -6,6 +6,48 @@ import type { Router, RouteRecordRaw } from 'vue-router' import type { Tool } from '../stores/tools' +/** + * 将 snake_case 转换为 PascalCase + */ +function snakeToPascal(snakeStr: string): string { + const components = snakeStr.split('_') + return components.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('') +} + +/** + * 基于 tool_name 生成 routeName(PascalCase) + */ +function generateRouteName(toolName: string): string { + return snakeToPascal(toolName) +} + +/** + * 基于 tool_name 生成 routePath(约定:tools/{tool_name}) + */ +function generateRoutePath(toolName: string): string { + return `tools/${toolName}` +} + +/** + * 确保工具具有 routeName 和 routePath(如果缺失则自动生成) + */ +function ensureToolRoutes(tool: Tool): Tool { + if (tool.type === 'route') { + // 如果没有 routeName,基于 tool.id 或 tool.name 生成 + if (!tool.routeName) { + const baseName = tool.id || tool.name + tool.routeName = generateRouteName(baseName) + } + + // 如果没有 routePath,基于 tool.id 或 tool.name 生成 + if (!tool.routePath) { + const baseName = tool.id || tool.name + tool.routePath = generateRoutePath(baseName) + } + } + return tool +} + /** * 动态注册工具路由 * @param router Vue Router 实例 @@ -17,24 +59,29 @@ export function registerToolRoute( tool: Tool, componentPath?: string ): boolean { - if (tool.type !== 'route' || !tool.routeName) { + // 确保工具具有 routeName 和 routePath + const toolWithRoutes = ensureToolRoutes({ ...tool }) + + if (toolWithRoutes.type !== 'route' || !toolWithRoutes.routeName) { return false } // 检查路由是否已存在 - if (router.hasRoute(tool.routeName)) { - console.warn(`Route ${tool.routeName} already exists, skipping registration`) + if (router.hasRoute(toolWithRoutes.routeName)) { + console.warn(`Route ${toolWithRoutes.routeName} already exists, skipping registration`) return false } // 确定组件路径和路由路径 - const finalComponentPath = componentPath || tool.componentPath || `../../views/tools/${tool.routeName}.vue` - const routePath = tool.routePath || `tools/${tool.id}` + // 默认路径:tools/{tool_id}/{tool_id}.vue(每个工具独立文件夹,入口文件与工具ID一致) + const defaultComponentPath = toolWithRoutes.componentPath || `../../views/tools/${toolWithRoutes.id}/${toolWithRoutes.id}.vue` + const finalComponentPath = componentPath || defaultComponentPath + const routePath = toolWithRoutes.routePath || `tools/${toolWithRoutes.id}` // 创建路由配置,添加组件加载错误处理 const route: RouteRecordRaw = { path: routePath, - name: tool.routeName, + name: toolWithRoutes.routeName, component: () => import(finalComponentPath).catch((error) => { console.error(`Failed to load tool component: ${finalComponentPath}`, error) // 返回一个简单的错误组件 @@ -45,8 +92,8 @@ export function registerToolRoute( }), meta: { requiresAuth: true, - toolId: tool.id, - toolName: tool.name + toolId: toolWithRoutes.id, + toolName: toolWithRoutes.name } } @@ -54,10 +101,10 @@ export function registerToolRoute( // 将路由添加到 AppLayout 的 children 下 // AppLayout 路由应该在应用启动时已存在 router.addRoute('AppLayout', route) - console.log(`Tool route registered: ${tool.routeName} -> ${routePath}`) + console.log(`Tool route registered: ${toolWithRoutes.routeName} -> ${routePath}`) return true } catch (error) { - console.error(`Failed to register tool route ${tool.routeName}:`, error) + console.error(`Failed to register tool route ${toolWithRoutes.routeName}:`, error) return false } } @@ -88,7 +135,8 @@ export function unregisterToolRoute(router: Router, routeName: string): boolean * @param tools 工具列表 */ export function registerAllToolRoutes(router: Router, tools: Tool[]): void { - const routeTools = tools.filter(t => t.type === 'route' && t.routeName && !t.isDefault) + // 过滤出路由类型的工具(routeName 会在 registerToolRoute 中自动生成) + const routeTools = tools.filter(t => t.type === 'route' && !t.isDefault) routeTools.forEach(tool => { registerToolRoute(router, tool) diff --git a/apps/jingrow/jingrow/api/tools.py b/apps/jingrow/jingrow/api/tools.py index d938a86..2d69d34 100644 --- a/apps/jingrow/jingrow/api/tools.py +++ b/apps/jingrow/jingrow/api/tools.py @@ -6,18 +6,41 @@ Jingrow Tools API 工具相关的 FastAPI 路由 """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Form, UploadFile, File from typing import Dict, Any, Optional import json import requests import logging +import os +import shutil +import uuid +import subprocess +import traceback +from pathlib import Path from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers +from jingrow.utils.path import get_root_path, get_jingrow_root logger = logging.getLogger(__name__) router = APIRouter() +def snake_to_pascal(snake_str: str) -> str: + """将 snake_case 转换为 PascalCase""" + components = snake_str.split('_') + return ''.join(word.capitalize() for word in components) + + +def generate_route_name(tool_name: str) -> str: + """基于 tool_name 生成 routeName(PascalCase)""" + return snake_to_pascal(tool_name) + + +def generate_route_path(tool_name: str) -> str: + """基于 tool_name 生成 routePath(约定:tools/{tool_name})""" + return f"tools/{tool_name}" + + @router.get("/jingrow/tool-marketplace") async def get_tool_marketplace( search: Optional[str] = None, @@ -104,3 +127,218 @@ async def get_tool_detail(name: str): logger.error(f"获取工具详情失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取工具详情失败: {str(e)}") + +@router.post("/jingrow/install-tool-from-file") +async def install_tool_from_file(file: UploadFile = File(...)): + """从上传的文件安装工具(支持ZIP,每个工具包独立)""" + try: + root = get_root_path() + tmp_dir = root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建临时文件 + temp_filename = f"tool_upload_{uuid.uuid4().hex[:8]}.zip" + temp_file_path = tmp_dir / temp_filename + + # 保存上传的文件 + with open(temp_file_path, 'wb') as f: + shutil.copyfileobj(file.file, f) + + # 安装工具 + result = _install_tool_from_file(str(temp_file_path)) + + # 清理临时文件 + if temp_file_path.exists(): + os.remove(temp_file_path) + + return result + + except Exception as e: + logger.error(f"从文件安装工具失败: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"安装工具失败: {str(e)}") + + +@router.post("/jingrow/install-tool-from-url") +async def install_tool_from_url(url: str = Form(...)): + """从URL安装工具""" + try: + root = get_root_path() + tmp_dir = root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建临时文件 + temp_filename = f"tool_download_{uuid.uuid4().hex[:8]}{Path(url).suffix or '.zip'}" + temp_file_path = tmp_dir / temp_filename + + # 下载文件 + response = requests.get(url, stream=True, timeout=300) + response.raise_for_status() + + with open(temp_file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # 安装工具 + result = _install_tool_from_file(str(temp_file_path)) + + # 清理临时文件 + if temp_file_path.exists(): + os.remove(temp_file_path) + + return result + + except Exception as e: + logger.error(f"从URL安装工具失败: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"安装工具失败: {str(e)}") + + +def _install_tool_from_file(file_path: str) -> Dict[str, Any]: + """从文件安装工具(支持ZIP,每个工具包独立)""" + try: + from jingrow.utils.app_installer import extract_package, cleanup_temp_dir + + # 解压文件 + extract_result = extract_package(file_path) + if not extract_result.get('success'): + return extract_result + + temp_dir = extract_result['temp_dir'] + + try: + # 查找工具定义文件 {tool_id}.json + # 工具包结构应该是: + # - {tool_id}.json(必需,在根目录,文件名与工具ID一致) + # - frontend/{tool_id}/{tool_id}.vue(前端组件) + # - backend/{tool_id}/{tool_id}.py(后端文件,可选) + + tool_json_path = None + + # 查找根目录下的 {tool_id}.json 文件 + # 遍历根目录,找到第一个 .json 文件(应该是工具ID命名的) + json_files = [f for f in Path(temp_dir).iterdir() + if f.is_file() and f.suffix == '.json' and not f.name.startswith('.')] + + if json_files: + # 使用第一个找到的 JSON 文件 + tool_json_path = json_files[0] + else: + return {'success': False, 'error': '压缩包中没有找到工具定义 JSON 文件(应为 {tool_id}.json)'} + + # 安装工具(每个包只包含一个工具) + tool_dir = tool_json_path.parent + result = _install_single_tool_directory(str(tool_dir)) + + if result.get('success'): + return { + 'success': True, + 'tool_id': result.get('tool_id'), + 'tool_name': result.get('tool_name'), + 'message': f"工具 {result.get('tool_name')} 安装成功" + } + else: + return result + + finally: + cleanup_temp_dir(temp_dir) + + except Exception as e: + logger.error(f"从文件安装工具失败: {str(e)}") + return {'success': False, 'error': str(e)} + + +def _install_single_tool_directory(tool_dir: str) -> Dict[str, Any]: + """安装单个工具目录(每个工具独立)""" + try: + tool_dir_path = Path(tool_dir) + + # 读取工具定义文件 {tool_name}.json + # 查找 {tool_name}.json 文件 + json_files = [f for f in tool_dir_path.iterdir() + if f.is_file() and f.suffix == '.json' and not f.name.startswith('.')] + if not json_files: + return {'success': False, 'error': '找不到工具定义文件: 应为 {tool_name}.json'} + + tool_json = json_files[0] + + with open(tool_json, 'r', encoding='utf-8') as f: + tool_data = json.load(f) + + if not isinstance(tool_data, dict): + return {'success': False, 'error': '工具定义文件格式错误'} + + tool_name = tool_data.get('tool_name') + if not tool_name: + return {'success': False, 'error': '工具定义中缺少 tool_name'} + + # 验证 JSON 文件名必须与工具名称一致 + json_filename = tool_json.stem # 获取文件名(不含扩展名) + if json_filename != tool_name: + return {'success': False, 'error': f'工具定义文件名 {json_filename}.json 与工具名称 {tool_name} 不一致,必须使用 {tool_name}.json'} + + # 自动生成 routeName 和 routePath(如果未提供) + if tool_data.get('type') == 'route': + if not tool_data.get('routeName'): + tool_data['routeName'] = generate_route_name(tool_name) + if not tool_data.get('routePath'): + tool_data['routePath'] = generate_route_path(tool_name) + + # 将更新后的数据写回 JSON 文件 + with open(tool_json, 'w', encoding='utf-8') as f: + json.dump(tool_data, f, ensure_ascii=False, indent=2) + + # 确定目标目录:apps/jingrow/frontend/src/views/tools/{tool_name} + jingrow_root = get_jingrow_root() + frontend_root = jingrow_root.parent / "frontend" / "src" + tool_frontend_dir = frontend_root / "views" / "tools" / tool_name + tool_frontend_dir.mkdir(parents=True, exist_ok=True) + + # 复制前端组件文件(如果存在) + frontend_source = tool_dir_path / "frontend" + if frontend_source.exists() and frontend_source.is_dir(): + # 如果前端目录下有子目录(如 frontend/{tool_id}/),复制整个子目录 + # 否则直接复制 frontend/ 下的所有文件到工具目录 + subdirs = [d for d in frontend_source.iterdir() if d.is_dir()] + if subdirs: + # 有子目录,复制第一个子目录的内容 + source_subdir = subdirs[0] + if tool_frontend_dir.exists(): + shutil.rmtree(tool_frontend_dir) + shutil.copytree(source_subdir, tool_frontend_dir) + logger.info(f"复制前端组件目录: {source_subdir} -> {tool_frontend_dir}") + else: + # 没有子目录,直接复制所有文件 + if tool_frontend_dir.exists(): + shutil.rmtree(tool_frontend_dir) + tool_frontend_dir.mkdir(parents=True, exist_ok=True) + for item in frontend_source.iterdir(): + if item.is_file(): + shutil.copy2(item, tool_frontend_dir / item.name) + logger.info(f"复制前端组件: {item.name} -> {tool_frontend_dir}") + elif item.is_dir(): + shutil.copytree(item, tool_frontend_dir / item.name) + logger.info(f"复制前端组件目录: {item.name} -> {tool_frontend_dir}") + + # 复制后端文件(如果存在) + backend_source = tool_dir_path / "backend" + if backend_source.exists() and backend_source.is_dir(): + backend_target = jingrow_root / "tools" / tool_name + backend_target.mkdir(parents=True, exist_ok=True) + # 如果目录已存在,先删除 + if backend_target.exists(): + shutil.rmtree(backend_target) + shutil.copytree(backend_source, backend_target) + logger.info(f"复制后端文件: {backend_source} -> {backend_target}") + + return { + 'success': True, + 'tool_name': tool_name, + 'tool_title': tool_data.get('title', tool_name), + 'message': f"工具 {tool_name} 安装成功" + } + + except Exception as e: + logger.error(f"安装工具目录失败: {str(e)}") + return {'success': False, 'error': str(e)} +