diff --git a/.gitignore b/.gitignore index 768ee45..7ccfbfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /conf.yml /conf.json /dist +/instances/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -66,7 +67,6 @@ db.sqlite3 db.sqlite3-journal # Flask stuff: -instance/ .webassets-cache # Scrapy stuff: diff --git a/launcher/config/conf.py b/launcher/config/conf.py index 3f9eff2..a5df264 100644 --- a/launcher/config/conf.py +++ b/launcher/config/conf.py @@ -1,3 +1,5 @@ +from typing import List + from pydantic import BaseModel, model_validator from pydantic_core import PydanticUndefined @@ -7,9 +9,10 @@ class ConfModel(BaseModel): @classmethod def nested_defaults(cls, data): for name, field in cls.model_fields.items(): + expected_type = field.annotation if name not in data: if field.default is PydanticUndefined: - data[name] = field.annotation() + data[name] = expected_type else: data[name] = field.default return data @@ -33,8 +36,28 @@ class UpdatePart(ConfModel): mirror: str = "aliyun" +class LaunchPart(ConfModel): + """启动程序""" + + class Instance(ConfModel): + """实例""" + + # 是否选中 + checked: bool = False + # 实例名 + name: str = "" + # 实例路径 + path: str = "" + + # 实例列表 + instances: List[Instance] = [] + # 是否展示日志窗口 + is_show_log: bool = False + + class Conf( Total, UpdatePart, + LaunchPart, ): pass diff --git a/launcher/constants.py b/launcher/constants.py index 8d9d0d4..3e519e2 100644 --- a/launcher/constants.py +++ b/launcher/constants.py @@ -27,3 +27,6 @@ mirror_list = { "tuna": "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple", "sjtu": "https://mirror.sjtu.edu.cn/pypi/web/simple", } + +# 实例文件夹名 +instances_folder_name = "instances" diff --git a/launcher/file/utils.py b/launcher/file/utils.py index 5ab58dc..b9dd968 100644 --- a/launcher/file/utils.py +++ b/launcher/file/utils.py @@ -20,13 +20,14 @@ def check_command_path(command, cwd=None): """检查命令路径是否存在exe文件 :return 是否存在,命令exe文件全路径 """ - command_path = command.split(" ")[0] - if command_path == "start": + command_name = command.split(" ")[0] + system_command = ["start", "explorer"] + if command_name in system_command: return True, None exec_command_path = os.getcwd() if cwd: exec_command_path = os.path.join(exec_command_path, cwd) full_command_path = ( - os.path.abspath(os.path.join(exec_command_path, command_path)) + ".exe" + os.path.abspath(os.path.join(exec_command_path, command_name)) + ".exe" ) return os.path.exists(full_command_path), full_command_path diff --git a/launcher/instances/__init__.py b/launcher/instances/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/launcher/instances/manager.py b/launcher/instances/manager.py new file mode 100644 index 0000000..f0ff759 --- /dev/null +++ b/launcher/instances/manager.py @@ -0,0 +1,129 @@ +import json +import os +import shutil +import uuid + +from launcher import config +from launcher.config.conf import LaunchPart +from launcher.constants import instances_folder_name +from launcher.webview.events import custom_event, LogType + + +def add_instance(): + instances_path = os.path.join(os.getcwd(), instances_folder_name) + if not os.path.exists(instances_path): + os.makedirs(instances_path) + instance_folder_name = str(uuid.uuid4()) + instance_folder_path = os.path.join(instances_path, instance_folder_name) + if os.path.exists(instance_folder_path): + raise Exception("创建实例目录失败") + os.makedirs(instance_folder_path) + custom_event(LogType.info, f"创建实例目录成功: {instance_folder_path}") + return instance_folder_path + + +def migrate_instance(source_folder): + """通用配置迁移方法 + Args: + source_folder: 源配置目录路径 + target_folder: 目标配置目录路径 + """ + try: + if not os.path.exists(source_folder): + custom_event(LogType.error, f"源配置目录不存在: {source_folder}") + return {"status": False, "message": f"源配置目录不存在{source_folder}"} + + target_folder = add_instance() + + # 需要复制的目录和文件列表 + copy_items = [("tmp", True), ("conf.yml", False), ("plan.json", False)] + + for item, is_dir in copy_items: + src = os.path.join(source_folder, item) + dst = os.path.join(target_folder, item) + + if is_dir: + if os.path.exists(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + if os.path.exists(src): + shutil.copy2(src, dst) + + custom_event(LogType.info, f"{source_folder} 配置成功迁移至 {target_folder}") + return {"status": True, "data": target_folder, "message": "配置迁移成功"} + + except Exception as e: + custom_event(LogType.error, f"配置迁移失败: {str(e)}") + return {"status": False, "message": str(e)} + + +def migrate_instances_config(): + try: + config_path = os.path.join(os.getcwd(), "mower-ng", "instances.json") + + if not os.path.exists(config_path): + custom_event(LogType.error, f"多开配置文件不存在{config_path}") + return {"status": False, "message": "多开配置文件不存在"} + + with open(config_path, "r", encoding="utf-8") as f: + instances_data = json.loads(f.read()) + custom_event(LogType.info, f"读取多开配置: {instances_data}") + + valid_instances = [] + error_messages = [] + + for index, item in enumerate(instances_data, 1): + try: + # 基础结构验证 + if not isinstance(item, dict): + raise ValueError("配置项必须是字典类型") + + # 必填字段检查 + required_fields = ["name", "path"] + for field in required_fields: + if field not in item: + raise ValueError(f"缺少必要字段: {field}") + + # 路径有效性验证 + if not os.path.exists(item["path"]): + raise ValueError(f"配置路径不存在{item['path']}") + + # 执行配置迁移 + migration_result = migrate_instance(item["path"]) + + if not migration_result["status"]: + raise Exception(f"路径迁移失败: {migration_result['message']}") + + # 转换为实例模型 + instance = LaunchPart.Instance( + name=item["name"].strip(), path=migration_result["data"] + ) + + valid_instances.append(instance) + + except Exception as e: + error_msg = f"第{index}项: {str(e)}" + error_messages.append(error_msg) + custom_event(LogType.error, error_msg) + + # 保存有效配置 + if valid_instances: + config.conf.instances.extend(valid_instances) + config.save_conf() + + message = f"成功导入{len(valid_instances)}个实例" + ( + f",存在{len(error_messages)}个错误项" if error_messages else "" + ) + custom_event(LogType.info, message) + return { + "status": not bool(error_messages), + "data": len(valid_instances), + "message": message, + } + + except json.JSONDecodeError as e: + custom_event(LogType.error, f"JSON解析失败: {str(e)}") + return {"status": False, "message": "配置文件格式错误"} + except Exception as e: + custom_event(LogType.error, f"迁移多开配置失败: {str(e)}") + return {"status": False, "message": str(e)} diff --git a/launcher/sys_config/config_dist.json b/launcher/sys_config/config_dist.json index b382c15..1764210 100644 --- a/launcher/sys_config/config_dist.json +++ b/launcher/sys_config/config_dist.json @@ -1,6 +1,6 @@ { "version": "v0.5", "url": "ui/dist/index.html", - "log_level": "ERROR", + "log_level": "INFO", "debug": false } \ No newline at end of file diff --git a/launcher/sys_config/config_local.json b/launcher/sys_config/config_local.json index d74400e..e42e05a 100644 --- a/launcher/sys_config/config_local.json +++ b/launcher/sys_config/config_local.json @@ -1,6 +1,6 @@ { "version": "dev", "url": "http://localhost:5173/", - "log_level": "INFO", + "log_level": "DEBUG", "debug": true } \ No newline at end of file diff --git a/launcher/webview/api.py b/launcher/webview/api.py index 1960b38..b24ab1d 100644 --- a/launcher/webview/api.py +++ b/launcher/webview/api.py @@ -1,6 +1,8 @@ import io import os +import shutil import subprocess +import threading from _winapi import CREATE_NO_WINDOW from pathlib import Path from shutil import rmtree @@ -9,6 +11,7 @@ from subprocess import Popen import requests from launcher import config +from launcher.config.conf import LaunchPart from launcher.constants import ( download_git_url, download_python_url, @@ -16,10 +19,12 @@ from launcher.constants import ( upgrade_script_name, mirror_list, file_name, + instances_folder_name, ) from launcher.file.download import init_download, download_file from launcher.file.extract import extract_7z_file from launcher.file.utils import ensure_directory_exists, check_command_path +from launcher.instances import manager from launcher.log import logger from launcher.sys_config import sys_config from launcher.webview.events import custom_event, LogType @@ -37,8 +42,8 @@ command_list = { "reset": lambda: f"..\\git\\bin\\git -c lfs.concurrenttransfers=200 reset --hard origin/{config.conf.branch}", "pip_tools_install": lambda: f"..\\python\\Scripts\\pip install --no-cache-dir -i {mirror_list[config.conf.mirror]} pip-tools --no-warn-script-location", "pip_sync": lambda: f"..\\python\\Scripts\\pip-sync -i {mirror_list[config.conf.mirror]} requirements.txt", - "webview": "..\\python\\pythonw webview_ui.py", - "manager": "start ..\\python\\pythonw manager.py", + "webview": lambda instance_path="": f'..\\python\\pythonw webview_ui.py "{instance_path}"', + "open_folder": lambda folder_path: f"explorer {folder_path}", } @@ -69,11 +74,11 @@ def check_command_end(command_key, output): class Api: def load_config(self): - logger.info("读取配置文件") + logger.debug("读取配置文件") return config.conf.model_dump() def save_config(self, conf): - logger.info(f"更新配置文件{conf}") + logger.debug(f"更新配置文件{conf}") config.conf = config.Conf(**conf) config.save_conf() @@ -139,10 +144,13 @@ class Api: except Exception as e: return repr(e) - def run(self, command_key, cwd=None): + def run(self, command_key, cwd=None, params={}): command = command_list[command_key] if callable(command): - command = command() + try: + command = command(**params) + except TypeError: + command = command() if callable(command): return "success" if command() else "failed" if cwd is not None: @@ -202,3 +210,45 @@ class Api: logger.exception(e) custom_event(LogType.error, str(e)) return "failed" + + def add_instance(self): + return manager.add_instance() + + def delete_instance(self, path): + instances_dir = os.path.join(os.getcwd(), instances_folder_name) + abs_path = os.path.abspath(path) + if os.path.commonpath( + [abs_path, instances_dir] + ) == instances_dir and os.path.exists(abs_path): + shutil.rmtree(abs_path) + + def start_checked_instance(self): + checked_instances = [ + instance for instance in config.conf.instances if instance.checked + ] + if not checked_instances: + custom_event(LogType.warning, "没有选中的实例") + return [{"status": False, "message": "No checked instances"}] + + def _run_instance(instance: LaunchPart.Instance): + self.run( + "webview", + "mower-ng", + {"instance_path": instance.path}, + ) + + # 创建并启动线程 目前进程依然有阻塞,待优化 + for instance in checked_instances: + thread = threading.Thread( + target=_run_instance, args=(instance,), daemon=True + ) + thread.start() + + def migrate_default_instance(self): + """迁移默认实例文件到新实例""" + source_path = os.path.join(os.getcwd(), "mower-ng") + return manager.migrate_instance(source_path) + + def migrate_instances_config(self): + """迁移多开配置""" + return manager.migrate_instances_config() diff --git a/ui/src/pages/Launch.vue b/ui/src/pages/Launch.vue index c84940c..3c15f37 100644 --- a/ui/src/pages/Launch.vue +++ b/ui/src/pages/Launch.vue @@ -1,34 +1,238 @@ \ No newline at end of file +.instance-list { + overflow: auto; +} +.log { + min-height: 40vh; + max-height: 40vh; + margin-top: auto; +} +.top { + align-items: center; +} +.is_show_log_switch { + margin-right: 20px; +} + diff --git a/ui/src/stores/config.js b/ui/src/stores/config.js index e33269a..80484fe 100644 --- a/ui/src/stores/config.js +++ b/ui/src/stores/config.js @@ -1,6 +1,14 @@ import { defineStore } from 'pinia' export const useConfigStore = defineStore('config', () => { + class Instance { + constructor(instance) { + this.checked = instance.checked + this.name = instance.name + this.path = instance.path + } + } + class Config { constructor(conf) { // 整体 Total @@ -9,21 +17,25 @@ export const useConfigStore = defineStore('config', () => { // 更新代码 UpdatePart this.branch = conf.branch this.mirror = conf.mirror + // 启动程序 LaunchPart + this.instances = [] + this.is_show_log = conf.is_show_log + Object.assign(this, conf) } } - const config = ref({}) + const config = reactive({}) async function load_config() { const conf = await pywebview.api.load_config() - config.value = new Config(conf) - console.log('config.value', config.value) + Object.assign(config, new Config(conf)) + console.log('响应式配置已更新', config) } watch( config, () => { - pywebview.api.save_config(config.value) + pywebview.api.save_config(config) }, { deep: true } )