Compare commits

..

26 commits
v0.4 ... main

Author SHA1 Message Date
06fab427aa 界面优化
1.启动程序界面字体对齐
2.编辑实例名称时保持行高和实例名称位置不变
3.修改实例名称输入框添加可清除选项
4.打开配置路径按钮移到右侧按钮处
2025-03-22 20:39:56 +08:00
e3c2627fc5 发布v0.7.1版本 2025-03-10 01:00:49 +08:00
e4b93b94b1 新增功能:启动实例后自动打开网页面板 2025-03-10 00:59:58 +08:00
6e235a28c1 功能修复:更新源码时没有指定为远程分支 2025-03-10 00:04:46 +08:00
e3457c1081 发布v0.7.0版本 2025-03-09 22:26:54 +08:00
5576f8de39 新增功能:使用cli控制实例启停、在浏览器中打开网页面板 2025-03-09 21:29:33 +08:00
de2c2af2bd 新增功能:添加zhaozuohong.vip镜像选项 2025-03-08 11:28:22 +08:00
501259b420 发布v0.6.1版本 2025-02-23 21:00:43 +08:00
f5011e0051 功能修复:打开实例文件夹时异常报错 2025-02-23 20:28:31 +08:00
c94d6d5494 功能修复:打包后运行实例时编码异常 2025-02-23 20:09:32 +08:00
4153164c49 发布v0.6版本 2025-02-23 18:11:58 +08:00
dc32d7e53e 新增功能:合并多开器 2025-02-23 18:11:26 +08:00
942d93417b 日志最多保留200行 2025-01-28 16:13:53 +08:00
25f9915fcc ruff格式化代码 2025-01-25 17:49:17 +08:00
cae312a272 新增功能:启动程序和设置页面添加日志窗口 2025-01-25 17:41:01 +08:00
9295d9f2cf 界面优化
1.日志的中文字体改为微软雅黑
2.帮助文档按钮外边距调整到和日志窗左边距一致
3.扩大默认窗口大小,使得PyPI可以初始展示到一行
4.表单label改为垂直居中
5.设置tab页的“新”标签设置垂直居中,并且不能影响菜单栏的高度
2025-01-07 13:29:00 +08:00
0661b6bbc3 发布v0.5版本 2024-12-28 21:05:35 +08:00
8ed883202b 构建:requirements添加pyinstaller 2024-12-28 21:05:28 +08:00
5cdf9d752f 功能修复:修复依赖时异常卡死 2024-12-28 20:15:04 +08:00
478bcd5f43 新增功能:执行命令时根据命令输出返回错误提示信息 2024-12-28 20:14:01 +08:00
60f43d42be 新增功能:界面输出分类并使用不同颜色显示 2024-12-28 11:29:58 +08:00
1c484d3f77 功能修复:在无网络情况下启动器无法使用
在加载配置完成后取消加载中状态,把一些不影响功能使用的操作后置
2024-12-26 08:57:15 +08:00
f22594a260 新增功能:添加帮助文档入口,首次启动启动器时自动打开帮助文档 2024-12-26 08:41:33 +08:00
92ef815d0d 使用pip-tools安装mower-ng的依赖 2024-12-19 08:07:32 +08:00
ffc2ff6808 使用pip-tools管理python依赖 2024-12-15 23:23:13 +08:00
bcbd556e7d 系统配置文件改为json类型,移除yaml的使用 2024-12-15 21:57:17 +08:00
37 changed files with 1134 additions and 181 deletions

2
.gitignore vendored
View file

@ -1,6 +1,7 @@
/conf.yml /conf.yml
/conf.json /conf.json
/dist /dist
/instances/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@ -66,7 +67,6 @@ db.sqlite3
db.sqlite3-journal db.sqlite3-journal
# Flask stuff: # Flask stuff:
instance/
.webassets-cache .webassets-cache
# Scrapy stuff: # Scrapy stuff:

View file

@ -2,14 +2,24 @@
## 开发环境 ## 开发环境
目前只有 `pywebview` 一个依赖。 安装 `pip-tools`
```bash
pip install pip-tools
```
安装依赖
```bash
pip-sync requirements.txt
```
## 打包 ## 打包
前端运行 `npm run build` 生成 `ui/dist`,之后安装 PyInstaller,运行 前端运行 `npm run build` 生成 `ui/dist`,之后安装 PyInstaller,运行
```bash ```bash
pyinstaller -w --add-data "ui/dist:ui/dist" --add-data "launcher/sys_config/config_dist.yaml:launcher/sys_config" launcher.py pyinstaller -w --add-data "ui/dist:ui/dist" --add-data "launcher/sys_config/config_dist.json:launcher/sys_config" launcher.py
``` ```
在dist文件夹生成launcher文件夹 在dist文件夹生成launcher文件夹

View file

@ -9,8 +9,7 @@ mimetypes.add_type("text/html", ".html")
mimetypes.add_type("text/css", ".css") mimetypes.add_type("text/css", ".css")
mimetypes.add_type("application/javascript", ".js") mimetypes.add_type("application/javascript", ".js")
if __name__ == '__main__': if __name__ == "__main__":
# 如果当前路径存在临时文件夹,则删除 # 如果当前路径存在临时文件夹,则删除
if Path(update_tmp_folder).exists(): if Path(update_tmp_folder).exists():
shutil.rmtree(update_tmp_folder) shutil.rmtree(update_tmp_folder)

View file

@ -2,9 +2,6 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import yaml
from yamlcore import CoreLoader
from launcher.config.conf import Conf from launcher.config.conf import Conf
conf_path = Path(os.path.join(os.getcwd(), "conf.json")) conf_path = Path(os.path.join(os.getcwd(), "conf.json"))
@ -22,8 +19,8 @@ def load_conf():
conf = Conf() conf = Conf()
save_conf() save_conf()
return return
with conf_path.open("r", encoding="utf-8") as f: with conf_path.open("r", encoding="utf-8") as file:
data = yaml.load(f, Loader=CoreLoader) data = json.load(file)
if data is None: if data is None:
data = {} data = {}
conf = Conf(**data) conf = Conf(**data)

View file

@ -1,3 +1,5 @@
from typing import List, Literal, get_args, get_origin
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined
@ -7,30 +9,75 @@ class ConfModel(BaseModel):
@classmethod @classmethod
def nested_defaults(cls, data): def nested_defaults(cls, data):
for name, field in cls.model_fields.items(): for name, field in cls.model_fields.items():
expected_type = field.annotation
if name not in data: if name not in data:
if field.default is PydanticUndefined: if field.default is PydanticUndefined:
data[name] = field.annotation() data[name] = expected_type
else: else:
data[name] = field.default data[name] = field.default
value = data[name]
# 检查 Literal 类型并修正
if get_origin(expected_type) is Literal:
valid_literals = get_args(expected_type)
if value not in valid_literals:
# 修正为默认值
data[name] = (
field.default
if field.default is not PydanticUndefined
else None
)
return data return data
class Total(ConfModel): class Total(ConfModel):
"""整体""" """整体"""
# 所在页面 # 所在页面
page: str = "init" page: str = "init"
# 是否已展示帮助文档
is_already_show_doc: bool = False
class UpdatePart(ConfModel): class UpdatePart(ConfModel):
"""更新代码""" """更新代码"""
# mower-ng 代码分支 # mower-ng 代码分支
branch: str = "slow" branch: str = "slow"
# PyPI 仓库镜像 # PyPI 仓库镜像
mirror: str = "aliyun" 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 OtherPart(ConfModel):
"""其他配置"""
# xx.zhaozuohong.vip镜像 (访问xx.zhaozuohong.vip url时,0=原路径 1=在.zhaozuohong.vip前添加-cf前缀)
base_mirror: Literal["0", "1"] = "0"
class Conf( class Conf(
Total, Total,
UpdatePart, UpdatePart,
LaunchPart,
OtherPart,
): ):
pass pass

View file

@ -8,14 +8,21 @@ constants.py
update_tmp_folder = "download_tmp" update_tmp_folder = "download_tmp"
# 更新脚本名 # 更新脚本名
upgrade_script_name = "upgrade.bat" upgrade_script_name = "upgrade.bat"
# 下载新版本压缩包名
file_name = "launcher.7z"
# 获取最新版本发布信息 # 获取最新版本发布信息
get_new_version_url = "https://git-cf.zhaozuohong.vip/api/v1/repos/mower-ng/launcher/releases/latest" get_new_version_url = (
"https://git-cf.zhaozuohong.vip/api/v1/repos/mower-ng/launcher/releases/latest"
)
# 下载地址 # 下载地址
download_git_url = "https://list.zhaozuohong.vip/mower-ng/git.7z" download_git_url = "https://list.zhaozuohong.vip/mower-ng/git.7z"
download_python_url = "https://list.zhaozuohong.vip/mower-ng/python.7z" download_python_url = "https://list.zhaozuohong.vip/mower-ng/python.7z"
# mower-ng git链接
mower_ng_git_url = "https://git-cf.zhaozuohong.vip/mower-ng/mower-ng.git"
# pip镜像地址 # pip镜像地址
mirror_list = { mirror_list = {
"pypi": "https://pypi.org/simple", "pypi": "https://pypi.org/simple",
@ -23,3 +30,18 @@ mirror_list = {
"tuna": "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple", "tuna": "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple",
"sjtu": "https://mirror.sjtu.edu.cn/pypi/web/simple", "sjtu": "https://mirror.sjtu.edu.cn/pypi/web/simple",
} }
# 实例文件夹名
instances_folder_name = "instances"
# cli命令
cli_command = {
"status": "获取mower-ng实例状态",
"launch": "启动mower-ng进程",
"exit": "停止mower-ng进程",
"kill": "强制退出mower-ng进程",
"start": "开始运行调度器",
"stop": "停止运行调度器",
"webui": "在浏览器中打开网页面板",
"log": "通过WebSocket获取日志",
}

View file

@ -5,7 +5,7 @@ import requests
from launcher.file.extract import extract_7z_file from launcher.file.extract import extract_7z_file
from launcher.file.utils import format_size from launcher.file.utils import format_size
from launcher.webview.events import custom_event from launcher.webview.events import custom_event, LogType
def download_file(download_name, download_url, destination_folder): def download_file(download_name, download_url, destination_folder):
@ -21,11 +21,11 @@ def download_file(download_name, download_url, destination_folder):
filename = os.path.basename(download_url) filename = os.path.basename(download_url)
download_path = os.path.join(destination_folder, filename) download_path = os.path.join(destination_folder, filename)
custom_event(f"开始下载: {download_name}") custom_event(LogType.info, f"开始下载: {download_name}")
response = requests.get(download_url, stream=True) response = requests.get(download_url, stream=True)
if response.status_code == 200: if response.status_code == 200:
total_size = int(response.headers.get('content-length', 0)) total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0 downloaded_size = 0
start_time = time.time() start_time = time.time()
last_update_time = time.time() # 记录上次更新时间 last_update_time = time.time() # 记录上次更新时间
@ -43,7 +43,9 @@ def download_file(download_name, download_url, destination_folder):
else: else:
download_speed = 0 download_speed = 0
progress_percent = (downloaded_size / total_size) * 100 if total_size != 0 else 0 progress_percent = (
(downloaded_size / total_size) * 100 if total_size != 0 else 0
)
# 检查是否需要更新进度信息,每1秒更新一次 # 检查是否需要更新进度信息,每1秒更新一次
if current_time - last_update_time >= 1: if current_time - last_update_time >= 1:
@ -53,20 +55,26 @@ def download_file(download_name, download_url, destination_folder):
formatted_speed = format_size(download_speed) + "/s" formatted_speed = format_size(download_speed) + "/s"
custom_event( custom_event(
f"下载进度: {progress_percent:.2f}% ({formatted_downloaded_size}/{formatted_total_size}), 下载速度: {formatted_speed}") LogType.info,
f"下载进度: {progress_percent:.2f}% ({formatted_downloaded_size}/{formatted_total_size}), 下载速度: {formatted_speed}",
)
last_update_time = current_time # 更新上次更新时间 last_update_time = current_time # 更新上次更新时间
end_time = time.time() end_time = time.time()
total_elapsed_time = end_time - start_time total_elapsed_time = end_time - start_time
average_download_speed = downloaded_size / total_elapsed_time if total_elapsed_time != 0 else 0 average_download_speed = (
downloaded_size / total_elapsed_time if total_elapsed_time != 0 else 0
)
# 格式化输出 # 格式化输出
formatted_total_elapsed_time = f"{total_elapsed_time:.2f}" formatted_total_elapsed_time = f"{total_elapsed_time:.2f}"
formatted_average_download_speed = format_size(average_download_speed) + "/s" formatted_average_download_speed = format_size(average_download_speed) + "/s"
custom_event( custom_event(
f"下载完成: {filename}, 耗时: {formatted_total_elapsed_time}, 下载速度: {formatted_average_download_speed}") LogType.info,
f"下载完成: {filename}, 耗时: {formatted_total_elapsed_time}, 下载速度: {formatted_average_download_speed}",
)
else: else:
custom_event(f"下载失败: {response.status_code}") custom_event(LogType.error, f"下载失败: {response.status_code}")
return False return False
return True return True
@ -84,7 +92,7 @@ def init_download(download_name, download_url, destination_folder):
def download(): def download():
target_folder = os.path.join(download_name) target_folder = os.path.join(download_name)
if os.path.exists(target_folder): if os.path.exists(target_folder):
custom_event(f"{download_name} 文件夹已存在,跳过下载") custom_event(LogType.info, f"{download_name} 文件夹已存在,跳过下载")
return True return True
filename = os.path.basename(download_url) filename = os.path.basename(download_url)

View file

@ -5,7 +5,7 @@ from py7zr import py7zr
from py7zr.callbacks import ExtractCallback from py7zr.callbacks import ExtractCallback
from launcher.file.utils import format_size from launcher.file.utils import format_size
from launcher.webview.events import custom_event from launcher.webview.events import custom_event, LogType
class MyExtractCallback(ExtractCallback): class MyExtractCallback(ExtractCallback):
@ -24,7 +24,7 @@ class MyExtractCallback(ExtractCallback):
self.total_size += int(wrote_bytes) self.total_size += int(wrote_bytes)
current_time = time.time() current_time = time.time()
if current_time - self.last_print_time >= 1.0: # 至少每隔1秒输出一次 if current_time - self.last_print_time >= 1.0: # 至少每隔1秒输出一次
custom_event(f"已解压: {format_size(self.total_size)}") custom_event(LogType.info, f"已解压: {format_size(self.total_size)}")
self.last_print_time = current_time self.last_print_time = current_time
def report_postprocess(self): def report_postprocess(self):
@ -37,7 +37,9 @@ class MyExtractCallback(ExtractCallback):
pass pass
def extract_7z_file(file_name, file_path, destination_folder, delete_after_extract=True): def extract_7z_file(
file_name, file_path, destination_folder, delete_after_extract=True
):
""" """
解压7z文件到指定文件夹 解压7z文件到指定文件夹
:param file_name: 7z文件的名称 :param file_name: 7z文件的名称
@ -49,27 +51,28 @@ def extract_7z_file(file_name, file_path, destination_folder, delete_after_extra
if not os.path.exists(destination_folder): if not os.path.exists(destination_folder):
os.makedirs(destination_folder) os.makedirs(destination_folder)
custom_event(f"开始解压文件: {file_name}") custom_event(LogType.info, f"开始解压文件: {file_name}")
try: try:
start_time = time.time() start_time = time.time()
with py7zr.SevenZipFile(file_path, mode='r') as z: with py7zr.SevenZipFile(file_path, mode="r") as z:
callback = MyExtractCallback() callback = MyExtractCallback()
z.extractall(path=destination_folder, callback=callback) z.extractall(path=destination_folder, callback=callback)
end_time = time.time() end_time = time.time()
total_elapsed_time = end_time - start_time total_elapsed_time = end_time - start_time
formatted_total_elapsed_time = f"{total_elapsed_time:.2f}" formatted_total_elapsed_time = f"{total_elapsed_time:.2f}"
custom_event( custom_event(
f"解压完成: {file_name}, 总大小: {format_size(callback.total_size)}, 耗时: {formatted_total_elapsed_time}") LogType.info,
f"解压完成: {file_name}, 总大小: {format_size(callback.total_size)}, 耗时: {formatted_total_elapsed_time}",
)
except Exception as e: except Exception as e:
e.print_exc() custom_event(LogType.error, f"解压失败: {repr(e)}")
custom_event(f"解压失败: {str(e)}")
return False return False
if delete_after_extract: if delete_after_extract:
try: try:
os.remove(file_path) os.remove(file_path)
custom_event(f"删除{file_path}成功") custom_event(LogType.info, f"删除{file_path}成功")
except OSError as e: except OSError as e:
custom_event(f"删除{file_path}失败: {str(e)}") custom_event(LogType.error, f"删除{file_path}失败: {repr(e)}")
return True return True

View file

@ -3,7 +3,7 @@ import os
def format_size(size_bytes): def format_size(size_bytes):
"""格式化文件大小为人类可读的形式""" """格式化文件大小为人类可读的形式"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']: for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024: if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}" return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024 size_bytes /= 1024
@ -14,3 +14,20 @@ def ensure_directory_exists(directory):
"""确保目录存在,如果不存在则创建""" """确保目录存在,如果不存在则创建"""
if not os.path.exists(directory): if not os.path.exists(directory):
os.makedirs(directory) os.makedirs(directory)
def check_command_path(command, cwd=None):
"""检查命令路径是否存在exe文件
:return 是否存在,命令exe文件全路径
"""
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_name)) + ".exe"
)
return os.path.exists(full_command_path), full_command_path

View file

View file

@ -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)}

View file

@ -9,13 +9,13 @@ from launcher.sys_config import sys_config
# 配置日志 # 配置日志
def setup_logger(): def setup_logger():
log_level = sys_config.get('log_level') log_level = sys_config.get("log_level")
logger = logging.getLogger("launcher.log") logger = logging.getLogger("launcher.log")
logger.setLevel(log_level) logger.setLevel(log_level)
# 设置标准输出编码为 UTF-8 # 设置标准输出编码为 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
# 控制台输出 # 控制台输出
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
@ -24,7 +24,7 @@ def setup_logger():
# 文件输出 # 文件输出
file_path = os.path.join(os.getcwd(), "launcher.log") file_path = os.path.join(os.getcwd(), "launcher.log")
file_handler = RotatingFileHandler( file_handler = RotatingFileHandler(
file_path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding='utf-8' file_path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
) )
file_handler.setLevel(logging.INFO) file_handler.setLevel(logging.INFO)

View file

@ -1,19 +1,21 @@
import json
import os import os
import sys import sys
import yaml
class SysConfig: class SysConfig:
""" """
读取系统配置文件 读取系统配置文件
""" """
# 版本 # 版本
version: str version: str
# ui路径 # ui路径
url: str url: str
# 日志输出级别 # 日志输出级别
log_level: str log_level: str
# 是否开启ui调试
debug: bool
def __init__(self): def __init__(self):
self.config = {} self.config = {}
@ -21,32 +23,32 @@ class SysConfig:
self.load_config() self.load_config()
def get_config_path(self): def get_config_path(self):
if getattr(sys, 'frozen', False): if getattr(sys, "frozen", False):
# logger.error("打包配置") # logger.error("打包配置")
# 如果是打包后的可执行文件 # 如果是打包后的可执行文件
base_path = sys._MEIPASS base_path = sys._MEIPASS
config_subdir = 'launcher/sys_config' # 添加子目录 config_subdir = "launcher/sys_config" # 添加子目录
config_filename = 'config_dist.yaml' config_filename = "config_dist.json"
else: else:
# logger.error("本地配置") # logger.error("本地配置")
# 如果是本地开发环境 # 如果是本地开发环境
base_path = os.path.dirname(__file__) base_path = os.path.dirname(__file__)
config_subdir = '' # 本地开发环境不需要子目录 config_subdir = "" # 本地开发环境不需要子目录
config_filename = 'config_local.yaml' config_filename = "config_local.json"
config_path = os.path.join(base_path, config_subdir, config_filename) config_path = os.path.join(base_path, config_subdir, config_filename)
return config_path return config_path
def load_config(self): def load_config(self):
try: try:
with open(self.config_path, 'r', encoding='utf-8') as file: with open(self.config_path, "r", encoding="utf-8") as file:
self.config = yaml.safe_load(file) self.config = json.load(file)
except FileNotFoundError: except FileNotFoundError:
pass pass
# logger.error(f"配置文件未找到: {self.config_path}") # logger.error(f"配置文件未找到: {self.config_path}")
except Exception: except Exception:
pass pass
# logger.error(f"加载配置文件时出错: {str(e)}") # logger.error(f"加载配置文件时出错: {repr(e)}")
def get(self, key): def get(self, key):
return self.config.get(key) return self.config.get(key)

View file

@ -0,0 +1,6 @@
{
"version": "v0.7.1",
"url": "ui/dist/index.html",
"log_level": "INFO",
"debug": false
}

View file

@ -1,3 +0,0 @@
version: "v0.4"
url: "ui/dist/index.html"
log_level: "ERROR"

View file

@ -0,0 +1,6 @@
{
"version": "dev",
"url": "http://localhost:5173/",
"log_level": "DEBUG",
"debug": true
}

View file

@ -1,3 +0,0 @@
version: "dev"
url: "http://localhost:5173/"
log_level: "INFO"

12
launcher/utils.py Normal file
View file

@ -0,0 +1,12 @@
from launcher import config
def build_base_url(url: str) -> str:
"""
构建xx.zhaozuohong.vip如果配置base_mirror是1.zhaozuohong.vip前添加-cf前缀
:param url: 带有xx.zhaozuohong.vip的url字符串
:return: 构建完成的url
"""
if config.conf.base_mirror == "1":
url = url.replace(".zhaozuohong.vip", "-cf.zhaozuohong.vip")
return url

View file

@ -8,6 +8,11 @@ window = None
def start_webview(): def start_webview():
global window global window
window = webview.create_window(f"mower-ng launcher {sys_config.get('version')}", sys_config.get('url'), window = webview.create_window(
js_api=Api()) f"mower-ng launcher {sys_config.get('version')}",
webview.start() sys_config.get("url"),
js_api=Api(),
width=850,
height=600,
)
webview.start(debug=sys_config.get("debug"))

View file

@ -1,67 +1,91 @@
import io import io
import os import os
import shutil
import subprocess import subprocess
import threading import time
from _winapi import CREATE_NO_WINDOW from _winapi import CREATE_NO_WINDOW
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from subprocess import Popen from subprocess import Popen
import chardet
import requests import requests
from launcher import config from launcher import config
from launcher.constants import download_git_url, download_python_url, get_new_version_url, upgrade_script_name, \ from launcher.constants import (
mirror_list download_git_url,
download_python_url,
get_new_version_url,
upgrade_script_name,
mirror_list,
file_name,
instances_folder_name,
mower_ng_git_url,
cli_command,
)
from launcher.file.download import init_download, download_file from launcher.file.download import init_download, download_file
from launcher.file.extract import extract_7z_file from launcher.file.extract import extract_7z_file
from launcher.file.utils import ensure_directory_exists from launcher.file.utils import ensure_directory_exists, check_command_path
from launcher.instances import manager
from launcher.log import logger from launcher.log import logger
from launcher.sys_config import sys_config from launcher.sys_config import sys_config
from launcher.webview.events import custom_event from launcher.utils import build_base_url
from launcher.webview.events import custom_event, LogType
command_list = { command_list = {
"download_git": lambda: init_download("git", download_git_url, os.getcwd()), "download_git": lambda: init_download(
"download_python": lambda: init_download("python", download_python_url, os.getcwd()), "git", build_base_url(download_git_url), os.getcwd()
),
"download_python": lambda: init_download(
"python", build_base_url(download_python_url), os.getcwd()
),
"lfs": "git\\bin\\git lfs install", "lfs": "git\\bin\\git lfs install",
"ensurepip": "python\\python -m ensurepip --default-pip", "ensurepip": "python\\python -m ensurepip --default-pip",
"clone": "git\\bin\\git -c lfs.concurrenttransfers=100 clone https://git-cf.zhaozuohong.vip/mower-ng/mower-ng.git --branch slow", "clone": lambda: f"git\\bin\\git -c lfs.concurrenttransfers=100 clone {build_base_url(mower_ng_git_url)} --branch slow",
"set_remote": lambda: f"..\\git\\bin\\git remote set-url origin {build_base_url(mower_ng_git_url)}",
"set_lfs": lambda: f"..\\git\\bin\\git config lfs.url {build_base_url(mower_ng_git_url)}/info/lfs",
"fetch": lambda: f"..\\git\\bin\\git fetch origin {config.conf.branch} --progress", "fetch": lambda: f"..\\git\\bin\\git fetch origin {config.conf.branch} --progress",
"switch": lambda: f"..\\git\\bin\\git -c lfs.concurrenttransfers=100 switch -f {config.conf.branch} --progress", "switch": lambda: f"..\\git\\bin\\git -c lfs.concurrenttransfers=100 switch -f {config.conf.branch} --progress",
"reset": lambda: f"..\\git\\bin\\git -c lfs.concurrenttransfers=200 reset --hard origin/{config.conf.branch}", "reset": lambda: f"..\\git\\bin\\git -c lfs.concurrenttransfers=200 reset --hard origin/{config.conf.branch}",
"pip_install": lambda: f"..\\python\\Scripts\\pip install --no-cache-dir -i {mirror_list[config.conf.mirror]} -r requirements.txt --no-warn-script-location", "pip_tools_install": lambda: f"..\\python\\Scripts\\pip install --no-cache-dir -i {mirror_list[config.conf.mirror]} pip-tools --no-warn-script-location",
"webview": "start ..\\python\\pythonw webview_ui.py", "pip_sync": lambda: f"..\\python\\Scripts\\pip-sync -i {mirror_list[config.conf.mirror]} requirements.txt",
"manager": "start ..\\python\\pythonw manager.py", "webview": lambda instance_path="": f'..\\python\\pythonw -X utf8 webview_ui.py "{instance_path}"',
"cli": lambda path,
command: f'..\\python\\pythonw -X utf8 cli.py -p "{path}" {command}',
} }
def detect_encoding(stream): def parse_stderr(stderr_output):
# 读取一部分数据来检测编码 error_keywords = {
raw_data = stream.read(4096) "fatal: destination path 'mower-ng' already exists and is not an empty directory.": "mower-ng文件夹已存在并且非空。",
result = chardet.detect(raw_data) "index.lock': File exists": "上一个git命令正在执行,请等待执行结束或在任务管理器中杀掉git进程,并确保上方提示的index.lock文件删除后再次运行。",
stream.seek(0) # 将流指针重置到开头 "Could not resolve host": "网络出现错误,请检查网络是否通畅。",
return result['encoding'] "ReadTimeoutError": "网络连接超时,请检查网络连接或尝试更换镜像源。",
"No space left on device": "磁盘空间不足。",
}
for keyword, message in error_keywords.items():
if keyword in stderr_output:
return message
return "未定义的错误"
def read_stream(stream, log_func): def check_command_end(command_key, output):
detected_encoding = detect_encoding(stream) end_keywords = {"webview": {"WebSocket客户端建立连接": "mower_ng已成功运行"}}
text_io = io.TextIOWrapper(stream, encoding=detected_encoding, errors='replace') if command_key in end_keywords:
try: keywords = end_keywords[command_key]
for line in iter(text_io.readline, ''): for keyword in keywords:
text = line.rstrip('\n') if keyword in output:
custom_event(text.strip() + "\n") custom_event(LogType.info, keywords[keyword])
finally: return True
text_io.close() return False
class Api: class Api:
def load_config(self): def load_config(self):
logger.info("读取配置文件") logger.debug("读取配置文件")
return config.conf.model_dump() return config.conf.model_dump()
def save_config(self, conf): def save_config(self, conf):
logger.info(f"更新配置文件{conf}") logger.debug(f"更新配置文件{conf}")
config.conf = config.Conf(**conf) config.conf = config.Conf(**conf)
config.save_conf() config.save_conf()
@ -70,14 +94,13 @@ class Api:
def get_new_version(self): def get_new_version(self):
logger.info("获取最新版本号") logger.info("获取最新版本号")
response = requests.get(get_new_version_url) response = requests.get(build_base_url(get_new_version_url))
return response.json() return response.json()
# 更新启动器本身 # 更新启动器本身
def update_self(self, download_url): def update_self(self, download_url):
download_url = build_base_url(download_url)
logger.info(f"开始更新启动器 {download_url}") logger.info(f"开始更新启动器 {download_url}")
file_name = os.path.basename(download_url)
file_name = "launcher.7z"
current_path = os.getcwd() current_path = os.getcwd()
download_tmp_folder = os.path.join(current_path, "download_tmp") download_tmp_folder = os.path.join(current_path, "download_tmp")
# 确保 download_tmp 文件夹存在 # 确保 download_tmp 文件夹存在
@ -110,42 +133,175 @@ class Api:
os._exit(0) os._exit(0)
def rm_site_packages(self): def rm_site_packages(self):
try:
site_packages_path = Path("./python/Lib/site-packages") site_packages_path = Path("./python/Lib/site-packages")
if site_packages_path.exists(): if site_packages_path.exists():
rmtree(site_packages_path) rmtree(site_packages_path)
return "site-packages目录移除成功" return "site-packages目录移除成功"
return "python\\Lib\\site-packages目录不存在" return "python\\Lib\\site-packages目录不存在"
except Exception as e:
return repr(e)
def rm_python_scripts(self): def rm_python_scripts(self):
try:
python_scripts_path = Path("./python/Scripts") python_scripts_path = Path("./python/Scripts")
if python_scripts_path.exists(): if python_scripts_path.exists():
rmtree(python_scripts_path) rmtree(python_scripts_path)
return "Scripts目录移除成功" return "Scripts目录移除成功"
return "python\\Scripts目录不存在" return "python\\Scripts目录不存在"
except Exception as e:
return repr(e)
def run(self, command, cwd=None): def run(self, command_key, cwd=None, params={}):
command = command_list[command] command = command_list[command_key]
if callable(command): if callable(command):
try:
command = command(**params)
except TypeError:
command = command() command = command()
if callable(command): if callable(command):
return "success" if command() else "failed" return "success" if command() else "failed"
custom_event(command + "\n") if cwd is not None:
custom_event(LogType.info, f"命令执行目录:{cwd}")
custom_event(LogType.execute_command, command)
# 执行命令前先判断命令路径是否存在
exist, command_path = check_command_path(command, cwd)
if not exist:
custom_event(
LogType.error,
f"命令路径不存在:{command_path} 请尝试依赖修复并重新初始化。",
)
return "failed"
try: try:
stdout_stderr = []
with subprocess.Popen( with subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, cwd=cwd, bufsize=0, command,
universal_newlines=False stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
cwd=cwd,
bufsize=0,
universal_newlines=False,
) as p: ) as p:
stdout_thread = threading.Thread(target=read_stream, args=(p.stdout, logger.info))
stderr_thread = threading.Thread(target=read_stream, args=(p.stderr, logger.info))
stdout_thread.start() def process_lines(text_io):
stderr_thread.start() for line in iter(text_io.readline, ""):
text = line.rstrip("\n").strip()
custom_event(LogType.command_out, text)
if stdout_stderr is not None:
stdout_stderr.append(text)
if check_command_end(command_key, text):
break
detected_encoding = "utf-8"
text_io = io.TextIOWrapper(
p.stdout, encoding=detected_encoding, errors="replace"
)
try:
process_lines(text_io)
except UnicodeDecodeError:
p.stdout.seek(0) # 重新将流指针重置到开头
text_io = io.TextIOWrapper(
p.stdout, encoding="gbk", errors="replace"
)
process_lines(text_io)
finally:
text_io.close()
stdout_thread.join()
stderr_thread.join()
if p.returncode == 0: if p.returncode == 0:
return "success" return "success"
else:
error_message = parse_stderr("\n".join(stdout_stderr))
custom_event(LogType.error, f"命令执行失败,{error_message}")
return "failed"
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
custom_event(str(e)) custom_event(LogType.error, str(e))
return "failed" 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 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()
def open_folder(self, path):
if not os.path.exists(path):
custom_event(LogType.error, f"路径不存在:{path}")
else:
os.startfile(path)
custom_event(LogType.info, f"成功打开文件夹:{path}")
def test_base_url_connect(self):
url = build_base_url(get_new_version_url)
custom_event(LogType.info, f"开始测试URL连接:{url}")
try:
start_time = time.time()
response = requests.get(url)
end_time = time.time()
if response.status_code == 200:
elapsed_time_ms = (end_time - start_time) * 1000
custom_event(
LogType.info, f"测试成功,响应时间为 {elapsed_time_ms:.2f} 毫秒"
)
else:
custom_event(
LogType.error, f"测试失败: HTTP状态码 {response.status_code}"
)
except requests.exceptions.RequestException as e:
custom_event(LogType.error, f"发生错误: {e}")
def cli_control(self, command: str, path: str):
"""
统一的CLI控制接口用于执行指定命令并可选地指定工作目录
:param command_str: 要执行的命令字符串或命令键 "status", "launch"
:param path: 实例路径
"""
if command not in cli_command:
custom_event(
LogType.error,
f"无效的命令字符串或命令键:{command},请检查输入。",
)
return
try:
ret = self.run("cli", "mower-ng", {"command": command, "path": path})
if command == "launch" and ret == "success":
self.run("cli", "mower-ng", {"command": "webui", "path": path})
except Exception as e:
custom_event(LogType.error, f"{cli_command[command]} 失败 {repr(e)}")
def batch_cli_control(self, command):
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"}]
for instance in checked_instances:
try:
custom_event(LogType.info, f"{instance.name} {cli_command[command]}")
self.cli_control(command, instance.path)
custom_event(
LogType.info, f"{instance.name} {cli_command[command]} 完成"
)
except Exception as e:
custom_event(
LogType.error,
f"{instance.name} {cli_command[command]} 失败 {repr(e)}",
)

View file

@ -1,11 +1,32 @@
import json import json
from enum import Enum
import launcher import launcher
from launcher.log import logger from launcher.log import logger
def custom_event(data): class LogType(Enum):
info = "信息"
error = "错误"
execute_command = "执行命令"
command_out = "命令输出"
def custom_event(log_type, data):
data = f"[{log_type.value}] {data}"
match log_type:
case LogType.info:
logger.info(data) logger.info(data)
case LogType.error:
logger.error(data)
case LogType.execute_command:
logger.info(data)
case LogType.command_out:
logger.info(data)
case _:
logger.info(data)
data = json.dumps({"log": data + "\n"}) data = json.dumps({"log": data + "\n"})
js = f"var event = new CustomEvent('log', {{detail: {data}}}); window.dispatchEvent(event);" js = f"var event = new CustomEvent('log', {{detail: {data}}}); window.dispatchEvent(event);"
launcher.webview.window.evaluate_js(js) launcher.webview.window.evaluate_js(js)

5
requirements.in Normal file
View file

@ -0,0 +1,5 @@
pywebview==5.1
requests==2.32.3
py7zr==0.22.0
pydantic==2.10.3
pyinstaller==6.11.1

80
requirements.txt Normal file
View file

@ -0,0 +1,80 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile requirements.in
#
--index-url https://pypi.tuna.tsinghua.edu.cn/simple
altgraph==0.17.4
# via pyinstaller
annotated-types==0.7.0
# via pydantic
bottle==0.13.2
# via pywebview
brotli==1.1.0
# via py7zr
certifi==2024.12.14
# via requests
cffi==1.17.1
# via clr-loader
charset-normalizer==3.4.0
# via requests
clr-loader==0.2.7.post0
# via pythonnet
idna==3.10
# via requests
inflate64==1.0.0
# via py7zr
multivolumefile==0.2.3
# via py7zr
packaging==24.2
# via
# pyinstaller
# pyinstaller-hooks-contrib
pefile==2023.2.7
# via pyinstaller
proxy-tools==0.1.0
# via pywebview
psutil==6.1.0
# via py7zr
py7zr==0.22.0
# via -r requirements.in
pybcj==1.0.2
# via py7zr
pycparser==2.22
# via cffi
pycryptodomex==3.21.0
# via py7zr
pydantic==2.10.3
# via -r requirements.in
pydantic-core==2.27.1
# via pydantic
pyinstaller==6.11.1
# via -r requirements.in
pyinstaller-hooks-contrib==2024.11
# via pyinstaller
pyppmd==1.1.0
# via py7zr
pythonnet==3.0.5
# via pywebview
pywebview==5.1
# via -r requirements.in
pywin32-ctypes==0.2.3
# via pyinstaller
pyzstd==0.16.2
# via py7zr
requests==2.32.3
# via -r requirements.in
texttable==1.7.0
# via py7zr
typing-extensions==4.12.2
# via
# pydantic
# pydantic-core
# pywebview
urllib3==2.2.3
# via requests
# The following packages are considered to be unsafe in a requirements file:
# setuptools

9
ui/package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1",
"pinia": "^2.2.8", "pinia": "^2.2.8",
"vue": "^3.5.11" "vue": "^3.5.11"
}, },
@ -2075,11 +2076,9 @@
} }
}, },
"node_modules/highlight.js": { "node_modules/highlight.js": {
"version": "11.10.0", "version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz", "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
} }

View file

@ -11,6 +11,7 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1",
"pinia": "^2.2.8", "pinia": "^2.2.8",
"vue": "^3.5.11" "vue": "^3.5.11"
}, },

View file

@ -12,6 +12,10 @@ const loading = ref(true)
const page = ref(null) const page = ref(null)
let conf let conf
function show_doc() {
window.open('https://hedgedoc.zhaozuohong.vip/s/LfSzK2n0K', '_blank')
}
async function init_version() { async function init_version() {
version.value = await pywebview.api.get_version() version.value = await pywebview.api.get_version()
new_version.value = await pywebview.api.get_new_version() new_version.value = await pywebview.api.get_new_version()
@ -22,19 +26,27 @@ async function init_version() {
async function initialize_config() { async function initialize_config() {
await configStore.load_config() await configStore.load_config()
conf = configStore.config conf = configStore.config
await init_version()
loading.value = false loading.value = false
if (!conf.is_already_show_doc) {
show_doc()
conf.is_already_show_doc = true
}
await init_version()
} }
const log = ref('') const log = ref([])
provide('log', log) provide('log', log)
const log_ele = ref(null) const log_ele = ref(null)
provide('log_ele', log_ele) provide('log_ele', log_ele)
watch(log, () => { watch(
log,
() => {
nextTick(() => { nextTick(() => {
log_ele.value?.scrollTo({ position: 'bottom' }) log_ele.value?.scrollTo({ position: 'bottom' })
}) })
}) },
{ deep: true }
)
onMounted(() => { onMounted(() => {
if (window.pywebview && pywebview.api) { if (window.pywebview && pywebview.api) {
@ -45,12 +57,15 @@ onMounted(() => {
}) })
} }
window.addEventListener('log', (e) => { window.addEventListener('log', (e) => {
log.value += e.detail.log log.value.push(e.detail.log)
if (log.value.length > 200) {
log.value.shift()
}
}) })
}) })
function set_page(value) { function set_page(value) {
log.value = '' log.value.splice(0)
} }
const running = ref(false) const running = ref(false)
@ -81,6 +96,7 @@ provide('new_version', new_version)
class="container" class="container"
v-model:value="conf.page" v-model:value="conf.page"
@update:value="set_page" @update:value="set_page"
justify-content="center"
> >
<n-tab-pane :disabled="running" name="init" tab="初始化"><init /></n-tab-pane> <n-tab-pane :disabled="running" name="init" tab="初始化"><init /></n-tab-pane>
<n-tab-pane :disabled="running" name="update" tab="更新代码"><update /></n-tab-pane> <n-tab-pane :disabled="running" name="update" tab="更新代码"><update /></n-tab-pane>
@ -88,13 +104,18 @@ provide('new_version', new_version)
<n-tab-pane :disabled="running" name="fix" tab="依赖修复"><fix /></n-tab-pane> <n-tab-pane :disabled="running" name="fix" tab="依赖修复"><fix /></n-tab-pane>
<n-tab-pane :disabled="running" name="settings"> <n-tab-pane :disabled="running" name="settings">
<template #tab> <template #tab>
<n-space :wrap="false"> <div class="tab-content">
设置 <span>设置</span>
<n-tag v-if="update_able" round type="success"></n-tag> <n-tag v-if="update_able" class="tag" round type="success"></n-tag>
</n-space> </div>
</template> </template>
<settings /> <settings />
</n-tab-pane> </n-tab-pane>
<template #suffix>
<div class="suffix-container">
<n-button type="primary" secondary size="small" @click="show_doc">帮助文档</n-button>
</div>
</template>
</n-tabs> </n-tabs>
</n-notification-provider> </n-notification-provider>
<n-global-style /> <n-global-style />
@ -106,4 +127,17 @@ provide('new_version', new_version)
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
.suffix-container {
margin: 0 4px 6px 4px;
}
.tab-content {
position: relative;
}
.tag {
margin-left: 4px;
position: absolute;
top: 50%;
left: 100%;
transform: translateY(-50%);
}
</style> </style>

View file

@ -0,0 +1,30 @@
<script setup>
import { form_item_label_style } from '@/styles/styles.js'
import { useConfigStore } from '@/stores/config.js'
const conf = useConfigStore().config
const running = inject('running')
async function test_connect() {
running.value = true
await pywebview.api.test_base_url_connect()
running.value = false
}
</script>
<template>
<n-form-item label="镜像模式" :label-style="form_item_label_style">
<n-radio-group v-model:value="conf.base_mirror" :disabled="running">
<n-flex>
<n-radio value="0">默认模式</n-radio>
<n-radio value="1">镜像模式-cf后缀</n-radio>
</n-flex>
</n-radio-group>
<n-button strong secondary type="primary" size="small" @click="test_connect" :disabled="running"
>测试连接</n-button
>
</n-form-item>
</template>
<style scoped></style>

View file

@ -11,7 +11,7 @@ const current_state = inject('current_state')
const notification = useNotification() const notification = useNotification()
async function start() { async function start() {
log.value = '' log.value = []
running.value = true running.value = true
for (const [i, step] of steps.value.entries()) { for (const [i, step] of steps.value.entries()) {
current_step.value = i + 1 current_step.value = i + 1

View file

@ -1,14 +1,57 @@
<script setup> <script setup>
import { darkTheme } from 'naive-ui' import { darkTheme } from 'naive-ui'
import hljs from 'highlight.js/lib/core'
const log = inject('log') const log = inject('log')
const log_ele = inject('log_ele') const log_ele = inject('log_ele')
const chinesePattern = {
className: 'chinese',
begin: /[\u4e00-\u9fa5]+/
}
hljs.registerLanguage('naive-log', () => ({
contains: [
{
className: 'info',
begin: /^\[信息\]/,
end: /$/,
returnBegin: true,
returnEnd: true,
contains: [chinesePattern]
},
{
className: 'error',
begin: /^\[错误\]/,
end: /$/,
returnBegin: true,
returnEnd: true,
contains: [chinesePattern]
},
{
className: 'execute_command',
begin: /^\[执行命令\]/,
end: /$/,
returnBegin: true,
returnEnd: true,
contains: [chinesePattern]
},
{
className: 'command_out',
begin: /^\[命令输出\]/,
end: /$/,
returnBegin: true,
returnEnd: true,
contains: [chinesePattern]
}
]
}))
</script> </script>
<template> <template>
<n-config-provider :theme="darkTheme" class="provider"> <n-config-provider :theme="darkTheme" class="provider" :hljs="hljs">
<n-card class="full" content-style="height: 100%"> <n-card class="full" content-style="height: 100%">
<n-log :log="log" class="full" ref="log_ele" /> <n-log :lines="log" class="full selectable-log" ref="log_ele" language="naive-log" />
</n-card> </n-card>
</n-config-provider> </n-config-provider>
</template> </template>
@ -23,10 +66,8 @@ const log_ele = inject('log_ele')
height: 100% !important; height: 100% !important;
box-sizing: border-box; box-sizing: border-box;
} }
</style>
<style> .selectable-log {
pre { user-select: text;
word-break: break-all !important;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
import 'vfonts/Lato.css'
import 'vfonts/FiraCode.css' import 'vfonts/FiraCode.css'
import './styles/global.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'

View file

@ -1,4 +1,6 @@
<script setup> <script setup>
import BaseMirrorOption from '@/components/BaseMirrorOption.vue'
const steps = ref([ const steps = ref([
{ {
title: '下载 git、python', title: '下载 git、python',
@ -22,6 +24,9 @@ provide('current_state', current_state)
<template> <template>
<n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box"> <n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box">
<n-form label-placement="left" :show-feedback="false" label-width="auto" label-align="left">
<base-mirror-option />
</n-form>
<n-alert title="以下步骤仅需运行一次" type="warning" /> <n-alert title="以下步骤仅需运行一次" type="warning" />
<n-steps :current="current_step" :status="current_state" size="small"> <n-steps :current="current_step" :status="current_state" size="small">
<n-step v-for="step in steps" :title="step.title" /> <n-step v-for="step in steps" :title="step.title" />

View file

@ -1,37 +1,285 @@
<script setup> <script setup>
function webview() { import { useConfigStore } from '@/stores/config.js'
pywebview.api.run('webview', 'mower-ng') import { NButton } from 'naive-ui'
import {
Add,
Pencil,
Play,
Folder,
TrashOutline,
Archive,
Browsers,
Stop,
Search
} from '@vicons/ionicons5'
const notification = useNotification()
const config_store = useConfigStore()
const conf = config_store.config
const instance_name_input_refs = ref([])
const update_instance_name_index = ref(null)
const migrate_options = [
{
label: '迁移单开配置',
key: 'default'
},
{
label: '迁移多开配置',
key: 'instances'
}
]
const check_all = computed(
() => conf.instances.length > 0 && conf.instances.every((item) => item.checked)
)
const check_part = computed(() => !check_all.value && conf.instances.some((item) => item.checked))
function click_check_all(ckecked) {
if (ckecked) {
conf.instances.forEach((item) => {
item.checked = true
})
} else {
conf.instances.forEach((item) => {
item.checked = false
})
}
}
function setInstanceNameInputRef(el, index) {
if (el) {
instance_name_input_refs.value[index] = el
}
}
async function add_instance() {
const instance_path = await pywebview.api.add_instance()
conf.instances.push({
checked: false,
name: '新实例',
path: instance_path
})
}
async function delete_instance(index, path) {
await pywebview.api.delete_instance(path)
conf.instances.splice(index, 1)
} }
function manager() { function start_update_instance_name(index) {
pywebview.api.run('manager', 'mower-ng') update_instance_name_index.value = index
nextTick(() => {
instance_name_input_refs.value[index]?.focus()
})
}
function end_update_instance_name() {
update_instance_name_index.value = null
}
function open_folder(path) {
pywebview.api.open_folder(path)
}
function cli(command, instance) {
pywebview.api.cli_control(command, instance.path)
}
function batch_cli_control(command) {
pywebview.api.batch_cli_control(command)
}
async function handle_migrate(key) {
if (key == 'default') {
const response = await pywebview.api.migrate_default_instance()
console.log(response)
if (response.status) {
conf.instances.push({
checked: false,
name: '默认实例',
path: response.data
})
notification['success']({
content: '信息',
meta: response.message,
duration: 3000
})
} else {
notification['error']({
content: '错误',
meta: response.message,
duration: 3000
})
}
} else {
const response = await pywebview.api.migrate_instances_config()
console.log('多开配置内容:', response)
if (response.data) {
await config_store.load_config()
conf.instances = [...config_store.config.instances]
}
if (response.status) {
notification['info']({
title: '信息',
content: response.message,
duration: 3000
})
} else {
notification['error']({
title: '错误',
content: response.message,
duration: 3000
})
}
}
} }
</script> </script>
<template> <template>
<n-flex <n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box">
vertical <n-space class="top" justify="space-between">
style=" <n-space>
gap: 16px; <n-button class="launch-btn" type="primary" secondary @click="add_instance">
height: 100%; <template #icon>
padding: 16px; <n-icon :component="Add"></n-icon>
box-sizing: border-box; </template>
justify-content: center; 添加实例
align-items: center; </n-button>
" <n-button
class="launch-btn"
type="primary"
secondary
@click="batch_cli_control('launch')"
:disabled="!check_all && !check_part"
> >
<n-button class="launch-btn" type="primary" secondary size="large" @click="webview"> <template #icon>
单开运行 <n-icon :component="Play"></n-icon>
</template>
启动所选实例
</n-button> </n-button>
<n-button class="launch-btn" type="primary" secondary size="large" @click="manager"> <n-button
多开器 class="launch-btn"
type="error"
secondary
@click="batch_cli_control('exit')"
:disabled="!check_all && !check_part"
>
<template #icon>
<n-icon :component="Stop"></n-icon>
</template>
停止所选实例
</n-button> </n-button>
<n-dropdown trigger="click" :options="migrate_options" @select="handle_migrate">
<n-button class="launch-btn" type="primary" secondary>
<template #icon>
<n-icon :component="Archive"></n-icon>
</template>
迁移配置
</n-button>
</n-dropdown>
</n-space>
<div class="is_show_log_switch">
<n-switch v-model:value="conf.is_show_log" />
显示日志
</div>
</n-space>
<n-list class="instance_list" bordered>
<n-list-item class="instance_list_item">
<template #prefix>
<n-checkbox
v-model:checked="check_all"
:indeterminate="check_part"
style="white-space: nowrap"
@update:checked="click_check_all"
>全选</n-checkbox
>
</template>
</n-list-item>
<n-list-item class="instance_list_item" v-for="(item, index) in conf.instances" :key="index">
<template #prefix>
<n-checkbox v-model:checked="item.checked"></n-checkbox>
</template>
<n-space vertical>
<n-space v-if="update_instance_name_index != index">
<n-text class="instance_name">{{ item.name }}</n-text>
<n-button size="tiny" @click="start_update_instance_name(index)">
<template #icon>
<n-icon :component="Pencil"></n-icon>
</template>
</n-button>
</n-space>
<n-input
v-else
class="instance_name_input"
:ref="(el) => setInstanceNameInputRef(el, index)"
v-model:value="item.name"
@blur="end_update_instance_name"
clearable
></n-input>
</n-space>
<template #suffix>
<n-space :wrap="false">
<n-button type="primary" ghost size="small" @click="cli('status', item)">
<template #icon>
<n-icon :component="Search"></n-icon>
</template>
</n-button>
<n-button type="primary" size="small" @click="cli('launch', item)">
<template #icon>
<n-icon :component="Play"></n-icon>
</template>
</n-button>
<n-button type="primary" ghost size="small" @click="cli('webui', item)">
<template #icon>
<n-icon :component="Browsers"></n-icon>
</template>
</n-button>
<n-button type="error" ghost size="small" @click="cli('exit', item)">
<template #icon>
<n-icon :component="Stop"></n-icon>
</template>
</n-button>
<n-button type="primary" ghost size="small" @click="open_folder(item.path)">
<template #icon>
<n-icon :component="Folder"></n-icon>
</template>
</n-button>
<n-popconfirm @positive-click="delete_instance(index, item.path)">
<template #trigger>
<n-button type="error" ghost size="small">
<template #icon>
<n-icon :component="TrashOutline"></n-icon>
</template>
</n-button>
</template>
删除操作将导致实例配置丢失请谨慎操作
</n-popconfirm>
</n-space>
</template>
</n-list-item>
</n-list>
<log-component v-show="conf.is_show_log" class="log" />
</n-flex> </n-flex>
</template> </template>
<style scoped> <style scoped>
.launch-btn { .launch-btn {
width: 120px; height: 38px;
height: 48px; }
.instance_list {
overflow: auto;
}
.instance_list_item {
height: 50px;
}
.instance_name {
margin-left: 12px;
}
.instance_name_input {
height: 35px;
}
.log {
min-height: 40vh;
max-height: 40vh;
margin-top: auto;
}
.top {
align-items: center;
}
.is_show_log_switch {
margin-right: 20px;
text-align: center;
} }
</style> </style>

View file

@ -1,5 +1,7 @@
<script setup> <script setup>
import { SyncCircle } from '@vicons/ionicons5' import { Sync } from '@vicons/ionicons5'
import { form_item_label_style } from '@/styles/styles.js'
import BaseMirrorOption from '@/components/BaseMirrorOption.vue'
const notification = useNotification() const notification = useNotification()
@ -57,17 +59,20 @@ async function check_update() {
<template> <template>
<n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box"> <n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box">
<n-form label-placement="left" :show-feedback="false" label-width="auto" label-align="left"> <n-form label-placement="left" :show-feedback="false" label-width="auto" label-align="left">
<n-form-item label="版本"> <base-mirror-option />
<n-form-item label="版本" :label-style="form_item_label_style">
<n-space align="center"> <n-space align="center">
{{ version }} {{ version }}
<n-button <n-button
type="success" type="success"
secondary
size="small"
:loading="check_running" :loading="check_running"
:disabled="running" :disabled="running"
@click="check_update" @click="check_update"
> >
<template #icon> <template #icon>
<n-icon :component="SyncCircle"></n-icon> <n-icon :component="Sync"></n-icon>
</template> </template>
检查更新 检查更新
</n-button> </n-button>
@ -76,11 +81,14 @@ async function check_update() {
<n-alert style="margin: 8px 0" type="success" v-if="update_able"> <n-alert style="margin: 8px 0" type="success" v-if="update_able">
<template #header> <template #header>
最新版本{{ `${new_version.tag_name} ${new_version.name}` }} 最新版本{{ `${new_version.tag_name} ${new_version.name}` }}
<n-button style="float: right" @click="open_new_version_html">了解此版本</n-button> <n-button type="success" secondary style="float: right" @click="open_new_version_html">
了解此版本
</n-button>
</template> </template>
<n-space> <n-space>
<n-button <n-button
type="success" type="success"
secondary
:loading="update_self_running" :loading="update_self_running"
:disabled="running" :disabled="running"
@click="update_self" @click="update_self"
@ -90,5 +98,6 @@ async function check_update() {
</n-space> </n-space>
</n-alert> </n-alert>
</n-form> </n-form>
<log-component />
</n-flex> </n-flex>
</template> </template>

View file

@ -1,19 +1,22 @@
<script setup> <script setup>
import { useConfigStore } from '@/stores/config.js' import { useConfigStore } from '@/stores/config.js'
import { form_item_label_style } from '@/styles/styles.js'
import BaseMirrorOption from '@/components/BaseMirrorOption.vue'
const conf = useConfigStore().config const conf = useConfigStore().config
const branch = ref(null) const branch = ref(null)
const mirror = ref(null) const mirror = ref(null)
const running = inject('running')
const steps = computed(() => [ const steps = computed(() => [
{ {
title: '更新源码', title: '更新源码',
command: ['fetch', 'switch', 'reset'], command: ['set_remote', 'set_lfs', 'fetch', 'switch', 'reset'],
cwd: 'mower-ng' cwd: 'mower-ng'
}, },
{ {
title: '安装依赖', title: '安装依赖',
command: ['pip_install'], command: ['pip_tools_install', 'pip_sync'],
cwd: 'mower-ng' cwd: 'mower-ng'
} }
]) ])
@ -27,16 +30,17 @@ provide('current_state', current_state)
<template> <template>
<n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box"> <n-flex vertical style="gap: 16px; height: 100%; padding: 16px; box-sizing: border-box">
<n-form label-placement="left" :show-feedback="false" label-width="auto" label-align="left"> <n-form label-placement="left" :show-feedback="false" label-width="auto" label-align="left">
<n-form-item label="mower-ng 代码分支"> <base-mirror-option />
<n-radio-group v-model:value="conf.branch"> <n-form-item label="mower-ng 代码分支" :label-style="form_item_label_style">
<n-radio-group v-model:value="conf.branch" :disabled="running">
<n-flex> <n-flex>
<n-radio value="fast">测试版</n-radio> <n-radio value="fast">测试版</n-radio>
<n-radio value="slow">稳定版</n-radio> <n-radio value="slow">稳定版</n-radio>
</n-flex> </n-flex>
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
<n-form-item label="PyPI 仓库镜像"> <n-form-item label="PyPI 仓库镜像" :label-style="form_item_label_style">
<n-radio-group v-model:value="conf.mirror"> <n-radio-group v-model:value="conf.mirror" :disabled="running">
<n-flex> <n-flex>
<n-radio value="pypi">PyPI</n-radio> <n-radio value="pypi">PyPI</n-radio>
<n-radio value="aliyun">阿里云镜像站</n-radio> <n-radio value="aliyun">阿里云镜像站</n-radio>

View file

@ -1,26 +1,43 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {
class Config { class Instance {
constructor(conf) { constructor(instance) {
this.page = conf.page this.checked = instance.checked
this.branch = conf.branch this.name = instance.name
this.mirror = conf.mirror this.path = instance.path
} }
} }
const config = ref({}) class Config {
constructor(conf) {
// 整体 Total
this.page = conf.page
this.is_already_show_doc = conf.is_already_show_doc
// 更新代码 UpdatePart
this.branch = conf.branch
this.mirror = conf.mirror
// 启动程序 LaunchPart
this.instances = []
this.is_show_log = conf.is_show_log
// 其他部分 OtherPart
this.base_mirror = conf.base_mirror
Object.assign(this, conf)
}
}
const config = reactive({})
async function load_config() { async function load_config() {
const conf = await pywebview.api.load_config() const conf = await pywebview.api.load_config()
config.value = new Config(conf) Object.assign(config, new Config(conf))
console.log('config.value', config.value) console.log('响应式配置已更新', config)
} }
watch( watch(
config, config,
() => { () => {
pywebview.api.save_config(config.value) pywebview.api.save_config(config)
}, },
{ deep: true } { deep: true }
) )

43
ui/src/styles/global.css Normal file
View file

@ -0,0 +1,43 @@
/* src/assets/styles/global.css */
/* 日志样式 */
.n-code pre {
word-break: break-all !important;
}
.n-code pre .hljs-info {
color: #33ff33;
}
.n-code pre .hljs-error {
color: #ff0000;
}
.n-code pre .hljs-execute_command {
color: #edaf1f;
}
.n-code pre .hljs-command_out {
color: #ffffff;
}
.n-code pre .hljs-chinese {
font-family: '微软雅黑', sans-serif;
}
/* 确保 chinese 类在所有父级类下生效 */
.n-code pre .hljs-info .hljs-chinese {
color: #33ff33;
}
.n-code pre .hljs-error .hljs-chinese {
color: #ff0000;
}
.n-code pre .hljs-execute_command .hljs-chinese {
color: #edaf1f;
}
.n-code pre .hljs-command_out .hljs-chinese {
color: #ffffff;
}

3
ui/src/styles/styles.js Normal file
View file

@ -0,0 +1,3 @@
export const form_item_label_style = {
alignSelf: 'center'
}