From 916ec1b69c9b5fdf7b6eaa2806e71ce73a5bf8f3 Mon Sep 17 00:00:00 2001 From: jingrow Date: Sun, 26 Oct 2025 22:05:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=94=E7=94=A8=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E7=95=8C=E9=9D=A2=E6=97=A0=E6=B3=95=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=AE=89=E8=A3=85=E5=8C=85=E5=AE=89=E8=A3=85?= =?UTF-8?q?app=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/views/dev/AppInstaller.vue | 88 +----------- .../jingrow/api/local_app_installer.py | 126 +++++++++++++++--- apps/jingrow/jingrow/utils/app_installer.py | 93 ++++++++++--- 3 files changed, 189 insertions(+), 118 deletions(-) diff --git a/apps/jingrow/frontend/src/views/dev/AppInstaller.vue b/apps/jingrow/frontend/src/views/dev/AppInstaller.vue index 2ce5ee6..725c9af 100644 --- a/apps/jingrow/frontend/src/views/dev/AppInstaller.vue +++ b/apps/jingrow/frontend/src/views/dev/AppInstaller.vue @@ -223,15 +223,9 @@ const beforeUpload = (data: { file: UploadFileInfo }) => { const handleFileChange = (options: { fileList: UploadFileInfo[] }) => { fileList.value = options.fileList + // 移除对扩展包的特殊处理,统一使用应用安装流程 if (fileList.value.length > 0) { - const fileName = fileList.value[0].file?.name || '' - isExtensionFile.value = fileName.toLowerCase().endsWith('.tar.gz') || - fileName.toLowerCase().endsWith('.tgz') || - fileName.toLowerCase().endsWith('.gz') - - if (isExtensionFile.value) { - appName.value = '' - } + isExtensionFile.value = false } } @@ -251,77 +245,7 @@ const startUpload = async () => { return } - const fileName = file.name.toLowerCase() - const isExtensionPackage = fileName.endsWith('.tar.gz') || fileName.endsWith('.tgz') || fileName.endsWith('.gz') - - if (isExtensionPackage) { - try { - uploading.value = true - installing.value = true - showProgressModal.value = true - installProgress.value = 0 - installMessage.value = t('Uploading file...') - installStatus.value = 'info' - - const formData = new FormData() - formData.append('file', file) - - installProgress.value = 20 - installMessage.value = t('Saving package...') - - const saveResponse = await axios.post('/jingrow/install-extension', formData, { - headers: { - ...get_session_api_headers(), - 'Content-Type': 'multipart/form-data' - }, - timeout: 60000 - }) - - if (!saveResponse.data.success) { - throw new Error(saveResponse.data.error || t('Failed to save package')) - } - - const fileUrl = saveResponse.data.file_url - - installProgress.value = 50 - installMessage.value = t('Installing package...') - const installResponse = await axios.post('/api/action/jingrow.ai.utils.jlocal.install_package', { - package_file_url: fileUrl - }, { - headers: get_session_api_headers() - }) - - installProgress.value = 100 - installMessage.value = t('Installation completed!') - installStatus.value = 'success' - - if (installResponse.data.message && installResponse.data.message.success) { - clearFiles() - const result = installResponse.data.message - message.success(t('Package \'{0}\' installed successfully').replace('{0}', result.package_name)) - } else { - throw new Error(installResponse.data.message?.error || t('Installation failed')) - } - - } catch (error: any) { - installProgress.value = 100 - installMessage.value = t('Installation failed!') - installStatus.value = 'error' - - const errorMsg = error.response?.data?.detail || - error.response?.data?.error || - error.message || - t('Upload failed') - - message.error(errorMsg) - } finally { - uploading.value = false - installing.value = false - } - - return - } - + // 统一使用应用安装流程,不再区分扩展包和应用包 const apiEndpoint = '/jingrow/install/upload' try { @@ -334,12 +258,12 @@ const startUpload = async () => { const formData = new FormData() formData.append('file', file) - if (appName.value.trim() && !isExtensionPackage) { + if (appName.value.trim()) { formData.append('app_name', appName.value.trim()) } installProgress.value = 30 - installMessage.value = isExtensionPackage ? t('Installing extension package...') : t('Uploading file...') + installMessage.value = t('Uploading file...') const response = await axios.post(apiEndpoint, formData, { headers: { ...get_session_api_headers(), @@ -360,7 +284,7 @@ const startUpload = async () => { if (response.data.success) { clearFiles() - const resultAppName = isExtensionPackage ? response.data.package_name : response.data.app_name + const resultAppName = response.data.app_name message.success(t('Package \'{0}\' installed successfully').replace('{0}', resultAppName)) } else { throw new Error(response.data.error || t('Installation failed')) diff --git a/apps/jingrow/jingrow/api/local_app_installer.py b/apps/jingrow/jingrow/api/local_app_installer.py index 53ee3cc..0a92ea2 100644 --- a/apps/jingrow/jingrow/api/local_app_installer.py +++ b/apps/jingrow/jingrow/api/local_app_installer.py @@ -8,6 +8,7 @@ from typing import Dict, Any, List, Optional import logging import tempfile import os +import uuid from pathlib import Path import requests import json @@ -82,34 +83,127 @@ async def install_app_from_upload( ): try: - if not file.filename.lower().endswith('.zip'): - raise HTTPException(status_code=400, detail="只支持ZIP格式的安装包") + filename_lower = file.filename.lower() - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') + # 支持zip、tar.gz、tgz、gz格式 + if not (filename_lower.endswith('.zip') or + filename_lower.endswith('.tar.gz') or + filename_lower.endswith('.tgz') or + filename_lower.endswith('.gz')): + raise HTTPException(status_code=400, detail="只支持ZIP、TAR.GZ、TGZ、GZ格式的安装包") + + # 获取项目根目录 + current = Path(__file__).resolve() + root = current.parents[4] # apps/jingrow/jingrow/api/ -> apps/ -> root + tmp_dir = root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建唯一的文件名 + temp_filename = f"upload_{uuid.uuid4().hex[:8]}{Path(filename_lower).suffix}" + temp_file_path = tmp_dir / temp_filename + + # 保存上传的文件到项目tmp目录 + content = await file.read() + log_info(f"文件大小: {len(content)} 字节") + + with open(temp_file_path, 'wb') as f: + f.write(content) + + log_info(f"文件已保存到: {temp_file_path}, 存在: {temp_file_path.exists()}, 大小: {temp_file_path.stat().st_size if temp_file_path.exists() else 0}") + + # 统一使用 install_app 函数处理所有格式 try: - content = await file.read() - temp_file.write(content) - temp_file.close() - - result = install_app(temp_file.name, app_name) + result = install_app(str(temp_file_path), app_name) if result.get('success'): app_name_result = result.get('app_name') + backend_result = result.get('backend_result', {}) + app_dir = backend_result.get('app_dir') + # 对齐扫描安装的执行链 try: - _import_app_package_and_pagetypes(app_name_result, result) - except Exception: - pass + # 1. 添加到 Local Installed Apps PageType + from jingrow.utils.jingrow_api import get_single_pagetype + pagetype_result = get_single_pagetype("Local Installed Apps") + if pagetype_result.get('success'): + config = pagetype_result.get('config', {}) + apps_list = config.get('local_installed_apps', []) + else: + apps_list = [] + + # 检查是否已存在,如果存在则更新,否则添加 + app_exists = False + for app in apps_list: + if app.get('app_name', '') == app_name_result: + app['app_version'] = '1.0.0' + app['git_branch'] = 'main' + app_exists = True + break + + if not app_exists: + apps_list.append({ + 'app_name': app_name_result, + 'app_version': '1.0.0', + 'git_branch': 'main' + }) + + # 更新数据库 + if await _update_local_installed_apps(apps_list): + log_info(f"已更新 Local Installed Apps: {app_name_result}") + + # 2. 调用 sync_app_files 同步文件到数据库(pagetype, package, module) + log_info(f"准备同步应用文件: app_name={app_name_result}, app_dir={app_dir}") + + if app_dir: + # 计算应用的后端目录 + current = Path(__file__).resolve() + root = current.parents[4] + apps_dir = root / "apps" + backend_dir = apps_dir / app_name_result / app_name_result + if not backend_dir.exists(): + backend_dir = apps_dir / app_name_result + + log_info(f"后端目录: {backend_dir}, 存在: {backend_dir.exists()}") + + if backend_dir.exists(): + try: + api_url = f"{Config.jingrow_server_url}/api/action/jingrow.ai.utils.jlocal.sync_app_files" + log_info(f"调用 sync_app_files API: {api_url}, 参数: app_name={app_name_result}, app_path={str(backend_dir)}") + response = requests.post( + api_url, + json={'app_name': app_name_result, 'app_path': str(backend_dir), 'force': True}, + headers=get_jingrow_api_headers(), + timeout=60 + ) + log_info(f"sync_app_files 响应: status={response.status_code}, body={response.text}") + if response.status_code == 200: + log_info(f"已同步应用文件到数据库: {app_name_result}, 响应: {response.json()}") + else: + log_error(f"同步应用文件失败: HTTP {response.status_code}, {response.text}") + except Exception as e: + import traceback + log_error(f"同步应用文件异常: {str(e)}, {traceback.format_exc()}") + else: + log_error(f"后端目录不存在: {backend_dir}") + else: + log_error(f"app_dir 为空,无法同步文件") + + except Exception as e: + log_error(f"更新数据库失败: {str(e)}") return result else: - raise HTTPException(status_code=400, detail=result.get('error')) - + error_msg = result.get('error', '未知错误') + log_error(f"安装失败: {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) finally: + # 清理上传的文件 try: - os.unlink(temp_file.name) - except: - pass + if temp_file_path.exists(): + os.unlink(temp_file_path) + log_info(f"已删除临时文件: {temp_file_path}") + except Exception as e: + log_error(f"删除临时文件失败: {e}") except HTTPException: raise diff --git a/apps/jingrow/jingrow/utils/app_installer.py b/apps/jingrow/jingrow/utils/app_installer.py index 384802e..99fa5b1 100644 --- a/apps/jingrow/jingrow/utils/app_installer.py +++ b/apps/jingrow/jingrow/utils/app_installer.py @@ -8,6 +8,7 @@ import shutil import tempfile import zipfile import tarfile +import uuid from pathlib import Path from typing import Dict, List, Any from datetime import datetime @@ -36,7 +37,6 @@ def get_app_directories(): """获取应用目录路径""" project_root = Path(__file__).resolve().parents[4] apps_dir = project_root / "apps" - apps_dir.mkdir(parents=True, exist_ok=True) return apps_dir, apps_dir @@ -45,7 +45,15 @@ def get_app_directories(): @handle_errors def extract_package(zip_path: str) -> Dict[str, Any]: """解压安装包 - 支持 ZIP 和 TAR.GZ""" - temp_dir = tempfile.mkdtemp(prefix="jingrow_app_install_") + # 获取项目根目录 + project_root = Path(__file__).resolve().parents[4] + tmp_dir = project_root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建唯一临时目录 + temp_dir_name = f"app_install_{uuid.uuid4().hex[:8]}" + temp_dir = tmp_dir / temp_dir_name + temp_dir.mkdir(parents=True, exist_ok=True) # 判断文件类型 if zip_path.endswith('.tar.gz') or zip_path.endswith('.tgz') or zip_path.endswith('.gz'): @@ -57,7 +65,7 @@ def extract_package(zip_path: str) -> Dict[str, Any]: else: return {'success': False, 'error': '不支持的文件格式'} - return {'success': True, 'temp_dir': temp_dir} + return {'success': True, 'temp_dir': str(temp_dir)} @handle_errors @@ -74,7 +82,8 @@ def analyze_package(temp_dir: str) -> Dict[str, Any]: 'has_backend': False, 'has_frontend': False, 'backend_files': [], - 'frontend_files': [] + 'frontend_files': [], + 'root_dir': root_dir # 保存根目录路径 } # 检查配置文件 @@ -98,7 +107,8 @@ def analyze_package(temp_dir: str) -> Dict[str, Any]: for root, dirs, files in os.walk(root_dir): for file in files: rel_path = os.path.relpath(os.path.join(root, file), root_dir) - if file.endswith(('.py', '.txt', '.cfg')): + + if file.endswith(('.py', '.txt', '.cfg', '.json', '.md', '.LICENSE', '.yaml', '.yml')): package_info['backend_files'].append(rel_path) package_info['has_backend'] = True elif file.endswith(('.vue', '.ts', '.js', '.css')): @@ -125,27 +135,49 @@ def install_files(temp_dir: str, app_name: str, package_info: Dict[str, Any], is backend_dir.mkdir(parents=True, exist_ok=True) frontend_dir.mkdir(parents=True, exist_ok=True) + # 获取根目录 + root_dir = package_info.get('root_dir', temp_dir) copied_files = [] # 复制后端文件 if package_info.get('has_backend', False): for file_path in package_info.get('backend_files', []): - src_path = os.path.join(temp_dir, file_path) - dst_path = backend_dir / file_path + # file_path 是相对于 root_dir 的路径 + # 例如:jin/hooks.py + # root_dir 是 tmp/app_install_xxx/jin + # 所以 src_path = tmp/app_install_xxx/jin/jin/hooks.py + src_path = os.path.join(root_dir, file_path) - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - copied_files.append(str(dst_path)) + # dst_path 需要去掉 app_name 前缀 + if file_path.startswith(f"{app_name}/"): + dst_path = backend_dir / file_path[len(app_name) + 1:] + else: + dst_path = backend_dir / file_path + + if os.path.exists(src_path): + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_path, dst_path) + copied_files.append(str(dst_path)) + else: + log_error(f"源文件不存在: {src_path}, root_dir={root_dir}, file_path={file_path}") # 复制前端文件 if package_info.get('has_frontend', False): for file_path in package_info.get('frontend_files', []): - src_path = os.path.join(temp_dir, file_path) - dst_path = frontend_dir / file_path + src_path = os.path.join(root_dir, file_path) - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - copied_files.append(str(dst_path)) + # dst_path 需要去掉 app_name 前缀 + if file_path.startswith(f"{app_name}/"): + dst_path = frontend_dir / file_path[len(app_name) + 1:] + else: + dst_path = frontend_dir / file_path + + if os.path.exists(src_path): + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_path, dst_path) + copied_files.append(str(dst_path)) + else: + log_error(f"源文件不存在: {src_path}, root_dir={root_dir}, file_path={file_path}") return { 'success': True, @@ -245,8 +277,15 @@ def install_app(uploaded_file_path: str, app_name: str = None) -> Dict[str, Any] log_info(f"开始安装应用包: {uploaded_file_path}") # 验证文件 - if not os.path.exists(uploaded_file_path) or not uploaded_file_path.lower().endswith('.zip'): - return {'success': False, 'error': '无效的ZIP文件'} + if not os.path.exists(uploaded_file_path): + return {'success': False, 'error': '文件不存在'} + + filename_lower = uploaded_file_path.lower() + if not (filename_lower.endswith('.zip') or + filename_lower.endswith('.tar.gz') or + filename_lower.endswith('.tgz') or + filename_lower.endswith('.gz')): + return {'success': False, 'error': '不支持的文件格式,支持ZIP、TAR.GZ、TGZ、GZ格式'} # 解压文件 extract_result = extract_package(uploaded_file_path) @@ -259,21 +298,35 @@ def install_app(uploaded_file_path: str, app_name: str = None) -> Dict[str, Any] analyze_result = analyze_package(temp_dir) if not analyze_result.get('success'): cleanup_temp_dir(temp_dir) + log_error(f"分析包失败: {analyze_result.get('error')}") return analyze_result package_info = analyze_result['data'] + log_info(f"包信息: {package_info}") # 确定应用名称 if not app_name: app_name = package_info.get('app_name') if not app_name: cleanup_temp_dir(temp_dir) + log_error(f"无法识别应用名称,包信息: {package_info}") return {'success': False, 'error': '无法识别应用名称'} - # 检查是否已安装 + log_info(f"应用名称: {app_name}") + + # 如果应用已安装,先删除旧版本(允许覆盖安装) if is_app_installed(app_name): - cleanup_temp_dir(temp_dir) - return {'success': False, 'error': f'应用 {app_name} 已安装'} + log_info(f"应用 {app_name} 已存在,将覆盖安装") + apps_dir, _ = get_app_directories() + app_dir = apps_dir / app_name + try: + if app_dir.exists(): + shutil.rmtree(app_dir) + log_info(f"已删除旧版本应用: {app_dir}") + except Exception as e: + cleanup_temp_dir(temp_dir) + log_error(f"删除旧应用失败: {e}") + return {'success': False, 'error': f'删除旧应用失败: {str(e)}'} # 安装后端文件 backend_result = None