Compare commits

..

3 Commits

Author SHA1 Message Date
Zrief
e516d1d866 脚本管理中,计划表中的关卡显示
更新了 ScriptTable.vue,能按计划配置显示关卡信息(支持周模式和全局模式),还加了获取展示计划中已配置关卡的逻辑,Scripts.vue 也整合了计划数据加载,让用计划表设置关卡的用户看得更清楚。
2025-10-03 00:03:14 +08:00
Zrief
f4bcb73fe9 计划表改进、添加计划数据协调器和计划名称工具类
引入了usePlanDataCoordinator组件式函数(composable),用于实现计划数据的统一管理与同步。
添加了planNameUtils工具类,用于生成唯一计划名称并进行有效性校验。
重构了plan/index.vue和MaaPlanTable.vue两个页面组件,使其适配上述工具;同时优化了计划切换、名称编辑及自定义阶段管理的功能。
2025-10-02 22:44:17 +08:00
DLmaster361
993524c4dd fix(Broadcas): 添加ws消息队列调试日志 2025-10-02 00:45:59 +08:00
39 changed files with 3761 additions and 5634 deletions

View File

@@ -29,7 +29,7 @@ from fastapi import APIRouter, Body
from app.core import Config
from app.services import System, Notify
from app.models.schema import *
from app.models.config import Webhook as WebhookConfig
import uuid
router = APIRouter(prefix="/api/setting", tags=["全局设置"])
@@ -100,99 +100,169 @@ async def test_notify() -> OutBase:
@router.post(
"/webhook/get",
summary="查询 webhook 配置",
response_model=WebhookGetOut,
"/webhook/create",
summary="创建自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
async def create_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""创建自定义Webhook"""
try:
index, data = await Config.get_webhook(None, None, webhook.webhookId)
index = [WebhookIndexItem(**_) for _ in index]
data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
# 生成唯一ID
webhook_id = str(uuid.uuid4())
# 创建webhook配置
webhook_config = {
"id": webhook_id,
"name": webhook_data.get("name", ""),
"url": webhook_data.get("url", ""),
"template": webhook_data.get("template", ""),
"enabled": webhook_data.get("enabled", True),
"headers": webhook_data.get("headers", {}),
"method": webhook_data.get("method", "POST"),
}
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 添加新webhook
custom_webhooks.append(webhook_config)
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message=f"Webhook '{webhook_config['name']}' 创建成功")
except Exception as e:
return WebhookGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return WebhookGetOut(index=index, data=data)
@router.post(
"/webhook/add",
summary="添加定时项",
response_model=WebhookCreateOut,
"/webhook/update",
summary="更新自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def add_webhook() -> WebhookCreateOut:
uid, config = await Config.add_webhook(None, None)
data = Webhook(**(await config.toDict()))
return WebhookCreateOut(webhookId=str(uid), data=data)
@router.post(
"/webhook/update", summary="更新定时项", response_model=OutBase, status_code=200
)
async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
async def update_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""更新自定义Webhook"""
try:
await Config.update_webhook(
None, None, webhook.webhookId, webhook.data.model_dump(exclude_unset=True)
)
webhook_id = webhook_data.get("id")
if not webhook_id:
return OutBase(code=400, status="error", message="缺少Webhook ID")
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 查找并更新webhook
updated = False
for i, webhook in enumerate(custom_webhooks):
if webhook.get("id") == webhook_id:
custom_webhooks[i].update(
{
"name": webhook_data.get("name", webhook.get("name", "")),
"url": webhook_data.get("url", webhook.get("url", "")),
"template": webhook_data.get(
"template", webhook.get("template", "")
),
"enabled": webhook_data.get(
"enabled", webhook.get("enabled", True)
),
"headers": webhook_data.get(
"headers", webhook.get("headers", {})
),
"method": webhook_data.get(
"method", webhook.get("method", "POST")
),
}
)
updated = True
break
if not updated:
return OutBase(code=404, status="error", message="Webhook不存在")
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message="Webhook更新成功")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/delete", summary="删除定时项", response_model=OutBase, status_code=200
"/webhook/delete",
summary="删除自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
async def delete_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""删除自定义Webhook"""
try:
await Config.del_webhook(None, None, webhook.webhookId)
webhook_id = webhook_data.get("id")
if not webhook_id:
return OutBase(code=400, status="error", message="缺少Webhook ID")
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 查找并删除webhook
original_length = len(custom_webhooks)
custom_webhooks = [w for w in custom_webhooks if w.get("id") != webhook_id]
if len(custom_webhooks) == original_length:
return OutBase(code=404, status="error", message="Webhook不存在")
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message="Webhook删除成功")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/order", summary="重新排序定时项", response_model=OutBase, status_code=200
"/webhook/test",
summary="测试自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_webhook(None, None, webhook.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/test", summary="测试Webhook配置", response_model=OutBase, status_code=200
)
async def test_webhook(webhook: WebhookTestIn = Body(...)) -> OutBase:
async def test_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""测试自定义Webhook"""
try:
webhook_config = WebhookConfig()
await webhook_config.load(webhook.data.model_dump())
await Notify.WebhookPush(
webhook_config = {
"name": webhook_data.get("name", "测试Webhook"),
"url": webhook_data.get("url", ""),
"template": webhook_data.get("template", ""),
"enabled": True,
"headers": webhook_data.get("headers", {}),
"method": webhook_data.get("method", "POST"),
}
await Notify.CustomWebhookPush(
"AUTO-MAS Webhook测试",
"这是一条测试消息如果您收到此消息说明Webhook配置正确",
webhook_config,
)
return OutBase(message="Webhook测试成功")
except Exception as e:
return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")
return OutBase()

View File

@@ -45,6 +45,7 @@ class _Broadcast:
async def put(self, item):
"""向所有订阅者广播消息"""
logger.debug(f"向所有订阅者广播消息: {item}")
for subscriber in self.__subscribers:
await subscriber.put(deepcopy(item))

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,10 @@ class _TaskManager:
actual_id = None
else:
for script_id, script in Config.ScriptConfig.items():
if actual_id in script.UserData:
if (
isinstance(script, (MaaConfig | GeneralConfig))
and actual_id in script.UserData
):
task_id = script_id
break
else:
@@ -139,26 +142,31 @@ class _TaskManager:
# 初始化任务列表
if task_id in Config.QueueConfig:
queue = Config.QueueConfig[task_id]
if not isinstance(queue, QueueConfig):
return
task_list = []
for queue_item in Config.QueueConfig[task_id].QueueItem.values():
for queue_item in queue.QueueItem.values():
if queue_item.get("Info", "ScriptId") == "-":
continue
script_uid = uuid.UUID(queue_item.get("Info", "ScriptId"))
script_id = uuid.UUID(queue_item.get("Info", "ScriptId"))
script = Config.ScriptConfig[script_id]
if not isinstance(script, (MaaConfig | GeneralConfig)):
logger.error(f"不支持的脚本类型: {type(script).__name__}")
continue
task_list.append(
{
"script_id": str(script_uid),
"script_id": str(script_id),
"status": "等待",
"name": Config.ScriptConfig[script_uid].get("Info", "Name"),
"name": script.get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in Config.ScriptConfig[
script_uid
].UserData.items()
for user_id, config in script.UserData.items()
if config.get("Info", "Status")
and config.get("Info", "RemainedDay") != 0
],
@@ -167,20 +175,23 @@ class _TaskManager:
elif actual_id is not None and actual_id in Config.ScriptConfig:
script = Config.ScriptConfig[actual_id]
if not isinstance(script, (MaaConfig | GeneralConfig)):
logger.error(f"不支持的脚本类型: {type(script).__name__}")
return
task_list = [
{
"script_id": str(actual_id),
"status": "等待",
"name": Config.ScriptConfig[actual_id].get("Info", "Name"),
"name": script.get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in Config.ScriptConfig[
actual_id
].UserData.items()
for user_id, config in script.UserData.items()
if config.get("Info", "Status")
and config.get("Info", "RemainedDay") != 0
],

View File

@@ -81,7 +81,9 @@ class _MainTimer:
for uid, queue in Config.QueueConfig.items():
if not queue.get("Info", "TimeEnabled"):
if not isinstance(queue, QueueConfig) or not queue.get(
"Info", "TimeEnabled"
):
continue
# 避免重复调起任务

View File

@@ -25,10 +25,9 @@ import json
import uuid
import win32com.client
from copy import deepcopy
from urllib.parse import urlparse
from datetime import datetime
from pathlib import Path
from typing import List, Any, Dict, Union, Optional, TypeVar, Generic, Type
from typing import List, Any, Dict, Union, Optional
from app.utils import dpapi_encrypt, dpapi_decrypt
@@ -128,25 +127,17 @@ class DateTimeValidator(ConfigValidator):
class JSONValidator(ConfigValidator):
def __init__(self, tpye: type[dict] | type[list] = dict) -> None:
self.type = tpye
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
try:
data = json.loads(value)
if isinstance(data, self.type):
return True
else:
return False
json.loads(value)
return True
except json.JSONDecodeError:
return False
def correct(self, value: Any) -> str:
return (
value if self.validate(value) else ("{ }" if self.type == dict else "[ ]")
)
return value if self.validate(value) else "{ }"
class EncryptValidator(ConfigValidator):
@@ -255,67 +246,6 @@ class UserNameValidator(ConfigValidator):
return value
class URLValidator(ConfigValidator):
"""URL格式验证器"""
def __init__(
self,
schemes: list[str] | None = None,
require_netloc: bool = True,
default: str = "",
):
"""
:param schemes: 允许的协议列表, 若为 None 则允许任意协议
:param require_netloc: 是否要求必须包含网络位置, 如域名或IP
"""
self.schemes = [s.lower() for s in schemes] if schemes else None
self.require_netloc = require_netloc
self.default = default
def validate(self, value: Any) -> bool:
if value == self.default:
return True
if not isinstance(value, str):
return False
try:
parsed = urlparse(value)
except Exception:
return False
# 检查协议
if self.schemes is not None:
if not parsed.scheme or parsed.scheme.lower() not in self.schemes:
return False
else:
# 不限制协议仍要求有 scheme
if not parsed.scheme:
return False
# 检查是否包含网络位置
if self.require_netloc and not parsed.netloc:
return False
return True
def correct(self, value: Any) -> str:
if self.validate(value):
return value
if isinstance(value, str):
# 简单尝试:若看起来像域名,加上 https://
stripped = value.strip()
if stripped and not stripped.startswith(("http://", "https://")):
candidate = f"https://{stripped}"
if self.validate(candidate):
return candidate
return self.default
class ConfigItem:
"""配置项"""
@@ -607,10 +537,7 @@ class ConfigBase:
await item.unlock()
T = TypeVar("T", bound="ConfigBase")
class MultipleConfig(Generic[T]):
class MultipleConfig:
"""
多配置项管理类
@@ -623,7 +550,7 @@ class MultipleConfig(Generic[T]):
子配置项的类型列表, 必须是 ConfigBase 的子类
"""
def __init__(self, sub_config_type: List[Type[T]]):
def __init__(self, sub_config_type: List[type]):
if not sub_config_type:
raise ValueError("子配置项类型列表不能为空")
@@ -634,13 +561,13 @@ class MultipleConfig(Generic[T]):
f"配置类型 {config_type.__name__} 必须是 ConfigBase 的子类"
)
self.sub_config_type: List[Type[T]] = sub_config_type
self.file: Path | None = None
self.sub_config_type = sub_config_type
self.file: None | Path = None
self.order: List[uuid.UUID] = []
self.data: Dict[uuid.UUID, T] = {}
self.data: Dict[uuid.UUID, ConfigBase] = {}
self.is_locked = False
def __getitem__(self, key: uuid.UUID) -> T:
def __getitem__(self, key: uuid.UUID) -> ConfigBase:
"""允许通过 config[uuid] 访问配置项"""
if key not in self.data:
raise KeyError(f"配置项 '{key}' 不存在")
@@ -738,9 +665,7 @@ class MultipleConfig(Generic[T]):
if self.file:
await self.save()
async def toDict(
self, ignore_multi_config: bool = False, if_decrypt: bool = True
) -> Dict[str, Union[list, dict]]:
async def toDict(self) -> Dict[str, Union[list, dict]]:
"""
将配置项转换为字典
@@ -753,7 +678,7 @@ class MultipleConfig(Generic[T]):
]
}
for uid, config in self.items():
data[str(uid)] = await config.toDict(ignore_multi_config, if_decrypt)
data[str(uid)] = await config.toDict()
return data
async def get(self, uid: uuid.UUID) -> Dict[str, Union[list, dict]]:
@@ -796,7 +721,7 @@ class MultipleConfig(Generic[T]):
encoding="utf-8",
)
async def add(self, config_type: Type[T]) -> tuple[uuid.UUID, T]:
async def add(self, config_type: type) -> tuple[uuid.UUID, ConfigBase]:
"""
添加一个新的配置项

View File

@@ -25,7 +25,6 @@ __author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .ConfigBase import *
from .config import *
from .schema import *
__all__ = ["ConfigBase", "config", "schema"]
__all__ = ["ConfigBase", "schema"]

View File

@@ -1,569 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from pathlib import Path
from datetime import datetime, timedelta
from .ConfigBase import *
class Webhook(ConfigBase):
"""Webhook 配置"""
Info_Name = ConfigItem("Info", "Name", "")
Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
Data_Url = ConfigItem("Data", "Url", "", URLValidator())
Data_Template = ConfigItem("Data", "Template", "")
Data_Headers = ConfigItem("Data", "Headers", "{ }", JSONValidator())
Data_Method = ConfigItem(
"Data", "Method", "POST", OptionsValidator(["POST", "GET"])
)
class GlobalConfig(ConfigBase):
"""全局配置"""
Function_HistoryRetentionTime = ConfigItem(
"Function",
"HistoryRetentionTime",
0,
OptionsValidator([7, 15, 30, 60, 90, 180, 365, 0]),
)
Function_IfAllowSleep = ConfigItem(
"Function", "IfAllowSleep", False, BoolValidator()
)
Function_IfSilence = ConfigItem("Function", "IfSilence", False, BoolValidator())
Function_BossKey = ConfigItem("Function", "BossKey", "")
Function_IfAgreeBilibili = ConfigItem(
"Function", "IfAgreeBilibili", False, BoolValidator()
)
Function_IfSkipMumuSplashAds = ConfigItem(
"Function", "IfSkipMumuSplashAds", False, BoolValidator()
)
Voice_Enabled = ConfigItem("Voice", "Enabled", False, BoolValidator())
Voice_Type = ConfigItem(
"Voice", "Type", "simple", OptionsValidator(["simple", "noisy"])
)
Start_IfSelfStart = ConfigItem("Start", "IfSelfStart", False, BoolValidator())
Start_IfMinimizeDirectly = ConfigItem(
"Start", "IfMinimizeDirectly", False, BoolValidator()
)
UI_IfShowTray = ConfigItem("UI", "IfShowTray", False, BoolValidator())
UI_IfToTray = ConfigItem("UI", "IfToTray", False, BoolValidator())
Notify_SendTaskResultTime = ConfigItem(
"Notify",
"SendTaskResultTime",
"不推送",
OptionsValidator(["不推送", "任何时刻", "仅失败时"]),
)
Notify_IfSendStatistic = ConfigItem(
"Notify", "IfSendStatistic", False, BoolValidator()
)
Notify_IfSendSixStar = ConfigItem("Notify", "IfSendSixStar", False, BoolValidator())
Notify_IfPushPlyer = ConfigItem("Notify", "IfPushPlyer", False, BoolValidator())
Notify_IfSendMail = ConfigItem("Notify", "IfSendMail", False, BoolValidator())
Notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "")
Notify_AuthorizationCode = ConfigItem(
"Notify", "AuthorizationCode", "", EncryptValidator()
)
Notify_FromAddress = ConfigItem("Notify", "FromAddress", "")
Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
Notify_IfServerChan = ConfigItem("Notify", "IfServerChan", False, BoolValidator())
Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
Notify_CustomWebhooks = MultipleConfig([Webhook])
Update_IfAutoUpdate = ConfigItem("Update", "IfAutoUpdate", False, BoolValidator())
Update_Source = ConfigItem(
"Update",
"Source",
"GitHub",
OptionsValidator(["GitHub", "MirrorChyan", "AutoSite"]),
)
Update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "")
Update_MirrorChyanCDK = ConfigItem(
"Update", "MirrorChyanCDK", "", EncryptValidator()
)
Data_UID = ConfigItem("Data", "UID", str(uuid.uuid4()), UUIDValidator())
Data_LastStatisticsUpload = ConfigItem(
"Data",
"LastStatisticsUpload",
"2000-01-01 00:00:00",
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
)
Data_LastStageUpdated = ConfigItem(
"Data",
"LastStageUpdated",
"2000-01-01 00:00:00",
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
)
Data_StageTimeStamp = ConfigItem(
"Data",
"StageTimeStamp",
"2000-01-01 00:00:00",
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
)
Data_Stage = ConfigItem("Data", "Stage", "{ }", JSONValidator())
Data_LastNoticeUpdated = ConfigItem(
"Data",
"LastNoticeUpdated",
"2000-01-01 00:00:00",
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
)
Data_IfShowNotice = ConfigItem("Data", "IfShowNotice", True, BoolValidator())
Data_Notice = ConfigItem("Data", "Notice", "{ }", JSONValidator())
Data_LastWebConfigUpdated = ConfigItem(
"Data",
"LastWebConfigUpdated",
"2000-01-01 00:00:00",
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
)
Data_WebConfig = ConfigItem("Data", "WebConfig", "{ }", JSONValidator())
class QueueItem(ConfigBase):
"""队列项配置"""
related_config: dict[str, MultipleConfig] = {}
def __init__(self) -> None:
super().__init__()
self.Info_ScriptId = ConfigItem(
"Info",
"ScriptId",
"-",
MultipleUIDValidator("-", self.related_config, "ScriptConfig"),
)
class TimeSet(ConfigBase):
"""时间设置配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Enabled = ConfigItem("Info", "Enabled", False, BoolValidator())
self.Info_Time = ConfigItem("Info", "Time", "00:00", DateTimeValidator("%H:%M"))
class QueueConfig(ConfigBase):
"""队列配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新队列")
self.Info_TimeEnabled = ConfigItem(
"Info", "TimeEnabled", False, BoolValidator()
)
self.Info_StartUpEnabled = ConfigItem(
"Info", "StartUpEnabled", False, BoolValidator()
)
self.Info_AfterAccomplish = ConfigItem(
"Info",
"AfterAccomplish",
"NoAction",
OptionsValidator(
[
"NoAction",
"KillSelf",
"Sleep",
"Hibernate",
"Shutdown",
"ShutdownForce",
]
),
)
self.Data_LastTimedStart = ConfigItem(
"Data",
"LastTimedStart",
"2000-01-01 00:00",
DateTimeValidator("%Y-%m-%d %H:%M"),
)
self.TimeSet = MultipleConfig([TimeSet])
self.QueueItem = MultipleConfig([QueueItem])
class MaaUserConfig(ConfigBase):
"""MAA用户配置"""
related_config: dict[str, MultipleConfig] = {}
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
self.Info_Id = ConfigItem("Info", "Id", "")
self.Info_Mode = ConfigItem(
"Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
)
self.Info_StageMode = ConfigItem(
"Info",
"StageMode",
"Fixed",
MultipleUIDValidator("Fixed", self.related_config, "PlanConfig"),
)
self.Info_Server = ConfigItem(
"Info",
"Server",
"Official",
OptionsValidator(
["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
),
)
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
self.Info_RemainedDay = ConfigItem(
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
)
self.Info_Annihilation = ConfigItem(
"Info",
"Annihilation",
"Annihilation",
OptionsValidator(
[
"Close",
"Annihilation",
"Chernobog@Annihilation",
"LungmenOutskirts@Annihilation",
"LungmenDowntown@Annihilation",
]
),
)
self.Info_Routine = ConfigItem("Info", "Routine", True, BoolValidator())
self.Info_InfrastMode = ConfigItem(
"Info",
"InfrastMode",
"Normal",
OptionsValidator(["Normal", "Rotation", "Custom"]),
)
self.Info_InfrastPath = ConfigItem(
"Info", "InfrastPath", str(Path.cwd()), FileValidator()
)
self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
self.Info_Notes = ConfigItem("Info", "Notes", "")
self.Info_MedicineNumb = ConfigItem(
"Info", "MedicineNumb", 0, RangeValidator(0, 9999)
)
self.Info_SeriesNumb = ConfigItem(
"Info",
"SeriesNumb",
"0",
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
)
self.Info_Stage = ConfigItem("Info", "Stage", "-")
self.Info_Stage_1 = ConfigItem("Info", "Stage_1", "-")
self.Info_Stage_2 = ConfigItem("Info", "Stage_2", "-")
self.Info_Stage_3 = ConfigItem("Info", "Stage_3", "-")
self.Info_Stage_Remain = ConfigItem("Info", "Stage_Remain", "-")
self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator())
self.Info_SklandToken = ConfigItem(
"Info", "SklandToken", "", EncryptValidator()
)
self.Data_LastProxyDate = ConfigItem(
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
)
self.Data_LastAnnihilationDate = ConfigItem(
"Data", "LastAnnihilationDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
)
self.Data_LastSklandDate = ConfigItem(
"Data", "LastSklandDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
)
self.Data_ProxyTimes = ConfigItem(
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
)
self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
self.Data_CustomInfrastPlanIndex = ConfigItem(
"Data", "CustomInfrastPlanIndex", "0"
)
self.Task_IfWakeUp = ConfigItem("Task", "IfWakeUp", True, BoolValidator())
self.Task_IfRecruiting = ConfigItem(
"Task", "IfRecruiting", True, BoolValidator()
)
self.Task_IfBase = ConfigItem("Task", "IfBase", True, BoolValidator())
self.Task_IfCombat = ConfigItem("Task", "IfCombat", True, BoolValidator())
self.Task_IfMall = ConfigItem("Task", "IfMall", True, BoolValidator())
self.Task_IfMission = ConfigItem("Task", "IfMission", True, BoolValidator())
self.Task_IfAutoRoguelike = ConfigItem(
"Task", "IfAutoRoguelike", False, BoolValidator()
)
self.Task_IfReclamation = ConfigItem(
"Task", "IfReclamation", False, BoolValidator()
)
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
self.Notify_IfSendStatistic = ConfigItem(
"Notify", "IfSendStatistic", False, BoolValidator()
)
self.Notify_IfSendSixStar = ConfigItem(
"Notify", "IfSendSixStar", False, BoolValidator()
)
self.Notify_IfSendMail = ConfigItem(
"Notify", "IfSendMail", False, BoolValidator()
)
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
self.Notify_IfServerChan = ConfigItem(
"Notify", "IfServerChan", False, BoolValidator()
)
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
class MaaConfig(ConfigBase):
"""MAA配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 脚本")
self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
self.Run_TaskTransitionMethod = ConfigItem(
"Run",
"TaskTransitionMethod",
"ExitEmulator",
OptionsValidator(["NoAction", "ExitGame", "ExitEmulator"]),
)
self.Run_ProxyTimesLimit = ConfigItem(
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
)
self.Run_ADBSearchRange = ConfigItem(
"Run", "ADBSearchRange", 0, RangeValidator(0, 3)
)
self.Run_RunTimesLimit = ConfigItem(
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
)
self.Run_AnnihilationTimeLimit = ConfigItem(
"Run", "AnnihilationTimeLimit", 40, RangeValidator(1, 9999)
)
self.Run_RoutineTimeLimit = ConfigItem(
"Run", "RoutineTimeLimit", 10, RangeValidator(1, 9999)
)
self.Run_AnnihilationWeeklyLimit = ConfigItem(
"Run", "AnnihilationWeeklyLimit", True, BoolValidator()
)
self.UserData = MultipleConfig([MaaUserConfig])
class MaaPlanConfig(ConfigBase):
"""MAA计划表配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 计划表")
self.Info_Mode = ConfigItem(
"Info", "Mode", "ALL", OptionsValidator(["ALL", "Weekly"])
)
self.config_item_dict: dict[str, Dict[str, ConfigItem]] = {}
for group in [
"ALL",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]:
self.config_item_dict[group] = {}
self.config_item_dict[group]["MedicineNumb"] = ConfigItem(
group, "MedicineNumb", 0, RangeValidator(0, 9999)
)
self.config_item_dict[group]["SeriesNumb"] = ConfigItem(
group,
"SeriesNumb",
"0",
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
)
self.config_item_dict[group]["Stage"] = ConfigItem(group, "Stage", "-")
self.config_item_dict[group]["Stage_1"] = ConfigItem(group, "Stage_1", "-")
self.config_item_dict[group]["Stage_2"] = ConfigItem(group, "Stage_2", "-")
self.config_item_dict[group]["Stage_3"] = ConfigItem(group, "Stage_3", "-")
self.config_item_dict[group]["Stage_Remain"] = ConfigItem(
group, "Stage_Remain", "-"
)
for name in [
"MedicineNumb",
"SeriesNumb",
"Stage",
"Stage_1",
"Stage_2",
"Stage_3",
"Stage_Remain",
]:
setattr(self, f"{group}_{name}", self.config_item_dict[group][name])
def get_current_info(self, name: str) -> ConfigItem:
"""获取当前的计划表配置项"""
if self.get("Info", "Mode") == "ALL":
return self.config_item_dict["ALL"][name]
elif self.get("Info", "Mode") == "Weekly":
dt = datetime.now()
if dt.time() < datetime.min.time().replace(hour=4):
dt = dt - timedelta(days=1)
today = dt.strftime("%A")
if today in self.config_item_dict:
return self.config_item_dict[today][name]
else:
return self.config_item_dict["ALL"][name]
else:
raise ValueError("非法的计划表模式")
class GeneralUserConfig(ConfigBase):
"""通用脚本用户配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
self.Info_RemainedDay = ConfigItem(
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
)
self.Info_IfScriptBeforeTask = ConfigItem(
"Info", "IfScriptBeforeTask", False, BoolValidator()
)
self.Info_ScriptBeforeTask = ConfigItem(
"Info", "ScriptBeforeTask", str(Path.cwd()), FileValidator()
)
self.Info_IfScriptAfterTask = ConfigItem(
"Info", "IfScriptAfterTask", False, BoolValidator()
)
self.Info_ScriptAfterTask = ConfigItem(
"Info", "ScriptAfterTask", str(Path.cwd()), FileValidator()
)
self.Info_Notes = ConfigItem("Info", "Notes", "")
self.Data_LastProxyDate = ConfigItem(
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
)
self.Data_ProxyTimes = ConfigItem(
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
)
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
self.Notify_IfSendStatistic = ConfigItem(
"Notify", "IfSendStatistic", False, BoolValidator()
)
self.Notify_IfSendMail = ConfigItem(
"Notify", "IfSendMail", False, BoolValidator()
)
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
self.Notify_IfServerChan = ConfigItem(
"Notify", "IfServerChan", False, BoolValidator()
)
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
class GeneralConfig(ConfigBase):
"""通用配置"""
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新通用脚本")
self.Info_RootPath = ConfigItem(
"Info", "RootPath", str(Path.cwd()), FileValidator()
)
self.Script_ScriptPath = ConfigItem(
"Script", "ScriptPath", str(Path.cwd()), FileValidator()
)
self.Script_Arguments = ConfigItem("Script", "Arguments", "")
self.Script_IfTrackProcess = ConfigItem(
"Script", "IfTrackProcess", False, BoolValidator()
)
self.Script_ConfigPath = ConfigItem(
"Script", "ConfigPath", str(Path.cwd()), FileValidator()
)
self.Script_ConfigPathMode = ConfigItem(
"Script", "ConfigPathMode", "File", OptionsValidator(["File", "Folder"])
)
self.Script_UpdateConfigMode = ConfigItem(
"Script",
"UpdateConfigMode",
"Never",
OptionsValidator(["Never", "Success", "Failure", "Always"]),
)
self.Script_LogPath = ConfigItem(
"Script", "LogPath", str(Path.cwd()), FileValidator()
)
self.Script_LogPathFormat = ConfigItem("Script", "LogPathFormat", "%Y-%m-%d")
self.Script_LogTimeStart = ConfigItem(
"Script", "LogTimeStart", 1, RangeValidator(1, 9999)
)
self.Script_LogTimeEnd = ConfigItem(
"Script", "LogTimeEnd", 1, RangeValidator(1, 9999)
)
self.Script_LogTimeFormat = ConfigItem(
"Script", "LogTimeFormat", "%Y-%m-%d %H:%M:%S"
)
self.Script_SuccessLog = ConfigItem("Script", "SuccessLog", "")
self.Script_ErrorLog = ConfigItem("Script", "ErrorLog", "")
self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator())
self.Game_Type = ConfigItem(
"Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client"])
)
self.Game_Path = ConfigItem("Game", "Path", str(Path.cwd()), FileValidator())
self.Game_Arguments = ConfigItem("Game", "Arguments", "")
self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999))
self.Game_IfForceClose = ConfigItem(
"Game", "IfForceClose", False, BoolValidator()
)
self.Run_ProxyTimesLimit = ConfigItem(
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
)
self.Run_RunTimesLimit = ConfigItem(
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
)
self.Run_RunTimeLimit = ConfigItem(
"Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
)
self.UserData = MultipleConfig([GeneralUserConfig])
CLASS_BOOK = {"MAA": MaaConfig, "MaaPlan": MaaPlanConfig, "General": GeneralConfig}
"""配置类映射表"""

View File

@@ -75,30 +75,6 @@ class GetStageIn(BaseModel):
)
class WebhookIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["Webhook"] = Field(..., description="配置类型")
class Webhook_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="Webhook名称")
Enabled: Optional[bool] = Field(default=None, description="是否启用")
class Webhook_Data(BaseModel):
Url: Optional[str] = Field(default=None, description="Webhook URL")
Template: Optional[str] = Field(default=None, description="消息模板")
Headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
Method: Optional[Literal["POST", "GET"]] = Field(
default=None, description="请求方法"
)
class Webhook(BaseModel):
Info: Optional[Webhook_Info] = Field(default=None, description="Webhook基础信息")
Data: Optional[Webhook_Data] = Field(default=None, description="Webhook配置数据")
class GlobalConfig_Function(BaseModel):
HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
None, description="历史记录保留时间, 0表示永久保存"
@@ -135,6 +111,18 @@ class GlobalConfig_UI(BaseModel):
IfToTray: Optional[bool] = Field(default=None, description="是否最小化到托盘")
class CustomWebhook(BaseModel):
id: str = Field(..., description="Webhook唯一标识")
name: str = Field(..., description="Webhook名称")
url: str = Field(..., description="Webhook URL")
template: str = Field(..., description="消息模板")
enabled: bool = Field(default=True, description="是否启用")
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
method: Optional[Literal["POST", "GET"]] = Field(
default="POST", description="请求方法"
)
class GlobalConfig_Notify(BaseModel):
SendTaskResultTime: Optional[Literal["不推送", "任何时刻", "仅失败时"]] = Field(
default=None, description="任务结果推送时机"
@@ -155,6 +143,9 @@ class GlobalConfig_Notify(BaseModel):
default=None, description="是否使用ServerChan推送"
)
ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥")
CustomWebhooks: Optional[List[CustomWebhook]] = Field(
default=None, description="自定义Webhook列表"
)
class GlobalConfig_Update(BaseModel):
@@ -322,6 +313,9 @@ class MaaUserConfig_Notify(BaseModel):
default=None, description="是否使用Server酱推送"
)
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
CustomWebhooks: Optional[List[CustomWebhook]] = Field(
default=None, description="用户自定义Webhook列表"
)
class GeneralUserConfig_Notify(BaseModel):
@@ -624,50 +618,6 @@ class UserSetIn(UserInBase):
jsonFile: str = Field(..., description="JSON文件路径, 用于导入自定义基建文件")
class WebhookInBase(BaseModel):
scriptId: Optional[str] = Field(
default=None, description="所属脚本ID, 获取全局设置的Webhook数据时无需携带"
)
userId: Optional[str] = Field(
default=None, description="所属用户ID, 获取全局设置的Webhook数据时无需携带"
)
class WebhookGetIn(WebhookInBase):
webhookId: Optional[str] = Field(
default=None, description="Webhook ID, 未携带时表示获取所有Webhook数据"
)
class WebhookGetOut(OutBase):
index: List[WebhookIndexItem] = Field(..., description="Webhook索引列表")
data: Dict[str, Webhook] = Field(
..., description="Webhook数据字典, key来自于index列表的uid"
)
class WebhookCreateOut(OutBase):
webhookId: str = Field(..., description="新创建的Webhook ID")
data: Webhook = Field(..., description="Webhook配置数据")
class WebhookUpdateIn(WebhookInBase):
webhookId: str = Field(..., description="Webhook ID")
data: Webhook = Field(..., description="Webhook更新数据")
class WebhookDeleteIn(WebhookInBase):
webhookId: str = Field(..., description="Webhook ID")
class WebhookReorderIn(WebhookInBase):
indexList: List[str] = Field(..., description="Webhook ID列表, 按新顺序排列")
class WebhookTestIn(WebhookInBase):
data: Webhook = Field(..., description="Webhook配置数据")
class PlanCreateIn(BaseModel):
type: Literal["MaaPlan"]

View File

@@ -21,20 +21,17 @@
import re
import json
import smtplib
import requests
from datetime import datetime
from plyer import notification
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
from typing import Literal
from plyer import notification
from app.core import Config
from app.models.config import Webhook
from app.utils import get_logger, ImageUtils
logger = get_logger("通知服务")
@@ -42,76 +39,71 @@ logger = get_logger("通知服务")
class Notification:
async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> None:
def __init__(self):
super().__init__()
async def push_plyer(self, title, message, ticker, t) -> bool:
"""
推送系统通知
Parameters
----------
title: str
通知标题
message: str
通知内容
ticker: str
通知横幅
t: int
通知持续时间
:param title: 通知标题
:param message: 通知内容
:param ticker: 通知横幅
:param t: 通知持续时间
:return: bool
"""
if not Config.get("Notify", "IfPushPlyer"):
return
if Config.get("Notify", "IfPushPlyer"):
logger.info(f"推送系统通知: {title}")
logger.info(f"推送系统通知: {title}")
if notification.notify is not None:
notification.notify(
title=title,
message=message,
app_name="AUTO-MAS",
app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(),
timeout=t,
ticker=ticker,
toast=True,
)
else:
logger.error("plyer.notification 未正确导入, 无法推送系统通知")
if notification.notify is not None:
notification.notify(
title=title,
message=message,
app_name="AUTO-MAS",
app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(),
timeout=t,
ticker=ticker,
toast=True,
)
else:
logger.error("plyer.notification 未正确导入, 无法推送系统通知")
async def send_mail(
self, mode: Literal["文本", "网页"], title: str, content: str, to_address: str
) -> None:
return True
async def send_mail(self, mode, title, content, to_address) -> None:
"""
推送邮件通知
Parameters
----------
mode: Literal["文本", "网页"]
邮件内容模式, 支持 "文本""网页"
title: str
邮件标题
content: str
邮件内容
to_address: str
收件人地址
:param mode: 邮件内容模式, 支持 "文本""网页"
:param title: 邮件标题
:param content: 邮件内容
:param to_address: 收件人地址
"""
if Config.get("Notify", "SMTPServerAddress") == "":
raise ValueError("邮件通知的SMTP服务器地址不能为空")
if Config.get("Notify", "AuthorizationCode") == "":
raise ValueError("邮件通知的授权码不能为空")
if not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get("Notify", "FromAddress"),
if (
Config.get("Notify", "SMTPServerAddress") == ""
or Config.get("Notify", "AuthorizationCode") == ""
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get("Notify", "FromAddress"),
)
)
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
to_address,
)
)
):
raise ValueError("邮件通知的发送邮箱格式错误或为空")
if not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
to_address,
logger.error(
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址"
)
raise ValueError(
"邮件通知的SMTP服务器地址、授权码、发件人地址或收件人地址未正确配置"
)
):
raise ValueError("邮件通知的接收邮箱格式错误或为空")
# 定义邮件正文
if mode == "文本":
@@ -143,21 +135,16 @@ class Notification:
smtpObj.quit()
logger.success(f"邮件发送成功: {title}")
async def ServerChanPush(self, title: str, content: str, send_key: str) -> None:
async def ServerChanPush(self, title, content, send_key) -> None:
"""
使用Server酱推送通知
Parameters
----------
title: str
通知标题
content: str
通知内容
send_key: str
Server酱的SendKey
:param title: 通知标题
:param content: 通知内容
:param send_key: Server酱的SendKey
"""
if send_key == "":
if not send_key:
raise ValueError("ServerChan SendKey 不能为空")
# 构造 URL
@@ -184,33 +171,33 @@ class Notification:
else:
raise Exception(f"ServerChan 推送通知失败: {response.text}")
async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
async def CustomWebhookPush(self, title, content, webhook_config) -> None:
"""
Webhook 推送通知
自定义 Webhook 推送通知
Parameters
----------
title: str
通知标题
content: str
通知内容
webhook: Webhook
Webhook配置对象
:param title: 通知标题
:param content: 通知内容
:param webhook_config: Webhook配置对象
"""
if not webhook.get("Info", "Enabled"):
return
if webhook.get("Data", "Url") == "":
if not webhook_config.get("url"):
raise ValueError("Webhook URL 不能为空")
if not webhook_config.get("enabled", True):
logger.info(
f"Webhook {webhook_config.get('name', 'Unknown')} 已禁用,跳过推送"
)
return
# 解析模板
template = (
webhook.get("Data", "Template")
or '{"title": "{title}", "content": "{content}"}'
template = webhook_config.get(
"template", '{"title": "{title}", "content": "{content}"}'
)
# 替换模板变量
try:
import json
from datetime import datetime
# 准备模板变量
template_vars = {
@@ -277,55 +264,56 @@ class Notification:
# 准备请求头
headers = {"Content-Type": "application/json"}
headers.update(json.loads(webhook.get("Data", "Headers")))
if webhook_config.get("headers"):
headers.update(webhook_config["headers"])
if webhook.get("Data", "Method") == "POST":
if isinstance(data, dict):
response = requests.post(
url=webhook.get("Data", "Url"),
json=data,
# 发送请求
method = webhook_config.get("method", "POST").upper()
try:
if method == "POST":
if isinstance(data, dict):
response = requests.post(
url=webhook_config["url"],
json=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
else:
response = requests.post(
url=webhook_config["url"],
data=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
else: # GET
params = data if isinstance(data, dict) else {"message": data}
response = requests.get(
url=webhook_config["url"],
params=params,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
elif isinstance(data, str):
response = requests.post(
url=webhook.get("Data", "Url"),
data=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
# 检查响应
if response.status_code == 200:
logger.success(
f"自定义Webhook推送成功: {webhook_config.get('name', 'Unknown')} - {title}"
)
elif webhook.get("Data", "Method") == "GET":
if isinstance(data, dict):
# Flatten params to ensure all values are str or list of str
params = {}
for k, v in data.items():
if isinstance(v, (dict, list)):
params[k] = json.dumps(v, ensure_ascii=False)
else:
params[k] = str(v)
else:
params = {"message": str(data)}
response = requests.get(
url=webhook.get("Data", "Url"),
params=params,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
raise Exception(f"HTTP {response.status_code}: {response.text}")
except Exception as e:
raise Exception(
f"自定义Webhook推送失败 ({webhook_config.get('name', 'Unknown')}): {str(e)}"
)
# 检查响应
if response.status_code == 200:
logger.success(
f"自定义Webhook推送成功: {webhook.get('Info', 'Name')} - {title}"
)
else:
raise Exception(f"HTTP {response.status_code}: {response.text}")
async def _WebHookPush(self, title, content, webhook_url) -> None:
async def WebHookPush(self, title, content, webhook_url) -> None:
"""
WebHook 推送通知 (即将弃用)
WebHook 推送通知 (兼容旧版企业微信格式)
:param title: 通知标题
:param content: 通知内容
@@ -352,7 +340,7 @@ class Notification:
self, image_path: Path, webhook_url: str
) -> None:
"""
使用企业微信群机器人推送图片通知(等待重新适配)
使用企业微信群机器人推送图片通知
:param image_path: 图片文件路径
:param webhook_url: 企业微信群机器人的WebHook地址
@@ -418,12 +406,23 @@ class Notification:
)
# 发送自定义Webhook通知
for webhook in Config.Notify_CustomWebhooks.values():
await self.WebhookPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
webhook,
)
try:
custom_webhooks = Config.get("Notify", "CustomWebhooks")
except AttributeError:
custom_webhooks = []
if custom_webhooks:
for webhook in custom_webhooks:
if webhook.get("enabled", True):
try:
await self.CustomWebhookPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
webhook,
)
except Exception as e:
logger.error(
f"自定义Webhook测试失败 ({webhook.get('name', 'Unknown')}): {e}"
)
logger.success("测试通知发送完成")

File diff suppressed because it is too large Load Diff

View File

@@ -27,9 +27,10 @@ import shutil
import asyncio
import subprocess
from pathlib import Path
from fastapi import WebSocket
from datetime import datetime, timedelta
from jinja2 import Environment, FileSystemLoader
from typing import List, Dict, Optional
from typing import Union, List, Dict, Optional
from app.core import Config, GeneralConfig, GeneralUserConfig
@@ -43,7 +44,7 @@ logger = get_logger("通用调度器")
class GeneralManager:
"""通用脚本调度"""
"""通用脚本通用控制"""
def __init__(
self, mode: str, script_id: uuid.UUID, user_id: Optional[uuid.UUID], ws_id: str
@@ -68,8 +69,9 @@ class GeneralManager:
await Config.ScriptConfig[self.script_id].lock()
self.script_config = Config.ScriptConfig[self.script_id]
self.user_config = MultipleConfig([GeneralUserConfig])
await self.user_config.load(await self.script_config.UserData.toDict())
if isinstance(self.script_config, GeneralConfig):
self.user_config = MultipleConfig([GeneralUserConfig])
await self.user_config.load(await self.script_config.UserData.toDict())
self.script_root_path = Path(self.script_config.get("Info", "RootPath"))
self.script_path = Path(self.script_config.get("Script", "ScriptPath"))
@@ -141,9 +143,6 @@ class GeneralManager:
def check_config(self) -> str:
"""检查配置是否可用"""
if not isinstance(Config.ScriptConfig[self.script_id], GeneralConfig):
return "脚本配置类型错误, 不是通用脚本类型"
if self.mode == "人工排查":
return "通用脚本不支持人工排查模式"
if self.mode == "设置脚本" and self.user_id is None:
@@ -222,319 +221,298 @@ class GeneralManager:
# 开始代理
for self.index, user in enumerate(self.user_list):
try:
self.cur_user_data = self.user_config[uuid.UUID(user["user_id"])]
self.cur_user_data = self.user_config[uuid.UUID(user["user_id"])]
if (self.script_config.get("Run", "ProxyTimesLimit") == 0) or (
self.cur_user_data.get("Data", "ProxyTimes")
< self.script_config.get("Run", "ProxyTimesLimit")
):
user["status"] = "运行"
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={"user_list": self.user_list},
).model_dump()
)
else:
user["status"] = "跳过"
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={"user_list": self.user_list},
).model_dump()
)
continue
if (self.script_config.get("Run", "ProxyTimesLimit") == 0) or (
self.cur_user_data.get("Data", "ProxyTimes")
< self.script_config.get("Run", "ProxyTimesLimit")
):
user["status"] = "运行"
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={"user_list": self.user_list},
).model_dump()
)
else:
user["status"] = "跳过"
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={"user_list": self.user_list},
).model_dump()
)
continue
logger.info(f"开始代理用户: {user['user_id']}")
logger.info(f"开始代理用户: {user['user_id']}")
self.user_start_time = datetime.now()
self.user_start_time = datetime.now()
self.run_book = False
self.run_book = False
if not (
Path.cwd() / f"data/{self.script_id}/{user['user_id']}/ConfigFile"
).exists():
if not (
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile"
).exists():
logger.error(f"用户: {user['user_id']} - 未找到配置文件")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={"Error": f"未找到 {user['user_id']} 的配置文件"},
).model_dump()
)
self.run_book = False
continue
# 尝试次数循环
for i in range(self.script_config.get("Run", "RunTimesLimit")):
if self.run_book:
break
logger.info(
f"用户 {user['user_id']} - 尝试次数: {i + 1}/{self.script_config.get('Run','RunTimesLimit')}"
)
# 配置脚本
await self.set_general()
# 记录当前时间
self.log_start_time = datetime.now()
# 执行任务前脚本
if (
self.cur_user_data.get("Info", "IfScriptBeforeTask")
and Path(
self.cur_user_data.get("Info", "ScriptBeforeTask")
).exists()
):
await self.execute_script_task(
Path(
self.cur_user_data.get("Info", "ScriptBeforeTask")
),
"脚本前任务",
)
# 启动游戏/模拟器
if self.script_config.get("Game", "Enabled"):
try:
logger.info(
f"启动游戏/模拟器: {self.game_path}, 参数: {self.script_config.get('Game','Arguments')}"
)
await self.game_process_manager.open_process(
self.game_path,
str(
self.script_config.get("Game", "Arguments")
).split(" "),
0,
)
except Exception as e:
logger.exception(f"启动游戏/模拟器时出现异常: {e}")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={
"Error": f"启动游戏/模拟器时出现异常: {e}"
},
).model_dump()
)
self.general_result = "游戏/模拟器启动失败"
break
# 更新静默进程标记有效时间
if self.script_config.get("Game", "Type") == "Emulator":
logger.info(
f"更新静默进程标记: {self.game_path}, 标记有效时间: {datetime.now() + timedelta(seconds=self.script_config.get('Game', 'WaitTime') + 10)}"
)
Config.silence_dict[
self.game_path
] = datetime.now() + timedelta(
seconds=self.script_config.get("Game", "WaitTime")
+ 10
)
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": f"正在等待游戏/模拟器完成启动\n请等待{self.script_config.get('Game', 'WaitTime')}s"
},
).model_dump()
)
await asyncio.sleep(
self.script_config.get("Game", "WaitTime")
)
# 运行脚本任务
logger.info(
f"运行脚本任务: {self.script_exe_path}, 参数: {self.script_arguments}"
)
await self.general_process_manager.open_process(
self.script_exe_path,
self.script_arguments,
tracking_time=(
60
if self.script_config.get("Script", "IfTrackProcess")
else 0
),
)
# 监测运行状态
await self.general_log_monitor.start(
self.script_log_path, self.log_start_time
)
self.wait_event.clear()
await self.wait_event.wait()
await self.general_log_monitor.stop()
# 处理通用脚本结果
if self.general_result == "Success!":
# 标记任务完成
self.run_book = True
logger.info(
f"用户: {user['user_id']} - 通用脚本进程完成代理任务"
)
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": "检测到通用脚本进程完成代理任务\n正在等待相关程序结束\n请等待10s"
},
).model_dump()
)
# 中止相关程序
logger.info(f"中止相关程序: {self.script_exe_path}")
await self.general_process_manager.kill()
await System.kill_process(self.script_exe_path)
if self.script_config.get("Game", "Enabled"):
logger.info(
f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}"
)
await self.game_process_manager.kill()
if self.script_config.get("Game", "IfForceClose"):
await System.kill_process(self.game_path)
await asyncio.sleep(10)
# 更新脚本配置文件
if self.script_config.get("Script", "UpdateConfigMode") in [
"Success",
"Always",
]:
if (
self.script_config.get("Script", "ConfigPathMode")
== "Folder"
):
shutil.copytree(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile",
dirs_exist_ok=True,
)
elif (
self.script_config.get("Script", "ConfigPathMode")
== "File"
):
shutil.copy(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile"
/ self.script_config_path.name,
)
logger.success("通用脚本配置文件已更新")
else:
logger.error(
f"配置: {user['user_id']} - 代理任务异常: {self.general_result}"
)
# 打印中止信息
# 此时, log变量内存储的就是出现异常的日志信息, 可以保存或发送用于问题排查
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": f"{self.general_result}\n正在中止相关程序\n请等待10s"
},
).model_dump()
)
# 中止相关程序
logger.info(f"中止相关程序: {self.script_exe_path}")
await self.general_process_manager.kill()
await System.kill_process(self.script_exe_path)
if self.script_config.get("Game", "Enabled"):
logger.info(
f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}"
)
await self.game_process_manager.kill()
if self.script_config.get("Game", "IfForceClose"):
await System.kill_process(self.game_path)
# 推送异常通知
await Notify.push_plyer(
"用户自动代理出现异常!",
f"用户 {user['name']} 的自动代理出现一次异常",
f"{user['name']} 的自动代理出现异常",
3,
)
await asyncio.sleep(10)
# 更新脚本配置文件
if self.script_config.get("Script", "UpdateConfigMode") in [
"Failure",
"Always",
]:
if (
self.script_config.get("Script", "ConfigPathMode")
== "Folder"
):
shutil.copytree(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile",
dirs_exist_ok=True,
)
elif (
self.script_config.get("Script", "ConfigPathMode")
== "File"
):
shutil.copy(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile"
/ self.script_config_path.name,
)
logger.success("通用脚本配置文件已更新")
# 执行任务后脚本
if (
self.cur_user_data.get("Info", "IfScriptAfterTask")
and Path(
self.cur_user_data.get("Info", "ScriptAfterTask")
).exists()
):
await self.execute_script_task(
Path(self.cur_user_data.get("Info", "ScriptAfterTask")),
"脚本后任务",
)
# 保存运行日志以及统计信息
await Config.save_general_log(
Path.cwd()
/ f"history/{self.curdate}/{user['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log",
self.general_logs,
self.general_result,
)
await self.result_record()
except Exception as e:
logger.exception(f"用户 {user['user_id']} 代理时出现异常: {e}")
user["status"] = "异常"
logger.error(f"用户: {user['user_id']} - 未找到配置文件")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={"Error": f"代理用户 {user['name']} 时出现异常: {e}"},
data={"Error": f"未找到 {user['user_id']} 的配置文件"},
).model_dump()
)
self.run_book = False
continue
# 尝试次数循环
for i in range(self.script_config.get("Run", "RunTimesLimit")):
if self.run_book:
break
logger.info(
f"用户 {user['user_id']} - 尝试次数: {i + 1}/{self.script_config.get('Run','RunTimesLimit')}"
)
# 配置脚本
await self.set_general()
# 记录当前时间
self.log_start_time = datetime.now()
# 执行任务前脚本
if (
self.cur_user_data.get("Info", "IfScriptBeforeTask")
and Path(
self.cur_user_data.get("Info", "ScriptBeforeTask")
).exists()
):
await self.execute_script_task(
Path(self.cur_user_data.get("Info", "ScriptBeforeTask")),
"脚本前任务",
)
# 启动游戏/模拟器
if self.script_config.get("Game", "Enabled"):
try:
logger.info(
f"启动游戏/模拟器: {self.game_path}, 参数: {self.script_config.get('Game','Arguments')}"
)
await self.game_process_manager.open_process(
self.game_path,
str(self.script_config.get("Game", "Arguments")).split(
" "
),
0,
)
except Exception as e:
logger.exception(f"启动游戏/模拟器时出现异常: {e}")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={"Error": f"启动游戏/模拟器时出现异常: {e}"},
).model_dump()
)
self.general_result = "游戏/模拟器启动失败"
break
# 更新静默进程标记有效时间
if self.script_config.get("Game", "Type") == "Emulator":
logger.info(
f"更新静默进程标记: {self.game_path}, 标记有效时间: {datetime.now() + timedelta(seconds=self.script_config.get('Game', 'WaitTime') + 10)}"
)
Config.silence_dict[
self.game_path
] = datetime.now() + timedelta(
seconds=self.script_config.get("Game", "WaitTime") + 10
)
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": f"正在等待游戏/模拟器完成启动\n请等待{self.script_config.get('Game', 'WaitTime')}s"
},
).model_dump()
)
await asyncio.sleep(self.script_config.get("Game", "WaitTime"))
# 运行脚本任务
logger.info(
f"运行脚本任务: {self.script_exe_path}, 参数: {self.script_arguments}"
)
await self.general_process_manager.open_process(
self.script_exe_path,
self.script_arguments,
tracking_time=(
60
if self.script_config.get("Script", "IfTrackProcess")
else 0
),
)
# 监测运行状态
await self.general_log_monitor.start(
self.script_log_path, self.log_start_time
)
self.wait_event.clear()
await self.wait_event.wait()
await self.general_log_monitor.stop()
# 处理通用脚本结果
if self.general_result == "Success!":
# 标记任务完成
self.run_book = True
logger.info(
f"用户: {user['user_id']} - 通用脚本进程完成代理任务"
)
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": "检测到通用脚本进程完成代理任务\n正在等待相关程序结束\n请等待10s"
},
).model_dump()
)
# 中止相关程序
logger.info(f"中止相关程序: {self.script_exe_path}")
await self.general_process_manager.kill()
await System.kill_process(self.script_exe_path)
if self.script_config.get("Game", "Enabled"):
logger.info(
f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}"
)
await self.game_process_manager.kill()
if self.script_config.get("Game", "IfForceClose"):
await System.kill_process(self.game_path)
await asyncio.sleep(10)
# 更新脚本配置文件
if self.script_config.get("Script", "UpdateConfigMode") in [
"Success",
"Always",
]:
if (
self.script_config.get("Script", "ConfigPathMode")
== "Folder"
):
shutil.copytree(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile",
dirs_exist_ok=True,
)
elif (
self.script_config.get("Script", "ConfigPathMode")
== "File"
):
shutil.copy(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile"
/ self.script_config_path.name,
)
logger.success("通用脚本配置文件已更新")
else:
logger.error(
f"配置: {user['user_id']} - 代理任务异常: {self.general_result}"
)
# 打印中止信息
# 此时, log变量内存储的就是出现异常的日志信息, 可以保存或发送用于问题排查
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Update",
data={
"log": f"{self.general_result}\n正在中止相关程序\n请等待10s"
},
).model_dump()
)
# 中止相关程序
logger.info(f"中止相关程序: {self.script_exe_path}")
await self.general_process_manager.kill()
await System.kill_process(self.script_exe_path)
if self.script_config.get("Game", "Enabled"):
logger.info(
f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}"
)
await self.game_process_manager.kill()
if self.script_config.get("Game", "IfForceClose"):
await System.kill_process(self.game_path)
# 推送异常通知
await Notify.push_plyer(
"用户自动代理出现异常!",
f"用户 {user['name']} 的自动代理出现一次异常",
f"{user['name']} 的自动代理出现异常",
3,
)
await asyncio.sleep(10)
# 更新脚本配置文件
if self.script_config.get("Script", "UpdateConfigMode") in [
"Failure",
"Always",
]:
if (
self.script_config.get("Script", "ConfigPathMode")
== "Folder"
):
shutil.copytree(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile",
dirs_exist_ok=True,
)
elif (
self.script_config.get("Script", "ConfigPathMode")
== "File"
):
shutil.copy(
self.script_config_path,
Path.cwd()
/ f"data/{self.script_id}/{user['user_id']}/ConfigFile"
/ self.script_config_path.name,
)
logger.success("通用脚本配置文件已更新")
# 执行任务后脚本
if (
self.cur_user_data.get("Info", "IfScriptAfterTask")
and Path(
self.cur_user_data.get("Info", "ScriptAfterTask")
).exists()
):
await self.execute_script_task(
Path(self.cur_user_data.get("Info", "ScriptAfterTask")),
"脚本后任务",
)
# 保存运行日志以及统计信息
await Config.save_general_log(
Path.cwd()
/ f"history/{self.curdate}/{user['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log",
self.general_logs,
self.general_result,
)
await self.result_record()
# 设置通用脚本模式
elif self.mode == "设置脚本":
@@ -567,21 +545,11 @@ class GeneralManager:
"end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"user_result": "代理成功" if self.run_book else self.general_result,
}
try:
await self.push_notification(
"统计信息",
f"{self.current_date} | 用户 {self.user_list[self.index]['name']} 的自动代理统计报告",
statistics,
)
except Exception as e:
logger.exception(f"推送统计信息失败: {e}")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={"Error": f"推送统计信息失败: {e}"},
).model_dump()
)
await self.push_notification(
"统计信息",
f"{self.current_date} | 用户 {self.user_list[self.index]['name']} 的自动代理统计报告",
statistics,
)
if self.run_book:
# 成功完成代理的用户修改相关参数
@@ -691,10 +659,10 @@ class GeneralManager:
if self.mode == "自动代理":
# 更新用户数据
await Config.ScriptConfig[self.script_id].UserData.load(
await self.user_config.toDict()
)
await Config.ScriptConfig.save()
sc = Config.ScriptConfig[self.script_id]
if isinstance(sc, GeneralConfig):
await sc.UserData.load(await self.user_config.toDict())
await Config.ScriptConfig.save()
error_user = [_["name"] for _ in self.user_list if _["status"] == "异常"]
over_user = [_["name"] for _ in self.user_list if _["status"] == "完成"]
@@ -740,17 +708,7 @@ class GeneralManager:
f"已完成配置数: {len(over_user)}, 未完成配置数: {len(error_user) + len(wait_user)}",
10,
)
try:
await self.push_notification("代理结果", title, result)
except Exception as e:
logger.exception(f"推送代理结果失败: {e}")
await Config.send_json(
WebSocketMessage(
id=self.ws_id,
type="Info",
data={"Error": f"推送代理结果失败: {e}"},
).model_dump()
)
await self.push_notification("代理结果", title, result)
elif self.mode == "设置脚本":
@@ -1012,6 +970,7 @@ class GeneralManager:
serverchan_message = message_text.replace("\n", "\n\n")
# 发送全局通知
if Config.get("Notify", "IfSendMail"):
await Notify.send_mail(
"网页", title, message_html, Config.get("Notify", "ToAddress")
@@ -1025,10 +984,21 @@ class GeneralManager:
)
# 发送自定义Webhook通知
for webhook in Config.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
try:
custom_webhooks = Config.get("Notify", "CustomWebhooks")
except AttributeError:
custom_webhooks = []
if custom_webhooks:
for webhook in custom_webhooks:
if webhook.get("enabled", True):
try:
await Notify.CustomWebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
except Exception as e:
logger.error(
f"自定义Webhook推送失败 ({webhook.get('name', 'Unknown')}): {e}"
)
elif mode == "统计信息":
@@ -1061,10 +1031,21 @@ class GeneralManager:
)
# 发送自定义Webhook通知
for webhook in Config.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
try:
custom_webhooks = Config.get("Notify", "CustomWebhooks")
except AttributeError:
custom_webhooks = []
if custom_webhooks:
for webhook in custom_webhooks:
if webhook.get("enabled", True):
try:
await Notify.CustomWebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
except Exception as e:
logger.error(
f"自定义Webhook推送失败 ({webhook.get('name', 'Unknown')}): {e}"
)
# 发送用户单独通知
if self.cur_user_data.get("Notify", "Enabled") and self.cur_user_data.get(
@@ -1097,7 +1078,22 @@ class GeneralManager:
)
# 推送CompanyWebHookBot通知
for webhook in self.cur_user_data.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
# 发送用户自定义Webhook通知
user_webhooks = self.cur_user_data.get("Notify", "CustomWebhooks")
if user_webhooks:
for webhook in user_webhooks:
if webhook.get("enabled", True):
try:
await Notify.CustomWebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
except Exception as e:
logger.error(
f"用户自定义Webhook推送失败 ({webhook.get('name', 'Unknown')}): {e}"
)
else:
logger.error(
"用户CompanyWebHookBot密钥为空, 无法发送用户单独的CompanyWebHookBot通知"
)
return None

View File

@@ -23,10 +23,6 @@
from datetime import datetime
TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"}
"""配置类型映射表"""
RESOURCE_STAGE_INFO = [
{"value": "-", "text": "当前/上次", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "1-7", "text": "1-7", "days": [1, 2, 3, 4, 5, 6, 7]},

View File

@@ -1,29 +1,18 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
nativeTheme,
screen,
dialog,
shell,
Tray,
Menu,
nativeImage,
screen,
} from 'electron'
import * as path from 'path'
import * as fs from 'fs'
import { exec, spawn } from 'child_process'
import { checkEnvironment, getAppRoot } from './services/environmentService'
import { setMainWindow as setDownloadMainWindow } from './services/downloadService'
import {
downloadPython,
installDependencies,
installPipPackage,
setMainWindow as setPythonMainWindow,
startBackend,
} from './services/pythonService'
import { cloneBackend, downloadGit, setMainWindow as setGitMainWindow } from './services/gitService'
import { cleanOldLogs, getLogFiles, getLogPath, log, setupLogger } from './services/logService'
import { spawn, exec } from 'child_process'
import { getAppRoot, checkEnvironment } from './services/environmentService'
// 强制清理相关进程的函数
async function forceKillRelatedProcesses(): Promise<void> {
@@ -33,15 +22,15 @@ async function forceKillRelatedProcesses(): Promise<void> {
log.info('所有相关进程已清理')
} catch (error) {
log.error('清理进程时出错:', error)
// 备用清理方法
if (process.platform === 'win32') {
const appRoot = getAppRoot()
const pythonExePath = path.join(appRoot, 'environment', 'python', 'python.exe')
return new Promise(resolve => {
return new Promise((resolve) => {
// 使用更简单的命令强制结束相关进程
exec(`taskkill /f /im python.exe`, error => {
exec(`taskkill /f /im python.exe`, (error) => {
if (error) {
log.warn('备用清理方法失败:', error.message)
} else {
@@ -53,6 +42,16 @@ async function forceKillRelatedProcesses(): Promise<void> {
}
}
}
import { setMainWindow as setDownloadMainWindow } from './services/downloadService'
import {
setMainWindow as setPythonMainWindow,
downloadPython,
installPipPackage,
installDependencies,
startBackend,
} from './services/pythonService'
import { setMainWindow as setGitMainWindow, downloadGit, cloneBackend } from './services/gitService'
import { setupLogger, log, getLogPath, getLogFiles, cleanOldLogs } from './services/logService'
// 检查是否以管理员权限运行
function isRunningAsAdmin(): boolean {
@@ -114,7 +113,6 @@ interface AppConfig {
IfMinimizeDirectly: boolean
IfSelfStart: boolean
}
[key: string]: any
}
@@ -306,9 +304,7 @@ function updateTrayVisibility(config: AppConfig) {
log.info('托盘图标已销毁')
}
}
let mainWindow: Electron.BrowserWindow | null = null
function createWindow() {
log.info('开始创建主窗口')
@@ -446,7 +442,7 @@ function createWindow() {
screen.removeListener('display-metrics-changed', recomputeMinSize)
// 置空模块级引用
mainWindow = null
// 如果是正在退出,立即执行进程清理
if (isQuitting) {
log.info('窗口关闭,执行最终清理')
@@ -602,19 +598,19 @@ ipcMain.handle('kill-all-processes', async () => {
ipcMain.handle('force-exit', async () => {
log.info('收到强制退出命令')
isQuitting = true
// 立即清理进程
try {
await forceKillRelatedProcesses()
} catch (e) {
log.error('强制清理失败:', e)
}
// 强制退出
setTimeout(() => {
process.exit(0)
}, 500)
return { success: true }
})
@@ -750,232 +746,6 @@ ipcMain.handle('stop-backend', async () => {
return stopBackend()
})
// 获取当前主题信息
ipcMain.handle('get-theme-info', async () => {
try {
const appRoot = getAppRoot()
const configPath = path.join(appRoot, 'config', 'frontend_config.json')
let themeMode = 'system'
let themeColor = 'blue'
// 尝试从配置文件读取主题设置
if (fs.existsSync(configPath)) {
try {
const configData = fs.readFileSync(configPath, 'utf8')
const config = JSON.parse(configData)
themeMode = config.themeMode || 'system'
themeColor = config.themeColor || 'blue'
} catch (error) {
log.warn('读取主题配置失败,使用默认值:', error)
}
}
// 检测系统主题
const systemTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
// 确定实际使用的主题
let actualTheme = themeMode
if (themeMode === 'system') {
actualTheme = systemTheme
}
const themeColors: Record<string, string> = {
blue: '#1677ff',
purple: '#722ed1',
cyan: '#13c2c2',
green: '#52c41a',
magenta: '#eb2f96',
pink: '#eb2f96',
red: '#ff4d4f',
orange: '#fa8c16',
yellow: '#fadb14',
volcano: '#fa541c',
geekblue: '#2f54eb',
lime: '#a0d911',
gold: '#faad14',
}
return {
themeMode,
themeColor,
actualTheme,
systemTheme,
isDark: actualTheme === 'dark',
primaryColor: themeColors[themeColor] || themeColors.blue
}
} catch (error) {
log.error('获取主题信息失败:', error)
return {
themeMode: 'system',
themeColor: 'blue',
actualTheme: 'light',
systemTheme: 'light',
isDark: false,
primaryColor: '#1677ff'
}
}
})
// 获取对话框专用的主题信息
ipcMain.handle('get-theme', async () => {
try {
const appRoot = getAppRoot()
const configPath = path.join(appRoot, 'config', 'frontend_config.json')
let themeMode = 'system'
// 尝试从配置文件读取主题设置
if (fs.existsSync(configPath)) {
try {
const configData = fs.readFileSync(configPath, 'utf8')
const config = JSON.parse(configData)
themeMode = config.themeMode || 'system'
} catch (error) {
log.warn('读取主题配置失败,使用默认值:', error)
}
}
// 检测系统主题
const systemTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
// 确定实际使用的主题
let actualTheme = themeMode
if (themeMode === 'system') {
actualTheme = systemTheme
}
return actualTheme
} catch (error) {
log.error('获取对话框主题失败:', error)
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
}
})
// 全局存储对话框窗口引用和回调
let dialogWindows = new Map<string, BrowserWindow>()
let dialogCallbacks = new Map<string, (result: boolean) => void>()
// 创建对话框窗口
function createQuestionDialog(questionData: any): Promise<boolean> {
return new Promise((resolve) => {
const messageId = questionData.messageId || 'dialog_' + Date.now()
// 存储回调函数
dialogCallbacks.set(messageId, resolve)
// 准备对话框数据
const dialogData = {
title: questionData.title || '操作确认',
message: questionData.message || '是否要执行此操作?',
options: questionData.options || ['确定', '取消'],
messageId: messageId
}
// 获取主窗口的尺寸用于全屏显示
let windowBounds = { width: 800, height: 600, x: 100, y: 100 }
if (mainWindow && !mainWindow.isDestroyed()) {
windowBounds = mainWindow.getBounds()
}
// 创建对话框窗口 - 小尺寸可拖动窗口
const dialogWindow = new BrowserWindow({
width: 400,
height: 145,
x: windowBounds.x + (windowBounds.width - 400) / 2, // 居中显示
y: windowBounds.y + (windowBounds.height - 200) / 2,
resizable: false, // 不允许改变大小
minimizable: false,
maximizable: false,
alwaysOnTop: true,
show: false,
frame: false,
modal: mainWindow ? true : false,
parent: mainWindow || undefined,
icon: path.join(__dirname, '../public/AUTO-MAS.ico'),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
})
// 存储窗口引用
dialogWindows.set(messageId, dialogWindow)
// 编码对话框数据
const encodedData = encodeURIComponent(JSON.stringify(dialogData))
// 加载对话框页面
const dialogUrl = `file://${path.join(__dirname, '../public/dialog.html')}?data=${encodedData}`
dialogWindow.loadURL(dialogUrl)
// 窗口准备好后显示
dialogWindow.once('ready-to-show', () => {
dialogWindow.show()
dialogWindow.focus()
})
// 窗口关闭时清理
dialogWindow.on('closed', () => {
dialogWindows.delete(messageId)
const callback = dialogCallbacks.get(messageId)
if (callback) {
dialogCallbacks.delete(messageId)
callback(false) // 默认返回 false (取消)
}
})
log.info(`全屏对话框窗口已创建: ${messageId}`)
})
}
// 显示问题对话框
ipcMain.handle('show-question-dialog', async (_event, questionData) => {
log.info('收到显示对话框请求:', questionData)
try {
const result = await createQuestionDialog(questionData)
log.info(`对话框结果: ${result}`)
return result
} catch (error) {
log.error('创建对话框失败:', error)
return false
}
})
// 处理对话框响应
ipcMain.handle('dialog-response', async (_event, messageId: string, choice: boolean) => {
log.info(`收到对话框响应: ${messageId} = ${choice}`)
const callback = dialogCallbacks.get(messageId)
if (callback) {
dialogCallbacks.delete(messageId)
callback(choice)
}
// 关闭对话框窗口
const dialogWindow = dialogWindows.get(messageId)
if (dialogWindow && !dialogWindow.isDestroyed()) {
dialogWindow.close()
}
dialogWindows.delete(messageId)
return true
})
// 移动对话框窗口
ipcMain.handle('move-window', async (_event, deltaX: number, deltaY: number) => {
// 获取当前活动的对话框窗口(最后创建的)
const dialogWindow = Array.from(dialogWindows.values()).pop()
if (dialogWindow && !dialogWindow.isDestroyed()) {
const currentBounds = dialogWindow.getBounds()
dialogWindow.setPosition(
currentBounds.x + deltaX,
currentBounds.y + deltaY
)
}
})
// Git相关
ipcMain.handle('download-git', async () => {
const appRoot = getAppRoot()
@@ -1015,7 +785,7 @@ ipcMain.handle('check-git-update', async () => {
// 不执行fetch直接检查本地状态
// 这样避免了直接访问GitHub而是在后续的pull操作中使用镜像站
// 获取当前HEAD的commit hash
const currentCommit = await new Promise<string>((resolve, reject) => {
const revParseProc = spawn(gitPath, ['rev-parse', 'HEAD'], {
@@ -1041,13 +811,14 @@ ipcMain.handle('check-git-update', async () => {
})
log.info(`当前本地commit: ${currentCommit}`)
// 由于我们跳过了fetch步骤避免直接访问GitHub
// 我们无法准确知道远程是否有更新
// 因此返回true让后续的pull操作通过镜像站来检查和获取更新
// 如果没有更新pull操作会很快完成且不会有实际变化
log.info('跳过远程检查返回hasUpdate=true以触发镜像站更新流程')
return { hasUpdate: true, skipReason: 'avoided_github_access' }
} catch (error) {
log.error('检查Git更新失败:', error)
// 如果检查失败返回true以触发更新流程确保代码是最新的
@@ -1313,23 +1084,23 @@ app.on('before-quit', async event => {
// 立即开始强制清理,不等待优雅关闭
log.info('开始强制清理所有相关进程')
try {
// 并行执行多种清理方法
const cleanupPromises = [
// 方法1: 使用我们的进程管理器
forceKillRelatedProcesses(),
// 方法2: 直接使用 taskkill 命令
new Promise<void>(resolve => {
new Promise<void>((resolve) => {
if (process.platform === 'win32') {
const appRoot = getAppRoot()
const commands = [
`taskkill /f /im python.exe`,
`wmic process where "CommandLine like '%main.py%'" delete`,
`wmic process where "CommandLine like '%${appRoot.replace(/\\/g, '\\\\')}%'" delete`,
`wmic process where "CommandLine like '%${appRoot.replace(/\\/g, '\\\\')}%'" delete`
]
let completed = 0
commands.forEach(cmd => {
exec(cmd, () => {
@@ -1339,26 +1110,26 @@ app.on('before-quit', async event => {
}
})
})
// 2秒超时
setTimeout(resolve, 2000)
} else {
resolve()
}
}),
})
]
// 最多等待3秒
const timeoutPromise = new Promise(resolve => setTimeout(resolve, 3000))
await Promise.race([Promise.all(cleanupPromises), timeoutPromise])
log.info('进程清理完成')
} catch (e) {
log.error('进程清理时出错:', e)
}
log.info('应用强制退出')
// 使用 process.exit 而不是 app.exit更加强制
setTimeout(() => {
process.exit(0)

View File

@@ -63,16 +63,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
openFile: (filePath: string) => ipcRenderer.invoke('open-file', filePath),
showItemInFolder: (filePath: string) => ipcRenderer.invoke('show-item-in-folder', filePath),
// 对话框相关
showQuestionDialog: (questionData: any) => ipcRenderer.invoke('show-question-dialog', questionData),
dialogResponse: (messageId: string, choice: boolean) => ipcRenderer.invoke('dialog-response', messageId, choice),
resizeDialogWindow: (height: number) => ipcRenderer.invoke('resize-dialog-window', height),
moveWindow: (deltaX: number, deltaY: number) => ipcRenderer.invoke('move-window', deltaX, deltaY),
// 主题信息获取
getThemeInfo: () => ipcRenderer.invoke('get-theme-info'),
getTheme: () => ipcRenderer.invoke('get-theme'),
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('download-progress', (_, progress) => callback(progress))

View File

@@ -1,7 +1,7 @@
import * as path from 'path'
import * as fs from 'fs'
import { spawn } from 'child_process'
import { BrowserWindow, app } from 'electron'
import { BrowserWindow } from 'electron'
import AdmZip from 'adm-zip'
import { downloadFile } from './downloadService'
@@ -13,116 +13,6 @@ export function setMainWindow(window: BrowserWindow) {
const gitDownloadUrl = 'https://download.auto-mas.top/d/AUTO_MAS/git.zip'
// 获取应用版本号
function getAppVersion(appRoot: string): string {
console.log('=== 开始获取应用版本号 ===')
console.log(`应用根目录: ${appRoot}`)
try {
// 方法1: 从 Electron app 获取版本号(打包后可用)
try {
const appVersion = app.getVersion()
if (appVersion && appVersion !== '1.0.0') { // 避免使用默认版本
console.log(`✅ 从 app.getVersion() 获取版本号: ${appVersion}`)
return appVersion
}
} catch (error) {
console.log('⚠️ app.getVersion() 获取失败:', error)
}
// 方法2: 从预设的环境变量获取(如果在构建时注入了)
if (process.env.VITE_APP_VERSION) {
console.log(`✅ 从环境变量获取版本号: ${process.env.VITE_APP_VERSION}`)
return process.env.VITE_APP_VERSION
}
// 方法3: 开发环境下从 package.json 获取
const packageJsonPath = path.join(appRoot, 'frontend', 'package.json')
console.log(`尝试读取前端package.json: ${packageJsonPath}`)
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
const version = packageJson.version || '获取版本失败!'
console.log(`✅ 从前端package.json获取版本号: ${version}`)
return version
}
console.log('⚠️ 前端package.json不存在尝试读取根目录package.json')
// 方法4: 从根目录 package.json 获取(开发环境)
const currentPackageJsonPath = path.join(appRoot, 'package.json')
console.log(`尝试读取根目录package.json: ${currentPackageJsonPath}`)
if (fs.existsSync(currentPackageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(currentPackageJsonPath, 'utf8'))
const version = packageJson.version || '获取版本失败!'
console.log(`✅ 从根目录package.json获取版本号: ${version}`)
return version
}
console.log('❌ 未找到任何版本信息源')
return '获取版本失败!'
} catch (error) {
console.error('❌ 获取版本号失败:', error)
return '获取版本失败!'
}
}
// 检查分支是否存在
async function checkBranchExists(
gitPath: string,
gitEnv: any,
repoUrl: string,
branchName: string
): Promise<boolean> {
console.log(`=== 检查分支是否存在: ${branchName} ===`)
console.log(`Git路径: ${gitPath}`)
console.log(`仓库URL: ${repoUrl}`)
try {
return new Promise<boolean>(resolve => {
const proc = spawn(gitPath, ['ls-remote', '--heads', repoUrl, branchName], {
stdio: 'pipe',
env: gitEnv,
})
let output = ''
let errorOutput = ''
proc.stdout?.on('data', data => {
const chunk = data.toString()
output += chunk
console.log(`git ls-remote stdout: ${chunk.trim()}`)
})
proc.stderr?.on('data', data => {
const chunk = data.toString()
errorOutput += chunk
console.log(`git ls-remote stderr: ${chunk.trim()}`)
})
proc.on('close', code => {
console.log(`git ls-remote 退出码: ${code}`)
// 如果输出包含分支名,说明分支存在
const branchExists = output.includes(`refs/heads/${branchName}`)
console.log(`分支 ${branchName} ${branchExists ? '✅ 存在' : '❌ 不存在'}`)
if (errorOutput) {
console.log(`错误输出: ${errorOutput}`)
}
resolve(branchExists)
})
proc.on('error', error => {
console.error(`git ls-remote 进程错误:`, error)
resolve(false)
})
})
} catch (error) {
console.error(`❌ 检查分支 ${branchName} 时出错:`, error)
return false
}
}
// 递归复制目录,包括文件和隐藏文件
function copyDirSync(src: string, dest: string) {
if (!fs.existsSync(dest)) {
@@ -302,226 +192,69 @@ export async function cloneBackend(
success: boolean
error?: string
}> {
console.log('=== 开始克隆/更新后端代码 ===')
console.log(`应用根目录: ${appRoot}`)
console.log(`仓库URL: ${repoUrl}`)
try {
const backendPath = appRoot
const gitPath = path.join(appRoot, 'environment', 'git', 'bin', 'git.exe')
console.log(`Git可执行文件路径: ${gitPath}`)
console.log(`后端代码路径: ${backendPath}`)
if (!fs.existsSync(gitPath)) {
const error = `Git可执行文件不存在: ${gitPath}`
console.error(`${error}`)
throw new Error(error)
}
console.log('✅ Git可执行文件存在')
if (!fs.existsSync(gitPath)) throw new Error(`Git可执行文件不存在: ${gitPath}`)
const gitEnv = getGitEnvironment(appRoot)
console.log('✅ Git环境变量配置完成')
// 检查 git 是否可用
console.log('=== 检查Git是否可用 ===')
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['--version'], { env: gitEnv })
proc.stdout?.on('data', data => {
console.log(`git --version output: ${data.toString().trim()}`)
})
proc.stderr?.on('data', data => {
console.log(`git --version error: ${data.toString().trim()}`)
})
proc.on('close', code => {
console.log(`git --version 退出码: ${code}`)
if (code === 0) {
console.log('✅ Git可用')
resolve()
} else {
console.error('❌ Git无法正常运行')
reject(new Error('git 无法正常运行'))
}
})
proc.on('error', error => {
console.error('❌ Git进程启动失败:', error)
reject(error)
})
proc.on('close', code => (code === 0 ? resolve() : reject(new Error('git 无法正常运行'))))
proc.on('error', reject)
})
// 获取版本号并确定目标分支
const version = getAppVersion(appRoot)
console.log(`=== 分支选择逻辑 ===`)
console.log(`当前应用版本: ${version}`)
let targetBranch = 'feature/refactor' // 默认分支
console.log(`默认分支: ${targetBranch}`)
if (version !== '获取版本失败!') {
// 检查版本对应的分支是否存在
console.log(`开始检查版本分支是否存在...`)
const versionBranchExists = await checkBranchExists(gitPath, gitEnv, repoUrl, version)
if (versionBranchExists) {
targetBranch = version
console.log(`🎯 将使用版本分支: ${targetBranch}`)
} else {
console.log(`⚠️ 版本分支 ${version} 不存在,使用默认分支: ${targetBranch}`)
}
} else {
console.log('⚠️ 版本号获取失败,使用默认分支: feature/refactor')
}
console.log(`=== 最终选择分支: ${targetBranch} ===`)
// 检查是否为Git仓库
const isRepo = isGitRepository(backendPath)
console.log(`检查是否为Git仓库: ${isRepo ? '✅ 是' : '❌ 否'}`)
// ==== 下面是关键逻辑 ====
if (isRepo) {
console.log('=== 更新现有Git仓库 ===')
if (isGitRepository(backendPath)) {
// 已是 git 仓库先更新远程URL为镜像站然后 pull
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 0,
status: 'downloading',
message: `正在更新后端代码(分支: ${targetBranch})...`,
message: '正在更新后端代码...',
})
}
// 更新远程URL为镜像站URL避免直接访问GitHub
console.log(`📡 更新远程URL为镜像站: ${repoUrl}`)
console.log(`更新远程URL为镜像站: ${repoUrl}`)
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['remote', 'set-url', 'origin', repoUrl], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath,
})
proc.stdout?.on('data', d => console.log('git remote set-url stdout:', d.toString().trim()))
proc.stderr?.on('data', d => console.log('git remote set-url stderr:', d.toString().trim()))
proc.on('close', code => {
console.log(`git remote set-url 退出码: ${code}`)
if (code === 0) {
console.log('✅ 远程URL更新成功')
resolve()
} else {
console.error('❌ 远程URL更新失败')
reject(new Error(`git remote set-url失败退出码: ${code}`))
}
})
proc.on('error', error => {
console.error('❌ git remote set-url 进程错误:', error)
reject(error)
const proc = spawn(gitPath, ['remote', 'set-url', 'origin', repoUrl], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath
})
proc.stdout?.on('data', d => console.log('git remote set-url:', d.toString()))
proc.stderr?.on('data', d => console.log('git remote set-url err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git remote set-url失败退出码: ${code}`))
)
proc.on('error', reject)
})
// 获取目标分支信息(显式 fetch 目标分支)
console.log(`📥 显式获取远程分支: ${targetBranch} ...`)
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['fetch', 'origin', targetBranch], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath,
})
proc.stdout?.on('data', d => console.log('git fetch stdout:', d.toString().trim()))
proc.stderr?.on('data', d => console.log('git fetch stderr:', d.toString().trim()))
proc.on('close', code => {
console.log(`git fetch origin ${targetBranch} 退出码: ${code}`)
if (code === 0) {
console.log(`✅ 成功获取远程分支: ${targetBranch}`)
resolve()
} else {
console.error(`❌ 获取远程分支失败: ${targetBranch}`)
reject(new Error(`git fetch origin ${targetBranch} 失败,退出码: ${code}`))
}
})
proc.on('error', error => {
console.error('❌ git fetch 进程错误:', error)
reject(error)
})
})
// 切换到目标分支
console.log(`🔀 切换到目标分支: ${targetBranch}`)
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['checkout', '-B', targetBranch, `origin/${targetBranch}`], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath,
})
proc.stdout?.on('data', d => console.log('git checkout stdout:', d.toString().trim()))
proc.stderr?.on('data', d => console.log('git checkout stderr:', d.toString().trim()))
proc.on('close', code => {
console.log(`git checkout 退出码: ${code}`)
if (code === 0) {
console.log(`✅ 成功切换到分支: ${targetBranch}`)
resolve()
} else {
console.error(`❌ 切换分支失败: ${targetBranch}`)
reject(new Error(`git checkout失败退出码: ${code}`))
}
})
proc.on('error', error => {
console.error('❌ git checkout 进程错误:', error)
reject(error)
})
})
// 执行pull操作
console.log('🔄 强制同步到远程分支最新提交...')
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['reset', '--hard', `origin/${targetBranch}`], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath,
})
proc.stdout?.on('data', d => console.log('git reset stdout:', d.toString().trim()))
proc.stderr?.on('data', d => console.log('git reset stderr:', d.toString().trim()))
proc.on('close', code => {
console.log(`git reset --hard 退出码: ${code}`)
if (code === 0) {
console.log('✅ 代码已强制更新到远程最新版本')
resolve()
} else {
console.error('❌ 代码重置失败')
reject(new Error(`git reset --hard 失败,退出码: ${code}`))
}
})
proc.on('error', error => {
console.error('❌ git reset 进程错误:', error)
reject(error)
})
const proc = spawn(gitPath, ['pull'], { stdio: 'pipe', env: gitEnv, cwd: backendPath })
proc.stdout?.on('data', d => console.log('git pull:', d.toString()))
proc.stderr?.on('data', d => console.log('git pull err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git pull失败退出码: ${code}`))
)
proc.on('error', reject)
})
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 100,
status: 'completed',
message: `后端代码更新完成(分支: ${targetBranch})`,
message: '后端代码更新完成',
})
}
console.log(`✅ 后端代码更新完成(分支: ${targetBranch})`)
} else {
console.log('=== 克隆新的Git仓库 ===')
// 不是 git 仓库clone 到 tmp再拷贝出来
const tmpDir = path.join(appRoot, 'git_tmp')
console.log(`临时目录: ${tmpDir}`)
if (fs.existsSync(tmpDir)) {
console.log('🗑️ 清理现有临时目录...')
fs.rmSync(tmpDir, { recursive: true, force: true })
}
console.log('📁 创建临时目录...')
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true })
fs.mkdirSync(tmpDir, { recursive: true })
if (mainWindow) {
@@ -529,13 +262,9 @@ export async function cloneBackend(
type: 'backend',
progress: 0,
status: 'downloading',
message: `正在克隆后端代码(分支: ${targetBranch})...`,
message: '正在克隆后端代码...',
})
}
console.log(`📥 开始克隆代码到临时目录...`)
console.log(`克隆参数: --single-branch --depth 1 --branch ${targetBranch}`)
await new Promise<void>((resolve, reject) => {
const proc = spawn(
gitPath,
@@ -547,7 +276,7 @@ export async function cloneBackend(
'--depth',
'1',
'--branch',
targetBranch,
'feature/refactor',
repoUrl,
tmpDir,
],
@@ -557,51 +286,26 @@ export async function cloneBackend(
cwd: appRoot,
}
)
proc.stdout?.on('data', d => console.log('git clone stdout:', d.toString().trim()))
proc.stderr?.on('data', d => console.log('git clone stderr:', d.toString().trim()))
proc.on('close', code => {
console.log(`git clone 退出码: ${code}`)
if (code === 0) {
console.log('✅ 代码克隆成功')
resolve()
} else {
console.error('❌ 代码克隆失败')
reject(new Error(`git clone失败退出码: ${code}`))
}
})
proc.on('error', error => {
console.error('❌ git clone 进程错误:', error)
reject(error)
})
proc.stdout?.on('data', d => console.log('git clone:', d.toString()))
proc.stderr?.on('data', d => console.log('git clone err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git clone失败,退出码: ${code}`))
)
proc.on('error', reject)
})
// 复制所有文件到 backendPathappRoot包含 .git
console.log('📋 复制文件到目标目录...')
const tmpFiles = fs.readdirSync(tmpDir)
console.log(`临时目录中的文件: ${tmpFiles.join(', ')}`)
for (const file of tmpFiles) {
const src = path.join(tmpDir, file)
const dst = path.join(backendPath, file)
console.log(`复制: ${file}`)
if (fs.existsSync(dst)) {
console.log(` - 删除现有文件/目录: ${dst}`)
if (fs.statSync(dst).isDirectory()) fs.rmSync(dst, { recursive: true, force: true })
else fs.unlinkSync(dst)
}
if (fs.statSync(src).isDirectory()) {
console.log(` - 复制目录: ${src} -> ${dst}`)
copyDirSync(src, dst)
} else {
console.log(` - 复制文件: ${src} -> ${dst}`)
fs.copyFileSync(src, dst)
}
if (fs.statSync(src).isDirectory()) copyDirSync(src, dst)
else fs.copyFileSync(src, dst)
}
console.log('🗑️ 清理临时目录...')
fs.rmSync(tmpDir, { recursive: true, force: true })
if (mainWindow) {
@@ -609,20 +313,14 @@ export async function cloneBackend(
type: 'backend',
progress: 100,
status: 'completed',
message: `后端代码克隆完成(分支: ${targetBranch})`,
message: '后端代码克隆完成',
})
}
console.log(`✅ 后端代码克隆完成(分支: ${targetBranch})`)
}
console.log('=== 后端代码获取操作完成 ===')
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('获取后端代码失败:', errorMessage)
console.error('错误堆栈:', error instanceof Error ? error.stack : 'N/A')
console.error('获取后端代码失败:', errorMessage)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
@@ -633,4 +331,4 @@ export async function cloneBackend(
}
return { success: false, error: errorMessage }
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "v5.0.0-alpha.3",
"version": "5.0.0-alpha.2",
"main": "dist-electron/main.js",
"scripts": {
"dev": "concurrently \"vite\" \"yarn watch:main\" \"yarn electron-dev\"",
@@ -113,4 +113,4 @@
"@types/node": "22.17.1"
},
"packageManager": "yarn@4.9.1"
}
}

View File

@@ -1,509 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>操作确认</title>
<style>
:root {
/* 亮色模式变量 */
--primary-color: #1677ff;
--primary-hover: #4096ff;
--primary-active: #0958d9;
--danger-color: #ff4d4f;
--danger-hover: #ff7875;
--danger-active: #d9363e;
--success-color: #52c41a;
--warning-color: #faad14;
--text-primary: #262626;
--text-secondary: #595959;
--text-tertiary: #8c8c8c;
--text-disabled: #bfbfbf;
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f5f5f5;
--bg-quaternary: #f0f0f0;
--border-primary: #d9d9d9;
--border-secondary: #f0f0f0;
--border-hover: #4096ff;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
/* 暗色模式变量 */
[data-theme="dark"] {
--primary-color: #1668dc;
--primary-hover: #3c89e8;
--primary-active: #1554ad;
--danger-color: #ff4d4f;
--danger-hover: #ff7875;
--danger-active: #d9363e;
--text-primary: #ffffff;
--text-secondary: #c9cdd4;
--text-tertiary: #a6adb4;
--text-disabled: #6c757d;
--bg-primary: #1f1f1f;
--bg-secondary: #2a2a2a;
--bg-tertiary: #373737;
--bg-quaternary: #404040;
--border-primary: #434343;
--border-secondary: #303030;
--border-hover: #3c89e8;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.15), 0 1px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px 0 rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.25), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
font-size: 14px;
line-height: 1.5715;
color: var(--text-primary);
background: var(--bg-primary);
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
transition: color 0.2s ease, background-color 0.2s ease;
user-select: none;
display: flex;
flex-direction: column;
}
.dialog-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
transition: background-color 0.2s ease, border-color 0.2s ease;
box-shadow: var(--shadow-lg);
}
.dialog-header {
padding: 12px 16px 8px 16px;
background: var(--bg-primary);
border-radius: var(--radius-md) var(--radius-md) 0 0;
transition: background-color 0.2s ease;
cursor: move;
-webkit-app-region: drag;
border-bottom: 1px solid var(--border-secondary);
}
.dialog-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
text-align: center;
transition: color 0.2s ease;
}
.dialog-content {
padding: 16px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
transition: background-color 0.2s ease;
}
.dialog-message {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
word-wrap: break-word;
white-space: pre-wrap;
text-align: center;
transition: color 0.2s ease;
max-width: 100%;
}
.dialog-actions {
padding: 12px 16px 12px 16px;
display: flex;
justify-content: center;
gap: 8px;
background: var(--bg-primary);
border-radius: 0 0 var(--radius-md) var(--radius-md);
transition: background-color 0.2s ease;
}
.dialog-button {
padding: 6px 12px;
height: 28px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
min-width: 60px;
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
font-family: var(--font-family);
line-height: 1.5715;
}
.dialog-button:hover {
background: var(--bg-quaternary);
border-color: var(--border-hover);
color: var(--primary-color);
}
.dialog-button:focus {
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2);
border-color: var(--border-hover);
outline: none;
}
.dialog-button:active {
transform: translateY(0);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
.dialog-button.primary {
background: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
}
.dialog-button.primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: #fff;
}
.dialog-button.primary:active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.dialog-button.danger {
background: var(--danger-color);
color: #fff;
border-color: var(--danger-color);
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
}
.dialog-button.danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
color: #fff;
}
.dialog-button.danger:active {
background: var(--danger-active);
border-color: var(--danger-active);
}
/* 键盘导航样式 */
.dialog-button:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* 动画效果 */
.dialog-container {
animation: dialogFadeIn 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
@keyframes dialogFadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 响应式设计 */
@media (max-width: 350px) {
.dialog-header {
padding: 8px 12px 6px 12px;
}
.dialog-title {
font-size: 12px;
}
.dialog-content {
padding: 12px;
}
.dialog-message {
font-size: 11px;
}
.dialog-actions {
padding: 8px 12px 8px 12px;
flex-direction: column;
gap: 6px;
}
.dialog-button {
width: 100%;
margin: 0;
font-size: 11px;
height: 24px;
}
}
</style>
</head>
<body>
<div class="dialog-container" role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-message">
<div class="dialog-header">
<h3 class="dialog-title" id="dialog-title">操作确认</h3>
</div>
<div class="dialog-content">
<p class="dialog-message" id="dialog-message">是否要执行此操作?</p>
</div>
<div class="dialog-actions" id="dialog-actions" role="group" aria-label="对话框操作按钮">
<!-- 按钮将通过 JavaScript 动态生成 -->
</div>
</div>
<script>
// 主题管理
const ThemeManager = {
init() {
this.applyTheme();
this.listenForThemeChanges();
},
applyTheme() {
// 优先从 Electron 主进程获取软件内主题状态
if (window.electronAPI && window.electronAPI.getTheme) {
window.electronAPI.getTheme().then(theme => {
document.documentElement.setAttribute('data-theme', theme);
}).catch(() => {
// 如果获取失败,使用系统主题
this.useSystemTheme();
});
} else {
this.useSystemTheme();
}
},
useSystemTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
},
listenForThemeChanges() {
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// 如果软件主题配置为系统跟随,则更新主题
if (!window.electronAPI || !window.electronAPI.getTheme) {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
} else {
// 重新获取软件内主题状态
this.applyTheme();
}
});
// 监听 Electron 主题变化
if (window.electronAPI && window.electronAPI.onThemeChanged) {
window.electronAPI.onThemeChanged((theme) => {
document.documentElement.setAttribute('data-theme', theme);
});
}
}
};
// 拖拽管理
const DragManager = {
isDragging: false,
startX: 0,
startY: 0,
init() {
const header = document.querySelector('.dialog-header');
if (!header) return;
header.addEventListener('mousedown', this.handleMouseDown.bind(this));
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
// 防止文本选择
header.addEventListener('selectstart', (e) => e.preventDefault());
},
handleMouseDown(e) {
// 只有在头部区域才能拖拽
if (!e.target.closest('.dialog-header')) return;
this.isDragging = true;
this.startX = e.clientX;
this.startY = e.clientY;
const container = document.querySelector('.dialog-container');
container.style.transition = 'none';
e.preventDefault();
},
handleMouseMove(e) {
if (!this.isDragging) return;
const deltaX = e.clientX - this.startX;
const deltaY = e.clientY - this.startY;
// 通知 Electron 移动窗口
if (window.electronAPI && window.electronAPI.moveWindow) {
window.electronAPI.moveWindow(deltaX, deltaY);
}
e.preventDefault();
},
handleMouseUp(e) {
if (!this.isDragging) return;
this.isDragging = false;
const container = document.querySelector('.dialog-container');
container.style.transition = '';
e.preventDefault();
}
};
// 窗口加载完成后的初始化
window.addEventListener('load', () => {
// 初始化主题管理
ThemeManager.init();
});
// 获取传递的参数
const urlParams = new URLSearchParams(window.location.search);
const data = JSON.parse(decodeURIComponent(urlParams.get('data') || '{}'));
// 设置对话框内容
document.getElementById('dialog-title').textContent = data.title || '操作确认';
document.getElementById('dialog-message').textContent = data.message || '是否要执行此操作?';
// 创建按钮
const actionsContainer = document.getElementById('dialog-actions');
const options = data.options || ['确定', '取消'];
options.forEach((option, index) => {
const button = document.createElement('button');
button.className = 'dialog-button';
button.textContent = option;
// 根据按钮文本设置样式
if (option.includes('确定') || option.includes('是') || option.includes('同意')) {
button.className += ' primary';
} else if (option.includes('删除') || option.includes('危险')) {
button.className += ' danger';
}
// 绑定点击事件
button.addEventListener('click', () => {
// 添加点击动画
button.style.transform = 'scale(0.98)';
setTimeout(() => {
button.style.transform = '';
}, 100);
// 发送结果到主进程
if (window.electronAPI && window.electronAPI.dialogResponse) {
const choice = index === 0; // 第一个选项为 true
window.electronAPI.dialogResponse(data.messageId, choice);
}
});
actionsContainer.appendChild(button);
});
// 自动聚焦第一个按钮
setTimeout(() => {
const firstButton = actionsContainer.querySelector('.dialog-button');
if (firstButton) {
firstButton.focus();
}
}, 100);
// 键盘事件处理
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
// ESC 键相当于取消
if (window.electronAPI && window.electronAPI.dialogResponse) {
window.electronAPI.dialogResponse(data.messageId, false);
}
} else if (event.key === 'Enter') {
// Enter 键相当于确定
const focusedButton = document.activeElement;
if (focusedButton && focusedButton.classList.contains('dialog-button')) {
focusedButton.click();
} else {
// 如果没有聚焦按钮,默认点击第一个
const firstButton = actionsContainer.querySelector('.dialog-button');
if (firstButton) {
firstButton.click();
}
}
} else if (event.key === 'Tab') {
// Tab 键在按钮间切换
const buttons = Array.from(actionsContainer.querySelectorAll('.dialog-button'));
const currentIndex = buttons.indexOf(document.activeElement);
if (event.shiftKey) {
// Shift+Tab 向前切换
const prevIndex = currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1;
buttons[prevIndex].focus();
} else {
// Tab 向后切换
const nextIndex = currentIndex >= buttons.length - 1 ? 0 : currentIndex + 1;
buttons[nextIndex].focus();
}
event.preventDefault();
}
});
// 窗口加载完成后的初始化
window.addEventListener('load', () => {
// 初始化主题管理
ThemeManager.init();
// 初始化拖拽管理
DragManager.init();
});
</script>
</body>
</html>

View File

@@ -171,10 +171,45 @@
基建: {{ user.Info.InfrastMode === 'Normal' ? '普通' : '自定义' }}
</a-tag>
<!-- 关卡信息 - Stage固定展示 -->
<a-tag v-if="user.Info.Stage" class="info-tag" color="blue">
关卡: {{ user.Info.Stage === '-' ? '未选择' : user.Info.Stage }}
</a-tag>
<!-- 关卡信息 - 根据是否使用计划表配置显示不同内容 -->
<template v-if="user.Info.Stage === '1-7' && props.currentPlanData">
<!-- 计划表模式信息 -->
<a-tag
v-if="props.currentPlanData.Info?.Mode"
class="info-tag"
color="purple"
>
模式:
{{ props.currentPlanData.Info.Mode === 'ALL' ? '全局' : '周计划' }}
</a-tag>
<!-- 显示计划表中的所有关卡 -->
<template v-for="(stageInfo, index) in getAllPlanStages()" :key="index">
<a-tag class="info-tag" color="green">
{{ stageInfo.label }}: {{ stageInfo.value }}
</a-tag>
</template>
<!-- 如果没有配置任何关卡,显示提示 -->
<a-tag
v-if="getAllPlanStages().length === 0"
class="info-tag"
color="orange"
>
关卡: 计划表未配置
</a-tag>
</template>
<!-- 用户自定义关卡 -->
<template v-else>
<a-tag
v-if="user.Info.Stage"
class="info-tag"
:color="getStageTagColor(user.Info.Stage)"
>
关卡: {{ getDisplayStage(user.Info.Stage) }}
</a-tag>
</template>
<!-- 额外关卡 - 只有不为-或空时才显示 -->
<a-tag
@@ -327,6 +362,7 @@ import { message } from 'ant-design-vue'
interface Props {
scripts: Script[]
activeConnections: Map<string, { subscriptionId: string; websocketId: string }>
currentPlanData?: Record<string, any> | null
}
interface Emits {
@@ -419,6 +455,12 @@ const getRemainingDayColor = (remainedDay: number): string => {
return 'green'
}
// 获取关卡标签颜色
const getStageTagColor = (stage: string): string => {
if (stage === '1-7') return 'green' // 使用计划表配置用绿色
return 'blue' // 自定义关卡用蓝色
}
// 获取剩余天数的显示文本
const getRemainingDayText = (remainedDay: number): string => {
if (remainedDay === -1) return '剩余天数: 长期有效'
@@ -426,6 +468,102 @@ const getRemainingDayText = (remainedDay: number): string => {
return `剩余天数: ${remainedDay}`
}
// 获取关卡的显示文本
const getDisplayStage = (stage: string): string => {
if (stage === '-') return '未选择'
// 如果是默认值且有计划表数据,显示计划表中的实际关卡
if (stage === '1-7' && props.currentPlanData) {
const planStage = getCurrentPlanStage()
if (planStage && planStage !== '-') {
return planStage
}
return '使用计划表配置'
}
return stage
}
// 从计划表获取当前关卡
const getCurrentPlanStage = (): string => {
if (!props.currentPlanData) return ''
// 根据当前时间确定使用哪个时间段的配置
const planMode = props.currentPlanData.Info?.Mode || 'ALL'
let timeKey = 'ALL'
if (planMode === 'Weekly') {
// 如果是周模式,根据当前星期几获取对应配置
const today = new Date().getDay() // 0=Sunday, 1=Monday, ...
const dayMap = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
timeKey = dayMap[today]
}
// 从计划表获取关卡配置
const timeConfig = props.currentPlanData[timeKey]
if (!timeConfig) return ''
// 获取主要关卡
if (timeConfig.Stage && timeConfig.Stage !== '-') {
return timeConfig.Stage
}
// 如果主要关卡为空,尝试获取第一个备选关卡
const backupStages = [timeConfig.Stage_1, timeConfig.Stage_2, timeConfig.Stage_3]
for (const stage of backupStages) {
if (stage && stage !== '-') {
return stage
}
}
return ''
}
// 从计划表获取所有配置的关卡
const getAllPlanStages = (): Array<{ label: string; value: string }> => {
if (!props.currentPlanData) return []
// 根据当前时间确定使用哪个时间段的配置
const planMode = props.currentPlanData.Info?.Mode || 'ALL'
let timeKey = 'ALL'
if (planMode === 'Weekly') {
// 如果是周模式,根据当前星期几获取对应配置
const today = new Date().getDay() // 0=Sunday, 1=Monday, ...
const dayMap = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
timeKey = dayMap[today]
}
// 从计划表获取关卡配置
const timeConfig = props.currentPlanData[timeKey]
if (!timeConfig) return []
const stages: Array<{ label: string; value: string }> = []
// 主关卡
if (timeConfig.Stage && timeConfig.Stage !== '-') {
stages.push({ label: '关卡', value: timeConfig.Stage })
}
// 备选关卡
if (timeConfig.Stage_1 && timeConfig.Stage_1 !== '-') {
stages.push({ label: '关卡1', value: timeConfig.Stage_1 })
}
if (timeConfig.Stage_2 && timeConfig.Stage_2 !== '-') {
stages.push({ label: '关卡2', value: timeConfig.Stage_2 })
}
if (timeConfig.Stage_3 && timeConfig.Stage_3 !== '-') {
stages.push({ label: '关卡3', value: timeConfig.Stage_3 })
}
// 剩余关卡
if (timeConfig.Stage_Remain && timeConfig.Stage_Remain !== '-') {
stages.push({ label: '剩余关卡', value: timeConfig.Stage_Remain })
}
return stages
}
// 处理脚本拖拽结束
const onScriptDragEnd = async () => {
try {

View File

@@ -1,69 +1,97 @@
<template>
<div style="display: none">
<!-- 这是一个隐藏的监听组件不需要UI -->
<!-- 现在使用系统级对话框窗口而不是应用内弹窗 -->
</div>
<!-- 简单的自定义对话框 -->
<div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ dialogData.title }}</h3>
</div>
<div class="dialog-content">
<p>{{ dialogData.message }}</p>
</div>
<div class="dialog-actions">
<button
v-for="(option, index) in dialogData.options"
:key="index"
class="dialog-button"
@click="handleChoice(index)"
>
{{ option }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useWebSocket, type WebSocketBaseMessage } from '@/composables/useWebSocket'
import { logger } from '@/utils/logger'
// WebSocket hook
const { subscribe, unsubscribe, sendRaw } = useWebSocket()
// 对话框状态
const showDialog = ref(false)
const dialogData = ref({
title: '',
message: '',
options: ['确定', '取消'],
messageId: ''
})
// 存储订阅ID用于取消订阅
let subscriptionId: string
// 检查是否在 Electron 环境中
const isElectron = () => {
return typeof window !== 'undefined' && (window as any).electronAPI
}
// 发送用户选择结果到后端
const sendResponse = (messageId: string, choice: boolean) => {
const response = {"choice": choice}
const response = {
message_id: messageId,
choice: choice,
}
logger.info('[WebSocket消息监听器] 发送用户选择结果:', response)
// 发送响应消息到后端
sendRaw('Response', response, messageId)
sendRaw('Response', response)
}
// 显示系统级问题对话框
const showQuestion = async (questionData: any) => {
// 处理用户选择
const handleChoice = (choiceIndex: number) => {
const choice = choiceIndex === 0 // 第一个选项为true其他为false
sendResponse(dialogData.value.messageId, choice)
showDialog.value = false
}
// 显示问题对话框
const showQuestion = (questionData: any) => {
const title = questionData.title || '操作提示'
const message = questionData.message || ''
const options = questionData.options || ['确定', '取消']
const messageId = questionData.message_id || 'fallback_' + Date.now()
logger.info('[WebSocket消息监听器] 显示系统级对话框:', questionData)
logger.info('[WebSocket消息监听器] 显示自定义对话框:', questionData)
if (!isElectron()) {
logger.error('[WebSocket消息监听器] 不在 Electron 环境中,无法显示系统级对话框')
// 在非 Electron 环境中,使用默认响应
sendResponse(messageId, false)
return
// 设置对话框数据
dialogData.value = {
title,
message,
options,
messageId
}
try {
// 调用 Electron API 显示系统级对话框
const result = await (window as any).electronAPI.showQuestionDialog({
title,
message,
options,
messageId
})
logger.info('[WebSocket消息监听器] 系统级对话框返回结果:', result)
// 发送结果到后端
sendResponse(messageId, result)
} catch (error) {
logger.error('[WebSocket消息监听器] 显示系统级对话框失败:', error)
// 出错时发送默认响应
sendResponse(messageId, false)
}
showDialog.value = true
// 在下一个tick自动聚焦第一个按钮
nextTick(() => {
const firstButton = document.querySelector('.dialog-button:first-child') as HTMLButtonElement
if (firstButton) {
firstButton.focus()
}
})
}
// 消息处理函数
@@ -109,14 +137,14 @@ const handleObjectMessage = (data: any) => {
logger.info('[WebSocket消息监听器] 发现Question类型消息')
if (data.message_id) {
logger.info('[WebSocket消息监听器] message_id存在显示系统级对话框')
logger.info('[WebSocket消息监听器] message_id存在显示选择弹窗')
showQuestion(data)
return
} else {
logger.warn('[WebSocket消息监听器] Question消息缺少message_id字段:', data)
// 即使缺少message_id也尝试显示对话框使用当前时间戳作为ID
// 即使缺少message_id也尝试显示弹窗使用当前时间戳作为ID
const fallbackId = 'fallback_' + Date.now()
logger.info('[WebSocket消息监听器] 使用备用ID显示对话框:', fallbackId)
logger.info('[WebSocket消息监听器] 使用备用ID显示弹窗:', fallbackId)
showQuestion({
...data,
message_id: fallbackId
@@ -182,4 +210,150 @@ onUnmounted(() => {
})
</script>
<style scoped>
/* 对话框遮罩层 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
/* 对话框容器 */
.dialog-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 300px;
max-width: 500px;
width: 90%;
animation: dialogAppear 0.2s ease-out;
}
/* 对话框头部 */
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
/* 对话框内容 */
.dialog-content {
padding: 20px;
}
.dialog-content p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #666;
word-break: break-word;
}
/* 按钮区域 */
.dialog-actions {
padding: 12px 20px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 按钮样式 */
.dialog-button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #333;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
min-width: 60px;
}
.dialog-button:hover {
background: #f5f5f5;
border-color: #999;
}
.dialog-button:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
.dialog-button:first-child {
background: #007bff;
color: white;
border-color: #007bff;
}
.dialog-button:first-child:hover {
background: #0056b3;
border-color: #0056b3;
}
/* 出现动画 */
@keyframes dialogAppear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.dialog-container {
background: #2d2d2d;
color: #fff;
}
.dialog-header {
border-bottom-color: #444;
}
.dialog-header h3 {
color: #fff;
}
.dialog-content p {
color: #ccc;
}
.dialog-button {
background: #444;
color: #fff;
border-color: #555;
}
.dialog-button:hover {
background: #555;
border-color: #666;
}
.dialog-button:first-child {
background: #0d6efd;
border-color: #0d6efd;
}
.dialog-button:first-child:hover {
background: #0b5ed7;
border-color: #0b5ed7;
}
}
</style>

View File

@@ -1,630 +1 @@
<template>
<div class="backend-launch-page">
<div class="section">
<h3 class="section-title">🚀 后端服务控制</h3>
<!-- 后端状态显示 -->
<div class="status-card" :class="{ running: isBackendRunning, stopped: !isBackendRunning }">
<div class="status-indicator">
<span class="status-dot" :class="{ active: isBackendRunning }"></span>
<span class="status-text">
{{ isBackendRunning ? '运行中' : '已停止' }}
</span>
</div>
<div v-if="backendPid" class="pid-info">
PID: {{ backendPid }}
</div>
</div>
<!-- 控制按钮 -->
<div class="action-buttons">
<button
@click="startBackend"
:disabled="isLoading || isBackendRunning"
class="action-btn start-btn"
>
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else></span>
启动后端
</button>
<button
@click="stopBackend"
:disabled="isLoading || !isBackendRunning"
class="action-btn stop-btn"
>
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else></span>
停止后端
</button>
<button
@click="refreshStatus"
:disabled="isLoading"
class="action-btn refresh-btn"
>
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else>🔄</span>
刷新状态
</button>
</div>
<!-- 操作结果显示 -->
<div v-if="lastResult" class="result-card" :class="{ success: lastResult.success, error: !lastResult.success }">
<div class="result-title">
{{ lastResult.success ? '✅ 操作成功' : '❌ 操作失败' }}
</div>
<div v-if="lastResult.message" class="result-message">
{{ lastResult.message }}
</div>
<div v-if="lastResult.error" class="result-error">
错误: {{ lastResult.error }}
</div>
</div>
</div>
<!-- 进程信息 -->
<div class="section">
<h3 class="section-title">📊 进程信息</h3>
<div class="process-info">
<div class="info-row">
<span class="info-label">Python路径:</span>
<span class="info-value">{{ pythonPath || '未检测到' }}</span>
</div>
<div class="info-row">
<span class="info-label">主文件:</span>
<span class="info-value">{{ mainPyPath || '未检测到' }}</span>
</div>
<div class="info-row">
<span class="info-label">工作目录:</span>
<span class="info-value">{{ workingDir || '未知' }}</span>
</div>
</div>
<button @click="getProcessInfo" :disabled="isLoading" class="action-btn info-btn">
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else>🔍</span>
获取进程信息
</button>
</div>
<!-- 快速操作 -->
<div class="section">
<h3 class="section-title"> 快速操作</h3>
<div class="quick-actions">
<button @click="restartBackend" :disabled="isLoading" class="action-btn restart-btn">
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else>🔄</span>
重启后端
</button>
<button @click="forceKillProcesses" :disabled="isLoading" class="action-btn kill-btn">
<span v-if="isLoading" class="loading-spinner"></span>
<span v-else>💀</span>
强制结束所有进程
</button>
</div>
</div>
<!-- 日志区域 -->
<div class="section">
<h3 class="section-title">📝 操作日志</h3>
<div class="log-container">
<div v-if="logs.length === 0" class="no-logs">
暂无日志记录
</div>
<div v-else class="log-entries">
<div
v-for="(log, index) in logs"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<button @click="clearLogs" class="action-btn clear-btn">
🗑 清空日志
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// 临时的类型断言确保能访问到完整的electronAPI
const electronAPI = (window as any).electronAPI
// 状态管理
const isBackendRunning = ref(false)
const isLoading = ref(false)
const backendPid = ref<number | null>(null)
const lastResult = ref<{ success: boolean; message?: string; error?: string } | null>(null)
// 进程信息
const pythonPath = ref<string>('')
const mainPyPath = ref<string>('')
const workingDir = ref<string>('')
// 日志管理
const logs = ref<Array<{ time: string; message: string; type: 'info' | 'success' | 'error' }>>([])
// 添加日志
const addLog = (message: string, type: 'info' | 'success' | 'error' = 'info') => {
const now = new Date()
const time = now.toLocaleTimeString()
logs.value.unshift({ time, message, type })
// 限制日志数量
if (logs.value.length > 50) {
logs.value = logs.value.slice(0, 50)
}
}
// 清空日志
const clearLogs = () => {
logs.value = []
addLog('日志已清空', 'info')
}
// 启动后端
const startBackend = async () => {
if (isLoading.value) return
isLoading.value = true
lastResult.value = null
addLog('正在启动后端服务...', 'info')
try {
const result = await electronAPI.startBackend()
if (result.success) {
lastResult.value = { success: true, message: '后端服务启动成功' }
addLog('✅ 后端服务启动成功', 'success')
await refreshStatus()
} else {
lastResult.value = { success: false, error: result.error }
addLog(`❌ 后端服务启动失败: ${result.error}`, 'error')
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
lastResult.value = { success: false, error: errorMsg }
addLog(`❌ 启动后端时出现异常: ${errorMsg}`, 'error')
} finally {
isLoading.value = false
}
}
// 停止后端
const stopBackend = async () => {
if (isLoading.value) return
isLoading.value = true
lastResult.value = null
addLog('正在停止后端服务...', 'info')
try {
// 检查stopBackend方法是否存在
if (electronAPI.stopBackend) {
const result = await electronAPI.stopBackend()
if (result.success) {
lastResult.value = { success: true, message: '后端服务已停止' }
addLog('✅ 后端服务已停止', 'success')
await refreshStatus()
} else {
lastResult.value = { success: false, error: result.error }
addLog(`❌ 停止后端服务失败: ${result.error}`, 'error')
}
} else {
// 如果没有stopBackend方法使用强制结束进程的方式
addLog(' 使用强制结束进程的方式停止后端', 'info')
await forceKillProcesses()
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
lastResult.value = { success: false, error: errorMsg }
addLog(`❌ 停止后端时出现异常: ${errorMsg}`, 'error')
} finally {
isLoading.value = false
}
}
// 重启后端
const restartBackend = async () => {
if (isLoading.value) return
addLog('正在重启后端服务...', 'info')
// 先停止
if (isBackendRunning.value) {
await stopBackend()
// 等待一秒确保完全停止
await new Promise(resolve => setTimeout(resolve, 1000))
}
// 再启动
await startBackend()
}
// 强制结束所有相关进程
const forceKillProcesses = async () => {
if (isLoading.value) return
isLoading.value = true
addLog('正在强制结束所有相关进程...', 'info')
try {
const result = await electronAPI.killAllProcesses()
if (result.success) {
lastResult.value = { success: true, message: '所有相关进程已强制结束' }
addLog('✅ 所有相关进程已强制结束', 'success')
await refreshStatus()
} else {
lastResult.value = { success: false, error: result.error }
addLog(`❌ 强制结束进程失败: ${result.error}`, 'error')
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
lastResult.value = { success: false, error: errorMsg }
addLog(`❌ 强制结束进程时出现异常: ${errorMsg}`, 'error')
} finally {
isLoading.value = false
}
}
// 刷新状态
const refreshStatus = async () => {
if (isLoading.value) return
isLoading.value = true
addLog('正在刷新后端状态...', 'info')
try {
// 获取相关进程信息
const processes = await electronAPI.getRelatedProcesses()
// 检查是否有Python进程在运行main.py
const backendProcess = processes.find((proc: any) =>
proc.command && proc.command.includes('main.py')
)
if (backendProcess) {
isBackendRunning.value = true
backendPid.value = backendProcess.pid
addLog(`✅ 检测到后端进程 (PID: ${backendProcess.pid})`, 'success')
} else {
isBackendRunning.value = false
backendPid.value = null
addLog(' 未检测到后端进程', 'info')
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
addLog(`❌ 刷新状态失败: ${errorMsg}`, 'error')
} finally {
isLoading.value = false
}
}
// 获取进程信息
const getProcessInfo = async () => {
if (isLoading.value) return
isLoading.value = true
addLog('正在获取进程信息...', 'info')
try {
// 这里可以调用一些API来获取Python路径等信息
// 暂时使用模拟数据
pythonPath.value = 'environment/python/python.exe'
mainPyPath.value = 'main.py'
workingDir.value = window.location.origin
addLog('✅ 进程信息获取完成', 'success')
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
addLog(`❌ 获取进程信息失败: ${errorMsg}`, 'error')
} finally {
isLoading.value = false
}
}
// 定时刷新状态
let statusInterval: NodeJS.Timeout | null = null
onMounted(() => {
addLog('📱 后端控制面板已加载', 'info')
// 初始化时获取状态
refreshStatus()
getProcessInfo()
// 每5秒自动刷新状态
statusInterval = setInterval(() => {
refreshStatus()
}, 5000)
})
onUnmounted(() => {
if (statusInterval) {
clearInterval(statusInterval)
}
})
</script>
<style scoped>
.backend-launch-page {
font-size: 11px;
line-height: 1.4;
}
.section {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.section-title {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: bold;
color: #4caf50;
}
/* 状态卡片 */
.status-card {
padding: 8px;
border-radius: 4px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-card.running {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
}
.status-card.stopped {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.3);
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f44336;
animation: pulse 2s infinite;
}
.status-dot.active {
background: #4caf50;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.status-text {
font-weight: bold;
}
.pid-info {
margin-top: 4px;
font-size: 10px;
color: #888;
}
/* 按钮样式 */
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.action-btn {
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
font-size: 10px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.start-btn:hover:not(:disabled) {
background: rgba(76, 175, 80, 0.3);
border-color: rgba(76, 175, 80, 0.5);
}
.stop-btn:hover:not(:disabled) {
background: rgba(244, 67, 54, 0.3);
border-color: rgba(244, 67, 54, 0.5);
}
.restart-btn:hover:not(:disabled) {
background: rgba(255, 193, 7, 0.3);
border-color: rgba(255, 193, 7, 0.5);
}
.kill-btn:hover:not(:disabled) {
background: rgba(156, 39, 176, 0.3);
border-color: rgba(156, 39, 176, 0.5);
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 结果卡片 */
.result-card {
padding: 8px;
border-radius: 4px;
margin-bottom: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.result-card.success {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
}
.result-card.error {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.3);
}
.result-title {
font-weight: bold;
margin-bottom: 4px;
}
.result-message, .result-error {
font-size: 10px;
line-height: 1.3;
}
.result-error {
color: #ff6b6b;
}
/* 进程信息 */
.process-info {
margin-bottom: 8px;
}
.info-row {
display: flex;
margin-bottom: 4px;
font-size: 10px;
}
.info-label {
width: 60px;
color: #888;
flex-shrink: 0;
}
.info-value {
color: #fff;
word-break: break-all;
}
/* 日志区域 */
.log-container {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
max-height: 120px;
overflow-y: auto;
margin-bottom: 8px;
}
.no-logs {
padding: 8px;
text-align: center;
color: #888;
font-size: 10px;
}
.log-entries {
padding: 4px;
}
.log-entry {
padding: 2px 4px;
font-size: 9px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 6px;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.success {
color: #4caf50;
}
.log-entry.error {
color: #f44336;
}
.log-entry.info {
color: #888;
}
.log-time {
color: #666;
font-size: 8px;
min-width: 60px;
}
.log-message {
flex: 1;
word-break: break-all;
}
.log-container::-webkit-scrollbar {
width: 3px;
}
.log-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.log-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
</style>
.log-entry.error .log-message {

View File

@@ -74,7 +74,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
import { logger } from '@/utils/logger'
const { subscribe, unsubscribe, getConnectionInfo } = useWebSocket()
const { subscribe, unsubscribe, sendRaw, getConnectionInfo } = useWebSocket()
// 测试状态
const isTesting = ref(false)
@@ -165,39 +165,27 @@ const directTriggerModal = () => {
}, 1000)
}
// 直接调用弹窗API测试功能
// 发送WebSocket消息来模拟接收消息
const simulateMessage = (messageData: any) => {
logger.info('[调试工具] 直接测试弹窗功能:', messageData)
logger.info('[调试工具] 发送模拟消息:', messageData)
// 检查连接状态
const connInfo = getConnectionInfo()
if (connInfo.status !== '已连接') {
logger.warn('[调试工具] WebSocket未连接无法发送消息')
lastResponse.value = '发送失败: WebSocket未连接'
return
}
try {
// 检查是否在Electron环境
if (typeof window !== 'undefined' && (window as any).electronAPI?.showQuestionDialog) {
// 直接调用Electron的弹窗API进行测试
(window as any).electronAPI.showQuestionDialog({
title: messageData.title || '测试标题',
message: messageData.message || '测试消息',
options: messageData.options || ['确定', '取消'],
messageId: messageData.message_id || 'test-' + Date.now()
}).then((result: boolean) => {
logger.info('[调试工具] 弹窗测试结果:', result)
const choice = result ? '确认' : '取消'
lastResponse.value = `用户选择: ${choice}`
addTestHistory('弹窗测试', choice)
}).catch((error: any) => {
logger.error('[调试工具] 弹窗测试失败:', error)
lastResponse.value = '弹窗测试失败: ' + (error?.message || '未知错误')
})
} else {
logger.warn('[调试工具] 不在Electron环境中或API不可用使用浏览器confirm作为备用')
const result = confirm(`${messageData.title || '测试'}\n\n${messageData.message || '这是测试消息'}`)
const choice = result ? '确认' : '取消'
lastResponse.value = `用户选择: ${choice} (浏览器备用)`
addTestHistory('浏览器备用测试', choice)
}
// 使用sendRaw直接发送Message类型的消息
sendRaw('Message', messageData)
logger.info('[调试工具] 消息已发送到WebSocket')
lastResponse.value = '消息已发送,等待弹窗显示...'
} catch (error: any) {
logger.error('[调试工具] 测试弹窗失败:', error)
lastResponse.value = '测试失败: ' + (error?.message || '未知错误')
logger.error('[调试工具] 发送消息失败:', error)
lastResponse.value = '发送失败: ' + (error?.message || '未知错误')
}
}

View File

@@ -44,28 +44,22 @@ import RouteInfoPage from './RouteInfoPage.vue'
import EnvironmentPage from './EnvironmentPage.vue'
import QuickNavPage from './QuickNavPage.vue'
import MessageTestPage from './MessageTestPage.vue'
import BackendLaunchPage from './BackendLaunchPage.vue'
// 调试页面配置
const tabs = [
{ key: 'route', title: '路由', icon: '🛣️', component: RouteInfoPage },
{ key: 'env', title: '环境', icon: '⚙️', component: EnvironmentPage },
{ key: 'backend', title: '后端', icon: '🚀', component: BackendLaunchPage },
{ key: 'nav', title: '导航', icon: '🧭', component: QuickNavPage },
{ key: 'nav', title: '导航', icon: '🚀', component: QuickNavPage },
{ key: 'message', title: '消息', icon: '💬', component: MessageTestPage },
]
// 开发环境检测
const isDev = ref(
process.env.NODE_ENV === 'development' ||
(import.meta as any).env?.DEV === true ||
window.location.hostname === 'localhost'
)
const isDev = ref(process.env.NODE_ENV === 'development' || import.meta.env?.DEV === true)
// 面板状态
const isCollapsed = ref(false)
const isDragging = ref(false)
const activeTab = ref('backend') // 默认显示后端页面
const activeTab = ref('route')
// 面板位置
const panelPosition = ref({

View File

@@ -71,9 +71,9 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { getConfig } from '@/utils/config'
import { mirrorManager } from '@/utils/mirrorManager'
import router from '@/router'
import { useUpdateChecker } from '@/composables/useUpdateChecker'
import { connectAfterBackendStart } from '@/composables/useWebSocket'
import { forceEnterApp } from '@/utils/appEntry'
import { message } from 'ant-design-vue'
// Props
@@ -154,12 +154,11 @@ function handleForceEnter() {
}
// 确认弹窗中的"我知道我在做什么"按钮,直接进入应用
async function handleForceEnterConfirm() {
function handleForceEnterConfirm() {
clearTimers()
aborted.value = true
forceEnterVisible.value = false
await forceEnterApp('自动模式-强行进入确认')
router.push('/home')
}
// 事件处理 - 增强重新配置环境按钮功能

View File

@@ -63,7 +63,7 @@
<script setup lang="ts">
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { forceEnterApp } from '@/utils/appEntry'
import router from '@/router'
// Props
interface Props {
@@ -79,8 +79,8 @@ function handleReconfigure() {
}
// 强行进入应用
async function handleForceEnter() {
await forceEnterApp('环境不完整-强行进入')
function handleForceEnter() {
router.push('/home')
}
</script>

View File

@@ -0,0 +1,531 @@
/**
* 计划表数据协调层
*
* 作为前端架构中的"交通指挥中心",负责:
* 1. 统一管理数据流
* 2. 协调视图间的同步
* 3. 处理与后端的通信
* 4. 提供统一的数据访问接口
*/
import { ref, computed } from 'vue'
import type { MaaPlanConfig, MaaPlanConfig_Item } from '@/api'
// 时间维度常量
export const TIME_KEYS = ['ALL', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] as const
export type TimeKey = typeof TIME_KEYS[number]
// 关卡槽位常量
export const STAGE_SLOTS = ['Stage', 'Stage_1', 'Stage_2', 'Stage_3'] as const
export type StageSlot = typeof STAGE_SLOTS[number]
// 统一的数据结构
export interface PlanDataState {
// 基础信息
info: {
name: string
mode: 'ALL' | 'Weekly'
type: string
}
// 时间维度的配置数据
timeConfigs: Record<TimeKey, {
medicineNumb: number
seriesNumb: string
stages: {
primary: string // Stage
backup1: string // Stage_1
backup2: string // Stage_2
backup3: string // Stage_3
remain: string // Stage_Remain
}
}>
// 自定义关卡定义
customStageDefinitions: {
custom_stage_1: string
custom_stage_2: string
custom_stage_3: string
}
}
// 关卡可用性信息
export interface StageAvailability {
value: string
text: string
days: number[]
}
export const STAGE_DAILY_INFO: StageAvailability[] = [
{ value: '-', text: '当前/上次', days: [1, 2, 3, 4, 5, 6, 7] },
{ value: '1-7', text: '1-7', days: [1, 2, 3, 4, 5, 6, 7] },
{ value: 'R8-11', text: 'R8-11', days: [1, 2, 3, 4, 5, 6, 7] },
{ value: '12-17-HARD', text: '12-17-HARD', days: [1, 2, 3, 4, 5, 6, 7] },
{ value: 'LS-6', text: '经验-6/5', days: [1, 2, 3, 4, 5, 6, 7] },
{ value: 'CE-6', text: '龙门币-6/5', days: [2, 4, 6, 7] },
{ value: 'AP-5', text: '红票-5', days: [1, 4, 6, 7] },
{ value: 'CA-5', text: '技能-5', days: [2, 3, 5, 7] },
{ value: 'SK-5', text: '碳-5', days: [1, 3, 5, 6] },
{ value: 'PR-A-1', text: '奶/盾芯片', days: [1, 4, 5, 7] },
{ value: 'PR-A-2', text: '奶/盾芯片组', days: [1, 4, 5, 7] },
{ value: 'PR-B-1', text: '术/狙芯片', days: [1, 2, 5, 6] },
{ value: 'PR-B-2', text: '术/狙芯片组', days: [1, 2, 5, 6] },
{ value: 'PR-C-1', text: '先/辅芯片', days: [3, 4, 6, 7] },
{ value: 'PR-C-2', text: '先/辅芯片组', days: [3, 4, 6, 7] },
{ value: 'PR-D-1', text: '近/特芯片', days: [2, 3, 6, 7] },
{ value: 'PR-D-2', text: '近/特芯片组', days: [2, 3, 6, 7] },
]
/**
* 计划表数据协调器
*/
export function usePlanDataCoordinator() {
// 当前计划表ID
const currentPlanId = ref<string>('default')
// localStorage 相关函数
const CUSTOM_STAGE_KEY_PREFIX = 'maa_custom_stage_definitions_'
const getStorageKey = (): string => {
return `${CUSTOM_STAGE_KEY_PREFIX}${currentPlanId.value}`
}
const getDefaultCustomStageDefinitions = () => ({
custom_stage_1: '',
custom_stage_2: '',
custom_stage_3: '',
})
const loadCustomStageDefinitionsFromStorage = () => {
try {
const storageKey = getStorageKey()
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
// 确保包含所有必需的键
return {
custom_stage_1: parsed.custom_stage_1 || '',
custom_stage_2: parsed.custom_stage_2 || '',
custom_stage_3: parsed.custom_stage_3 || '',
}
}
} catch (error) {
console.error('[自定义关卡] localStorage 恢复失败:', error)
}
return getDefaultCustomStageDefinitions()
}
const saveCustomStageDefinitionsToStorage = (definitions: Record<string, string>) => {
try {
const storageKey = getStorageKey()
localStorage.setItem(storageKey, JSON.stringify(definitions))
// 只在开发环境输出详细日志
if (process.env.NODE_ENV === 'development') {
console.log(`[自定义关卡] 保存到 localStorage (${currentPlanId.value})`)
}
} catch (error) {
console.error('[自定义关卡] localStorage 保存失败:', error)
}
}
// 单一数据源
const planData = ref<PlanDataState>({
info: {
name: '',
mode: 'ALL',
type: 'MaaPlanConfig'
},
timeConfigs: {} as Record<TimeKey, any>,
customStageDefinitions: loadCustomStageDefinitionsFromStorage()
})
// 初始化时间配置
const initializeTimeConfigs = () => {
TIME_KEYS.forEach(timeKey => {
planData.value.timeConfigs[timeKey] = {
medicineNumb: 0,
seriesNumb: '0',
stages: {
primary: '-',
backup1: '-',
backup2: '-',
backup3: '-',
remain: '-'
}
}
})
}
// 初始化数据
initializeTimeConfigs()
// 从API数据转换为内部数据结构
const fromApiData = (apiData: MaaPlanConfig) => {
// 更新基础信息
if (apiData.Info) {
planData.value.info.name = apiData.Info.Name || ''
planData.value.info.mode = apiData.Info.Mode || 'ALL'
// 如果API数据中包含计划表ID信息更新当前planId
// 注意这里假设planId通过其他方式传入API数据本身可能不包含ID
}
// 更新时间配置
TIME_KEYS.forEach(timeKey => {
const timeData = apiData[timeKey] as MaaPlanConfig_Item
if (timeData) {
planData.value.timeConfigs[timeKey] = {
medicineNumb: timeData.MedicineNumb || 0,
seriesNumb: timeData.SeriesNumb || '0',
stages: {
primary: timeData.Stage || '-',
backup1: timeData.Stage_1 || '-',
backup2: timeData.Stage_2 || '-',
backup3: timeData.Stage_3 || '-',
remain: timeData.Stage_Remain || '-'
}
}
}
})
// 更新自定义关卡定义
const customStages = (apiData.ALL as any)?.customStageDefinitions
if (customStages && typeof customStages === 'object') {
// 只在开发环境输出详细日志
if (process.env.NODE_ENV === 'development') {
console.log(`[自定义关卡] 从后端数据恢复 (${currentPlanId.value})`)
}
const newDefinitions = {
custom_stage_1: customStages.custom_stage_1 || '',
custom_stage_2: customStages.custom_stage_2 || '',
custom_stage_3: customStages.custom_stage_3 || '',
}
// 只有当定义真的不同时才更新和保存
const hasChanged = JSON.stringify(newDefinitions) !== JSON.stringify(planData.value.customStageDefinitions)
if (hasChanged) {
planData.value.customStageDefinitions = newDefinitions
// 同步到 localStorage
saveCustomStageDefinitionsToStorage(planData.value.customStageDefinitions)
}
} else {
// 只在开发环境输出日志
if (process.env.NODE_ENV === 'development') {
console.log(`[自定义关卡] 使用 localStorage 数据 (${currentPlanId.value})`)
}
// 如果后端没有自定义关卡定义,使用 localStorage 中的值
const storedDefinitions = loadCustomStageDefinitionsFromStorage()
planData.value.customStageDefinitions = storedDefinitions
}
}
// 转换为API数据格式
const toApiData = (): MaaPlanConfig => {
const result: MaaPlanConfig = {
Info: {
Name: planData.value.info.name,
Mode: planData.value.info.mode
}
}
TIME_KEYS.forEach(timeKey => {
const config = planData.value.timeConfigs[timeKey]
result[timeKey] = {
MedicineNumb: config.medicineNumb,
SeriesNumb: config.seriesNumb as any,
Stage: config.stages.primary,
Stage_1: config.stages.backup1,
Stage_2: config.stages.backup2,
Stage_3: config.stages.backup3,
Stage_Remain: config.stages.remain
}
})
// 在ALL中包含自定义关卡定义
if (result.ALL) {
(result.ALL as any).customStageDefinitions = planData.value.customStageDefinitions
}
return result
}
// 配置视图数据适配器
const configViewData = computed(() => {
return [
{
key: 'MedicineNumb',
taskName: '吃理智药',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.medicineNumb || 0
])
)
},
{
key: 'SeriesNumb',
taskName: '连战次数',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.seriesNumb || '0'
])
)
},
{
key: 'Stage',
taskName: '关卡选择',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.stages.primary || '-'
])
)
},
{
key: 'Stage_1',
taskName: '备选关卡-1',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.stages.backup1 || '-'
])
)
},
{
key: 'Stage_2',
taskName: '备选关卡-2',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.stages.backup2 || '-'
])
)
},
{
key: 'Stage_3',
taskName: '备选关卡-3',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.stages.backup3 || '-'
])
)
},
{
key: 'Stage_Remain',
taskName: '剩余理智关卡',
...Object.fromEntries(
TIME_KEYS.map(timeKey => [
timeKey,
planData.value.timeConfigs[timeKey]?.stages.remain || '-'
])
)
}
]
})
// 简化视图数据适配器
const simpleViewData = computed(() => {
const result: any[] = []
// 添加自定义关卡
Object.entries(planData.value.customStageDefinitions).forEach(([, stageName]) => {
if (stageName && stageName.trim()) {
const stageStates: Record<string, boolean> = {}
TIME_KEYS.forEach(timeKey => {
const config = planData.value.timeConfigs[timeKey]
stageStates[timeKey] = Object.values(config.stages).includes(stageName)
})
result.push({
key: stageName,
taskName: stageName,
isCustom: true,
stageName: stageName,
...stageStates
})
}
})
// 添加标准关卡
STAGE_DAILY_INFO.filter(stage => stage.value !== '-').forEach(stage => {
const stageStates: Record<string, boolean> = {}
TIME_KEYS.forEach(timeKey => {
const config = planData.value.timeConfigs[timeKey]
stageStates[timeKey] = Object.values(config.stages).includes(stage.value)
})
result.push({
key: stage.value,
taskName: stage.text,
isCustom: false,
stageName: stage.value,
...stageStates
})
})
return result
})
// 更新配置数据
const updateConfig = (timeKey: TimeKey, field: string, value: any) => {
if (field === 'MedicineNumb') {
planData.value.timeConfigs[timeKey].medicineNumb = value
} else if (field === 'SeriesNumb') {
planData.value.timeConfigs[timeKey].seriesNumb = value
} else if (field === 'Stage') {
planData.value.timeConfigs[timeKey].stages.primary = value
} else if (field === 'Stage_1') {
planData.value.timeConfigs[timeKey].stages.backup1 = value
} else if (field === 'Stage_2') {
planData.value.timeConfigs[timeKey].stages.backup2 = value
} else if (field === 'Stage_3') {
planData.value.timeConfigs[timeKey].stages.backup3 = value
} else if (field === 'Stage_Remain') {
planData.value.timeConfigs[timeKey].stages.remain = value
}
}
// 切换关卡状态(简化视图用)
const toggleStage = (stageName: string, timeKey: TimeKey, enabled: boolean) => {
const config = planData.value.timeConfigs[timeKey]
const stageSlots = ['primary', 'backup1', 'backup2', 'backup3'] as const
if (enabled) {
// 找到第一个空槽位
const emptySlot = stageSlots.find(slot =>
!config.stages[slot] || config.stages[slot] === '-'
)
if (emptySlot) {
config.stages[emptySlot] = stageName
}
// 启用后重新按简化视图顺序排列
reassignSlotsBySimpleViewOrder(timeKey)
} else {
// 从所有槽位中移除
stageSlots.forEach(slot => {
if (config.stages[slot] === stageName) {
config.stages[slot] = '-'
}
})
// 移除后重新按简化视图顺序排列
reassignSlotsBySimpleViewOrder(timeKey)
}
}
// 按简化视图顺序重新分配槽位
const reassignSlotsBySimpleViewOrder = (timeKey: TimeKey) => {
const config = planData.value.timeConfigs[timeKey]
const stageSlots = ['primary', 'backup1', 'backup2', 'backup3'] as const
// 收集当前已启用的关卡
const enabledStages = Object.values(config.stages).filter(stage => stage && stage !== '-')
// 清空所有槽位
stageSlots.forEach(slot => {
config.stages[slot] = '-'
})
// 按简化视图的实际显示顺序重新分配
const sortedStages: string[] = []
// 1. 先添加自定义关卡(按 custom_stage_1, custom_stage_2, custom_stage_3 的顺序)
for (let i = 1; i <= 3; i++) {
const key = `custom_stage_${i}` as keyof typeof planData.value.customStageDefinitions
const stageName = planData.value.customStageDefinitions[key]
if (stageName && stageName.trim() && enabledStages.includes(stageName)) {
sortedStages.push(stageName)
}
}
// 2. 再添加标准关卡按STAGE_DAILY_INFO的顺序跳过'-'
STAGE_DAILY_INFO.filter(stage => stage.value !== '-').forEach(stage => {
if (enabledStages.includes(stage.value)) {
sortedStages.push(stage.value)
}
})
// 3. 按顺序分配到槽位第1个→primary第2个→backup1第3个→backup2第4个→backup3
sortedStages.forEach((stageName, index) => {
if (index < stageSlots.length) {
config.stages[stageSlots[index]] = stageName
}
})
// 只在开发环境输出排序日志
if (process.env.NODE_ENV === 'development') {
console.log(`[关卡排序] ${timeKey}:`, sortedStages.join(' → '))
}
}
// 更新自定义关卡定义
const updateCustomStageDefinition = (index: 1 | 2 | 3, name: string) => {
const key = `custom_stage_${index}` as keyof typeof planData.value.customStageDefinitions
const oldName = planData.value.customStageDefinitions[key]
// 只在开发环境输出详细日志
if (process.env.NODE_ENV === 'development') {
console.log(`[自定义关卡] 保存关卡-${index}: "${oldName}" -> "${name}"`)
}
planData.value.customStageDefinitions[key] = name
// 保存到 localStorage
saveCustomStageDefinitionsToStorage(planData.value.customStageDefinitions)
// 如果名称改变了,需要更新所有引用
if (oldName !== name) {
TIME_KEYS.forEach(timeKey => {
const config = planData.value.timeConfigs[timeKey]
Object.keys(config.stages).forEach(stageKey => {
if (config.stages[stageKey as keyof typeof config.stages] === oldName) {
config.stages[stageKey as keyof typeof config.stages] = name || '-'
}
})
})
}
}
// 更新计划表ID
const updatePlanId = (newPlanId: string) => {
if (currentPlanId.value !== newPlanId) {
// 只在开发环境输出日志
if (process.env.NODE_ENV === 'development') {
console.log(`[自定义关卡] 计划表切换: ${currentPlanId.value} -> ${newPlanId}`)
}
currentPlanId.value = newPlanId
// 重新加载自定义关卡定义
const newDefinitions = loadCustomStageDefinitionsFromStorage()
planData.value.customStageDefinitions = newDefinitions
}
}
return {
// 数据
planData: planData.value,
// 视图适配器
configViewData,
simpleViewData,
// 数据转换
fromApiData,
toApiData,
// 数据操作
updateConfig,
toggleStage,
updateCustomStageDefinition,
updatePlanId,
// 工具函数
initializeTimeConfigs
}
}

View File

@@ -5,9 +5,9 @@ import { Modal } from 'ant-design-vue'
// ====== 配置项 ======
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
const HEARTBEAT_INTERVAL = 30000 // 30秒心跳间隔与后端保持一致
const HEARTBEAT_TIMEOUT = 45000 // 45秒超时给网络延迟留够时间
const BACKEND_CHECK_INTERVAL = 6000 // 6秒检查间隔
const HEARTBEAT_INTERVAL = 15000
const HEARTBEAT_TIMEOUT = 5000
const BACKEND_CHECK_INTERVAL = 3000
const MAX_RESTART_ATTEMPTS = 3
const RESTART_DELAY = 2000
const MAX_QUEUE_SIZE = 50 // 每个 ID 或全局 type 队列最大条数
@@ -111,7 +111,7 @@ const initGlobalStorage = (): GlobalWSStorage => ({
const getGlobalStorage = (): GlobalWSStorage => {
if (!(window as any)[WS_STORAGE_KEY]) {
; (window as any)[WS_STORAGE_KEY] = initGlobalStorage()
;(window as any)[WS_STORAGE_KEY] = initGlobalStorage()
}
return (window as any)[WS_STORAGE_KEY]
}
@@ -166,7 +166,7 @@ const handleBackendFailure = async () => {
okText: '重启应用',
onOk: () => {
if ((window.electronAPI as any)?.windowClose) {
; (window.electronAPI as any).windowClose()
;(window.electronAPI as any).windowClose()
} else {
window.location.reload()
}
@@ -178,26 +178,13 @@ const handleBackendFailure = async () => {
setTimeout(async () => {
const success = await restartBackend()
if (success) {
// 统一在一个地方管理连接权限
setConnectionPermission(true, '后端重启后重连')
// 等待后端完全启动
setTimeout(async () => {
try {
const connected = await connectGlobalWebSocket('后端重启后重连')
if (connected) {
// 连接成功后再禁用权限
setTimeout(() => {
setConnectionPermission(false, '正常运行中')
}, 1000)
}
} catch (e) {
warn('重启后重连失败:', e)
setConnectionPermission(false, '连接失败')
}
setTimeout(() => {
connectGlobalWebSocket('后端重启后重连').then(() => {
setConnectionPermission(false, '正常运行中')
})
}, RESTART_DELAY)
} else {
// 重启失败,继续尝试
setTimeout(handleBackendFailure, RESTART_DELAY)
}
}, RESTART_DELAY)
@@ -221,15 +208,12 @@ const startBackendMonitoring = () => {
setBackendStatus('stopped')
}
// 检查心跳超时:如果超过心跳超时时间且连接仍然打开,说明后端可能有问题
if (global.lastPingTime > 0 && now - global.lastPingTime > HEARTBEAT_TIMEOUT) {
if (global.lastPingTime > 0 && now - global.lastPingTime > HEARTBEAT_TIMEOUT * 2) {
if (global.wsRef?.readyState === WebSocket.OPEN) {
setBackendStatus('error')
// 主动关闭可能有问题的连接
global.wsRef.close(1000, '心跳超时')
}
}
}, BACKEND_CHECK_INTERVAL)
}, BACKEND_CHECK_INTERVAL * 2)
}
// ====== 心跳 ======
@@ -255,7 +239,7 @@ const startGlobalHeartbeat = (ws: WebSocket) => {
data: { Ping: pingTime, connectionId: global.connectionId },
})
)
} catch { }
} catch {}
}
}, HEARTBEAT_INTERVAL)
}
@@ -272,13 +256,13 @@ const cleanupExpiredMessages = (now: number) => {
const messageMatchesFilter = (message: WebSocketBaseMessage, filter: SubscriptionFilter): boolean => {
// 如果都不指定,匹配所有消息
if (!filter.type && !filter.id) return true
// 如果只指定type
if (filter.type && !filter.id) return message.type === filter.type
// 如果只指定id
if (!filter.type && filter.id) return message.id === filter.id
// 如果同时指定type和id必须都匹配
return message.type === filter.type && message.id === filter.id
}
@@ -294,11 +278,11 @@ const getCacheMarkerKey = (filter: SubscriptionFilter): string => {
// 添加缓存标记
const addCacheMarker = (filter: SubscriptionFilter) => {
if (!filter.needCache) return
const global = getGlobalStorage()
const key = getCacheMarkerKey(filter)
const existing = global.cacheMarkers.value.get(key)
if (existing) {
existing.refCount++
} else {
@@ -308,18 +292,18 @@ const addCacheMarker = (filter: SubscriptionFilter) => {
refCount: 1
})
}
log(`缓存标记 ${key} 引用计数: ${global.cacheMarkers.value.get(key)?.refCount}`)
}
// 移除缓存标记
const removeCacheMarker = (filter: SubscriptionFilter) => {
if (!filter.needCache) return
const global = getGlobalStorage()
const key = getCacheMarkerKey(filter)
const existing = global.cacheMarkers.value.get(key)
if (existing) {
existing.refCount--
if (existing.refCount <= 0) {
@@ -334,7 +318,7 @@ const removeCacheMarker = (filter: SubscriptionFilter) => {
// 检查消息是否需要缓存
const shouldCacheMessage = (message: WebSocketBaseMessage): boolean => {
const global = getGlobalStorage()
for (const [, marker] of global.cacheMarkers.value) {
const filter = { type: marker.type, id: marker.id }
if (messageMatchesFilter(message, filter)) {
@@ -377,8 +361,8 @@ const handleMessage = (raw: WebSocketBaseMessage) => {
log(`消息已缓存: type=${raw.type}, id=${raw.id}`)
}
// 定期清理过期消息(每处理50条消息触发一次,避免频繁且更可预测
if (global.cachedMessages.value.length > 0 && global.cachedMessages.value.length % 50 === 0) {
// 定期清理过期消息(每 10 条触发一次,避免频繁)
if (Math.random() < 0.1) {
cleanupExpiredMessages(now)
}
@@ -394,23 +378,23 @@ export const subscribe = (
): string => {
const global = getGlobalStorage()
const subscriptionId = `sub_${++global.subscriptionCounter}_${Date.now()}`
const subscription: WebSocketSubscription = {
subscriptionId,
filter,
handler
}
global.subscriptions.value.set(subscriptionId, subscription)
// 添加缓存标记
addCacheMarker(filter)
// 回放匹配的缓存消息
const matchingMessages = global.cachedMessages.value.filter(cached =>
const matchingMessages = global.cachedMessages.value.filter(cached =>
messageMatchesFilter(cached.message, filter)
)
if (matchingMessages.length > 0) {
log(`回放 ${matchingMessages.length} 条缓存消息给订阅 ${subscriptionId}`)
matchingMessages.forEach(cached => {
@@ -421,7 +405,7 @@ export const subscribe = (
}
})
}
log(`新订阅创建: ${subscriptionId}`, filter)
return subscriptionId
}
@@ -429,14 +413,14 @@ export const subscribe = (
export const unsubscribe = (subscriptionId: string): void => {
const global = getGlobalStorage()
const subscription = global.subscriptions.value.get(subscriptionId)
if (subscription) {
// 移除缓存标记
removeCacheMarker(subscription.filter)
// 清理缓存中没有任何标记的消息
cleanupUnmarkedCache()
global.subscriptions.value.delete(subscriptionId)
log(`订阅已取消: ${subscriptionId}`)
} else {
@@ -447,7 +431,7 @@ export const unsubscribe = (subscriptionId: string): void => {
// 清理没有标记的缓存消息
const cleanupUnmarkedCache = () => {
const global = getGlobalStorage()
global.cachedMessages.value = global.cachedMessages.value.filter(cached => {
// 检查是否还有标记需要这条消息
for (const [, marker] of global.cacheMarkers.value) {
@@ -471,7 +455,7 @@ const releaseConnectionLock = () => {
isGlobalConnectingLock = false
}
const allowedConnectionReasons = ['后端启动后连接', '后端重启后重连', '系统初始化', '手动重连', '强制连接']
const allowedConnectionReasons = ['后端启动后连接', '后端重启后重连']
const isValidConnectionReason = (reason: string) => allowedConnectionReasons.includes(reason)
const checkConnectionPermission = () => getGlobalStorage().allowNewConnection
const setConnectionPermission = (allow: boolean, reason: string) => {
@@ -482,19 +466,9 @@ const setConnectionPermission = (allow: boolean, reason: string) => {
const createGlobalWebSocket = (): WebSocket => {
const global = getGlobalStorage()
// 清理旧连接
if (global.wsRef) {
if (global.wsRef.readyState === WebSocket.OPEN) {
log('警告:尝试创建新连接但当前连接仍有效')
return global.wsRef
}
if (global.wsRef.readyState === WebSocket.CONNECTING) {
log('警告:尝试创建新连接但当前连接正在建立中')
return global.wsRef
}
// 清理已关闭或错误状态的连接
global.wsRef = null
if (global.wsRef.readyState === WebSocket.OPEN) return global.wsRef
if (global.wsRef.readyState === WebSocket.CONNECTING) return global.wsRef
}
const ws = new WebSocket(BASE_WS_URL)
@@ -506,11 +480,7 @@ const createGlobalWebSocket = (): WebSocket => {
global.reconnectAttempts = 0
setGlobalStatus('已连接')
startGlobalHeartbeat(ws)
// 只有在特殊连接原因下才设置为正常运行
if (global.connectionReason !== '系统初始化') {
setConnectionPermission(false, '正常运行中')
}
setConnectionPermission(false, '正常运行中')
try {
ws.send(
@@ -525,52 +495,35 @@ const createGlobalWebSocket = (): WebSocket => {
data: { Pong: Date.now(), connectionId: global.connectionId },
})
)
} catch (e) {
warn('发送初始信号失败:', e)
}
} catch {}
initializeGlobalSubscriptions()
log('WebSocket连接已建立并初始化完成')
}
ws.onmessage = ev => {
try {
const raw = JSON.parse(ev.data) as WebSocketBaseMessage
handleMessage(raw)
} catch (e) {
warn('解析WebSocket消息失败:', e, '原始数据:', ev.data)
}
}
ws.onerror = (error) => {
setGlobalStatus('连接错误')
warn('WebSocket错误:', error)
} catch {}
}
ws.onerror = () => setGlobalStatus('连接错误')
ws.onclose = event => {
setGlobalStatus('已断开')
stopGlobalHeartbeat()
global.isConnecting = false
log(`WebSocket连接关闭: code=${event.code}, reason="${event.reason}"`)
// 根据关闭原因决定是否需要处理后端故障
if (event.code === 1000 && event.reason === 'Ping超时') {
handleBackendFailure().catch(e => warn('handleBackendFailure error:', e))
} else if (event.code === 1000 && event.reason === '心跳超时') {
handleBackendFailure().catch(e => warn('handleBackendFailure error:', e))
}
}
return ws
}
const connectGlobalWebSocket = async (reason: string = '手动重连'): Promise<boolean> => {
const connectGlobalWebSocket = async (reason: string = '未指定原因'): Promise<boolean> => {
const global = getGlobalStorage()
if (!checkConnectionPermission() || !isValidConnectionReason(reason)) {
warn(`连接被拒绝: 权限=${checkConnectionPermission()}, 原因="${reason}"是否有效=${isValidConnectionReason(reason)}`)
return false
}
if (!checkConnectionPermission() || !isValidConnectionReason(reason)) return false
if (!acquireConnectionLock()) return false
try {
@@ -620,72 +573,6 @@ export const connectAfterBackendStart = async (): Promise<boolean> => {
}
}
// 强制连接模式,用于强行进入应用时
export const forceConnectWebSocket = async (): Promise<boolean> => {
log('强制WebSocket连接模式开始')
const global = getGlobalStorage()
// 显示当前状态
log('当前连接状态:', {
status: global.status.value,
wsReadyState: global.wsRef?.readyState,
allowNewConnection: global.allowNewConnection,
connectionReason: global.connectionReason
})
// 设置连接权限
setConnectionPermission(true, '强制连接')
log('已设置强制连接权限')
try {
// 尝试连接最多重试3次
let connected = false
let attempts = 0
const maxAttempts = 3
while (!connected && attempts < maxAttempts) {
attempts++
log(`强制连接尝试 ${attempts}/${maxAttempts}`)
try {
connected = await connectGlobalWebSocket('强制连接')
if (connected) {
startBackendMonitoring()
log('强制WebSocket连接成功')
break
} else {
warn(`强制连接尝试 ${attempts} 失败`)
if (attempts < maxAttempts) {
// 等待1秒后重试
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
} catch (attemptError) {
warn(`强制连接尝试 ${attempts} 异常:`, attemptError)
if (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}
if (!connected) {
warn('所有强制连接尝试均失败,但不阻止应用启动')
}
return connected
} catch (error) {
warn('强制WebSocket连接异常:', error)
return false
} finally {
// 稍后重置连接权限,给连接时间
setTimeout(() => {
setConnectionPermission(false, '强制连接完成')
log('强制连接权限已重置')
}, 2000) // 增加到2秒
}
}
// ====== 全局处理器 ======
let _defaultHandlersLoaded = true
let _defaultTaskManagerHandler = schedulerHandlers.handleTaskManagerMessage
@@ -740,7 +627,7 @@ const initializeGlobalSubscriptions = () => {
data: { Pong: msg.data.Ping, connectionId: global.connectionId },
})
)
} catch { }
} catch {}
}
return
}
@@ -761,22 +648,8 @@ export function useWebSocket() {
const ws = global.wsRef
if (ws?.readyState === WebSocket.OPEN) {
try {
const message = { id, type, data }
ws.send(JSON.stringify(message))
if (DEBUG && type !== 'Signal') { // 避免心跳消息spam日志
log('发送消息:', message)
}
return true
} catch (e) {
warn('发送消息失败:', e, { id, type, data })
return false
}
} else {
warn('WebSocket未连接无法发送消息:', {
readyState: ws?.readyState,
message: { id, type, data }
})
return false
ws.send(JSON.stringify({ id, type, data }))
} catch {}
}
}
@@ -806,21 +679,6 @@ export function useWebSocket() {
lastCheck: global.lastBackendCheck,
})
// 调试功能
const debug = {
forceConnect: forceConnectWebSocket,
normalConnect: connectAfterBackendStart,
getGlobalStorage,
setConnectionPermission,
checkConnectionPermission,
allowedReasons: allowedConnectionReasons
}
// 在开发模式下暴露调试功能到全局
if (DEBUG && typeof window !== 'undefined') {
; (window as any).wsDebug = debug
}
return {
subscribe,
unsubscribe,
@@ -830,7 +688,6 @@ export function useWebSocket() {
backendStatus: global.backendStatus,
restartBackend: restartBackendManually,
getBackendStatus,
debug: DEBUG ? debug : undefined
}
}

View File

@@ -1,84 +1,64 @@
export interface ElectronAPI {
openDevTools: () => Promise<void>
selectFolder: () => Promise<string | null>
selectFile: (filters?: any[]) => Promise<string[]>
openUrl: (url: string) => Promise<{ success: boolean; error?: string }>
// 窗口控制
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<void>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
appQuit: () => Promise<void>
// 进程管理
getRelatedProcesses: () => Promise<any[]>
killAllProcesses: () => Promise<{ success: boolean; error?: string }>
forceExit: () => Promise<{ success: boolean }>
// 初始化相关API
checkEnvironment: () => Promise<any>
checkCriticalFiles: () => Promise<{ pythonExists: boolean; gitExists: boolean; mainPyExists: boolean }>
checkGitUpdate: () => Promise<{ hasUpdate: boolean; error?: string }>
downloadPython: (mirror?: string) => Promise<any>
installPip: () => Promise<any>
downloadGit: () => Promise<any>
installDependencies: (mirror?: string) => Promise<any>
cloneBackend: (repoUrl?: string) => Promise<any>
updateBackend: (repoUrl?: string) => Promise<any>
startBackend: () => Promise<{ success: boolean; error?: string }>
stopBackend?: () => Promise<{ success: boolean; error?: string }>
// 管理员权限相关
checkAdmin: () => Promise<boolean>
restartAsAdmin: () => Promise<void>
// 配置文件操作
saveConfig: (config: any) => Promise<void>
loadConfig: () => Promise<any>
resetConfig: () => Promise<void>
// 日志文件操作
getLogPath: () => Promise<string>
getLogFiles: () => Promise<string[]>
getLogs: (lines?: number, fileName?: string) => Promise<string>
clearLogs: (fileName?: string) => Promise<void>
cleanOldLogs: (daysToKeep?: number) => Promise<void>
// 保留原有方法以兼容现有代码
saveLogsToFile: (logs: string) => Promise<void>
loadLogsFromFile: () => Promise<string | null>
// 文件系统操作
openFile: (filePath: string) => Promise<void>
showItemInFolder: (filePath: string) => Promise<void>
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => void
removeDownloadProgressListener: () => void
}
declare global {
interface ElectronAPI {
openDevTools: () => Promise<void>
selectFolder: () => Promise<string | null>
selectFile: (filters?: any[]) => Promise<string[]>
openUrl: (url: string) => Promise<{ success: boolean; error?: string }>
// 窗口控制
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<void>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
appQuit: () => Promise<void>
// 进程管理
getRelatedProcesses: () => Promise<any[]>
killAllProcesses: () => Promise<{ success: boolean; error?: string }>
forceExit: () => Promise<{ success: boolean }>
// 初始化相关API
checkEnvironment: () => Promise<any>
checkCriticalFiles: () => Promise<{ pythonExists: boolean; gitExists: boolean; mainPyExists: boolean }>
checkGitUpdate: () => Promise<{ hasUpdate: boolean; error?: string }>
downloadPython: (mirror?: string) => Promise<any>
installPip: () => Promise<any>
downloadGit: () => Promise<any>
installDependencies: (mirror?: string) => Promise<any>
cloneBackend: (repoUrl?: string) => Promise<any>
updateBackend: (repoUrl?: string) => Promise<any>
startBackend: () => Promise<{ success: boolean; error?: string }>
stopBackend?: () => Promise<{ success: boolean; error?: string }>
// 管理员权限相关
checkAdmin: () => Promise<boolean>
restartAsAdmin: () => Promise<void>
// 配置文件操作
saveConfig: (config: any) => Promise<void>
loadConfig: () => Promise<any>
resetConfig: () => Promise<void>
// 日志文件操作
getLogPath: () => Promise<string>
getLogFiles: () => Promise<string[]>
getLogs: (lines?: number, fileName?: string) => Promise<string>
clearLogs: (fileName?: string) => Promise<void>
cleanOldLogs: (daysToKeep?: number) => Promise<void>
// 保留原有方法以兼容现有代码
saveLogsToFile: (logs: string) => Promise<void>
loadLogsFromFile: () => Promise<string | null>
// 文件系统操作
openFile: (filePath: string) => Promise<void>
showItemInFolder: (filePath: string) => Promise<void>
// 对话框相关
showQuestionDialog: (questionData: {
title?: string
message?: string
options?: string[]
messageId?: string
}) => Promise<boolean>
dialogResponse: (messageId: string, choice: boolean) => Promise<boolean>
resizeDialogWindow: (height: number) => Promise<void>
// 主题信息获取
getThemeInfo: () => Promise<{
themeMode: string
themeColor: string
actualTheme: string
systemTheme: string
isDark: boolean
primaryColor: string
}>
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => void
removeDownloadProgressListener: () => void
}
interface Window {
electronAPI: ElectronAPI
}

View File

@@ -0,0 +1,78 @@
// Electron API 类型定义
export interface ElectronAPI {
// 开发工具
openDevTools: () => Promise<void>
selectFolder: () => Promise<string | null>
selectFile: (filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>
// 窗口控制
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<void>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
// 管理员权限检查
checkAdmin: () => Promise<boolean>
// 重启为管理员
restartAsAdmin: () => Promise<void>
appQuit: () => Promise<void>
// 进程管理
getRelatedProcesses: () => Promise<any[]>
killAllProcesses: () => Promise<{ success: boolean; error?: string }>
forceExit: () => Promise<{ success: boolean }>
// 环境检查
checkEnvironment: () => Promise<{
pythonExists: boolean
gitExists: boolean
backendExists: boolean
dependenciesInstalled: boolean
isInitialized: boolean
}>
// 关键文件检查
checkCriticalFiles: () => Promise<{
pythonExists: boolean
pipExists: boolean
gitExists: boolean
mainPyExists: boolean
}>
// Python相关
downloadPython: (mirror: string) => Promise<{ success: boolean; error?: string }>
deletePython: () => Promise<{ success: boolean; error?: string }>
// pip相关
installPip: () => Promise<{ success: boolean; error?: string }>
deletePip: () => Promise<{ success: boolean; error?: string }>
// Git相关
downloadGit: () => Promise<{ success: boolean; error?: string }>
deleteGit: () => Promise<{ success: boolean; error?: string }>
checkGitUpdate: () => Promise<{ hasUpdate: boolean; error?: string }>
// 后端代码相关
cloneBackend: (gitUrl: string) => Promise<{ success: boolean; error?: string }>
updateBackend: (gitUrl: string) => Promise<{ success: boolean; error?: string }>
// 依赖安装
installDependencies: (mirror: string) => Promise<{ success: boolean; error?: string }>
// 后端服务
startBackend: () => Promise<{ success: boolean; error?: string }>
// 下载进度监听
onDownloadProgress: (
callback: (progress: { progress: number; status: string; message: string }) => void
) => void
removeDownloadProgressListener: () => void
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}

View File

@@ -1,81 +0,0 @@
// appEntry.ts - 统一的应用进入逻辑
import router from '@/router'
import { connectAfterBackendStart, forceConnectWebSocket } from '@/composables/useWebSocket'
/**
* 统一的进入应用函数会自动尝试建立WebSocket连接
* @param reason 进入应用的原因,用于日志记录
* @param forceEnter 是否强制进入即使WebSocket连接失败
* @returns Promise<boolean> 是否成功进入应用
*/
export async function enterApp(reason: string = '正常进入', forceEnter: boolean = true): Promise<boolean> {
console.log(`${reason}开始进入应用流程尝试建立WebSocket连接...`)
let wsConnected = false
try {
// 尝试建立WebSocket连接
wsConnected = await connectAfterBackendStart()
if (wsConnected) {
console.log(`${reason}WebSocket连接建立成功`)
} else {
console.warn(`${reason}WebSocket连接建立失败`)
}
} catch (error) {
console.error(`${reason}WebSocket连接尝试失败:`, error)
}
// 决定是否进入应用
if (wsConnected || forceEnter) {
if (!wsConnected && forceEnter) {
console.warn(`${reason}WebSocket连接失败但强制进入应用`)
}
// 跳转到主页
router.push('/home')
console.log(`${reason}:已进入应用`)
return true
} else {
console.error(`${reason}WebSocket连接失败且不允许强制进入`)
return false
}
}
/**
* 强行进入应用忽略WebSocket连接状态
* @param reason 进入原因
*/
export async function forceEnterApp(reason: string = '强行进入'): Promise<void> {
console.log(`🚀 ${reason}:强行进入应用流程开始`)
console.log(`📡 ${reason}尝试强制建立WebSocket连接...`)
try {
// 使用强制连接模式
const wsConnected = await forceConnectWebSocket()
if (wsConnected) {
console.log(`${reason}强制WebSocket连接成功`)
} else {
console.warn(`⚠️ ${reason}强制WebSocket连接失败但继续进入应用`)
}
// 等待一下确保连接状态稳定
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
console.error(`${reason}强制WebSocket连接异常:`, error)
}
// 无论WebSocket是否成功都进入应用
console.log(`🏠 ${reason}:跳转到主页...`)
router.push('/home')
console.log(`${reason}:已强行进入应用`)
}
/**
* 正常进入应用需要WebSocket连接成功
* @param reason 进入原因
* @returns 是否成功进入
*/
export async function normalEnterApp(reason: string = '正常进入'): Promise<boolean> {
return await enterApp(reason, false)
}

View File

@@ -1,13 +1,24 @@
// 渲染进程日志工具
const LogLevel = {
DEBUG: 'DEBUG',
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR'
} as const
interface ElectronAPI {
getLogPath: () => Promise<string>
getLogFiles: () => Promise<string[]>
getLogs: (lines?: number, fileName?: string) => Promise<string>
clearLogs: (fileName?: string) => Promise<void>
cleanOldLogs: (daysToKeep?: number) => Promise<void>
}
export type LogLevel = typeof LogLevel[keyof typeof LogLevel]
export { LogLevel }
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR'
}
class Logger {
// 直接使用原生console主进程会自动处理日志记录
@@ -29,32 +40,32 @@ class Logger {
// 获取日志文件路径
async getLogPath(): Promise<string> {
if ((window as any).electronAPI) {
return await (window as any).electronAPI.getLogPath()
if (window.electronAPI) {
return await window.electronAPI.getLogPath()
}
throw new Error('Electron API not available')
}
// 获取日志文件列表
async getLogFiles(): Promise<string[]> {
if ((window as any).electronAPI) {
return await (window as any).electronAPI.getLogFiles()
if (window.electronAPI) {
return await window.electronAPI.getLogFiles()
}
throw new Error('Electron API not available')
}
// 获取日志内容
async getLogs(lines?: number, fileName?: string): Promise<string> {
if ((window as any).electronAPI) {
return await (window as any).electronAPI.getLogs(lines, fileName)
if (window.electronAPI) {
return await window.electronAPI.getLogs(lines, fileName)
}
throw new Error('Electron API not available')
}
// 清空日志
async clearLogs(fileName?: string): Promise<void> {
if ((window as any).electronAPI) {
await (window as any).electronAPI.clearLogs(fileName)
if (window.electronAPI) {
await window.electronAPI.clearLogs(fileName)
console.info(`日志已清空: ${fileName || '当前文件'}`)
} else {
throw new Error('Electron API not available')
@@ -63,8 +74,8 @@ class Logger {
// 清理旧日志
async cleanOldLogs(daysToKeep: number = 7): Promise<void> {
if ((window as any).electronAPI) {
await (window as any).electronAPI.cleanOldLogs(daysToKeep)
if (window.electronAPI) {
await window.electronAPI.cleanOldLogs(daysToKeep)
console.info(`已清理${daysToKeep}天前的旧日志`)
} else {
throw new Error('Electron API not available')

View File

@@ -0,0 +1,91 @@
/**
* 计划表名称管理工具函数
*/
export interface PlanNameValidationResult {
isValid: boolean
message?: string
}
/**
* 生成唯一的计划表名称(使用数字后缀)
* @param planType 计划表类型
* @param existingNames 已存在的名称列表
* @returns 唯一的计划表名称
*/
export function generateUniquePlanName(planType: string, existingNames: string[]): string {
const baseNames = {
MaaPlanConfig: '新 MAA 计划表',
GeneralPlan: '新通用计划表',
CustomPlan: '新自定义计划表',
} as Record<string, string>
const baseName = baseNames[planType] || '新计划表'
// 如果基础名称没有被使用,直接返回
if (!existingNames.includes(baseName)) {
return baseName
}
// 查找可用的编号
let counter = 2
let candidateName = `${baseName} ${counter}`
while (existingNames.includes(candidateName)) {
counter++
candidateName = `${baseName} ${counter}`
}
return candidateName
}
/**
* 验证计划表名称是否可用
* @param newName 新名称
* @param existingNames 已存在的名称列表
* @param currentName 当前名称(编辑时排除自己)
* @returns 验证结果
*/
export function validatePlanName(
newName: string,
existingNames: string[],
currentName?: string
): PlanNameValidationResult {
// 检查名称是否为空
if (!newName || !newName.trim()) {
return { isValid: false, message: '计划表名称不能为空' }
}
const trimmedName = newName.trim()
// 检查名称长度
if (trimmedName.length > 50) {
return { isValid: false, message: '计划表名称不能超过50个字符' }
}
// 检查是否与其他计划表重名(排除当前名称)
const isDuplicate = existingNames.some(name =>
name === trimmedName && name !== currentName
)
if (isDuplicate) {
return { isValid: false, message: '计划表名称已存在,请使用其他名称' }
}
return { isValid: true }
}
/**
* 获取计划表类型的显示标签
* @param planType 计划表类型
* @returns 显示标签
*/
export function getPlanTypeLabel(planType: string): string {
const labels = {
MaaPlanConfig: 'MAA计划表',
GeneralPlan: '通用计划表',
CustomPlan: '自定义计划表',
} as Record<string, string>
return labels[planType] || '计划表'
}

View File

@@ -16,7 +16,11 @@
</a-breadcrumb>
</div>
<a-space size="middle">
// 如果已有连接,先断开
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
generalWebsocketId.value = nulla-space size="middle">
<a-button
type="primary"
ghost
@@ -371,15 +375,6 @@ import { Service } from '@/api'
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
import WebhookManager from '@/components/WebhookManager.vue'
// 扩展 Window 接口以支持 Electron API
declare global {
interface Window {
electronAPI: {
selectFile: (filters: Array<{ name: string; extensions: string[] }>) => Promise<string[]>
}
}
}
const router = useRouter()
const route = useRoute()
const { addUser, updateUser, getUsers, loading: userLoading } = useUserApi()
@@ -586,9 +581,8 @@ const handleGeneralConfig = async () => {
showGeneralConfigMask.value = true
// 如果已有连接,先断开并清理
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
if (generalWebsocketId.value) {
unsubscribe(generalWebsocketId.value)
generalWebsocketId.value = null
showGeneralConfigMask.value = false
if (generalConfigTimeout) {
@@ -696,7 +690,7 @@ const handleSaveGeneralConfig = async () => {
// 文件选择方法
const selectScriptBeforeTask = async () => {
try {
const path = await window.electronAPI?.selectFile([
const path = await (window.electronAPI as any)?.selectFile([
{ name: '可执行文件', extensions: ['exe', 'bat', 'cmd', 'ps1'] },
{ name: '脚本文件', extensions: ['py', 'js', 'sh'] },
{ name: '所有文件', extensions: ['*'] },
@@ -714,7 +708,7 @@ const selectScriptBeforeTask = async () => {
const selectScriptAfterTask = async () => {
try {
const path = await window.electronAPI?.selectFile([
const path = await (window.electronAPI as any)?.selectFile([
{ name: '可执行文件', extensions: ['exe', 'bat', 'cmd', 'ps1'] },
{ name: '脚本文件', extensions: ['py', 'js', 'sh'] },
{ name: '所有文件', extensions: ['*'] },

View File

@@ -43,7 +43,6 @@ import ManualMode from '@/components/initialization/ManualMode.vue'
import EnvironmentIncomplete from '@/components/initialization/EnvironmentIncomplete.vue'
import type { DownloadProgress } from '@/types/initialization'
import { mirrorManager } from '@/utils/mirrorManager'
import { forceEnterApp } from '@/utils/appEntry'
const router = useRouter()
@@ -70,8 +69,8 @@ const mirrorConfigStatus = ref({
const manualModeRef = ref()
// 基础功能函数
async function skipToHome() {
await forceEnterApp('跳过初始化直接进入')
function skipToHome() {
router.push('/home')
}
function switchToManualMode() {
@@ -85,14 +84,10 @@ async function enterApp() {
try {
// 设置初始化完成标记
await setInitialized(true)
console.log('设置初始化完成标记,准备进入应用...')
// 使用统一的进入应用函数
await forceEnterApp('初始化完成后进入')
console.log('设置初始化完成标记,跳转到首页')
router.push('/home')
} catch (error) {
console.error('进入应用失败:', error)
// 即使出错也强制进入
await forceEnterApp('初始化失败后强制进入')
}
}

View File

@@ -56,6 +56,7 @@
<ScriptTable
:scripts="scripts"
:active-connections="activeConnections"
:current-plan-data="currentPlanData"
@edit="handleEditScript"
@delete="handleDeleteScript"
@add-user="handleAddUser"
@@ -253,6 +254,7 @@ import { useScriptApi } from '@/composables/useScriptApi'
import { useUserApi } from '@/composables/useUserApi'
import { useWebSocket } from '@/composables/useWebSocket'
import { useTemplateApi, type WebConfigTemplate } from '@/composables/useTemplateApi'
import { usePlanApi } from '@/composables/usePlanApi'
import { Service } from '@/api/services/Service'
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
import MarkdownIt from 'markdown-it'
@@ -262,6 +264,7 @@ const { addScript, deleteScript, getScriptsWithUsers } = useScriptApi()
const { updateUser, deleteUser } = useUserApi()
const { subscribe, unsubscribe } = useWebSocket()
const { getWebConfigTemplates, importScriptFromWeb } = useTemplateApi()
const { getPlans } = usePlanApi()
// 初始化markdown解析器
const md = new MarkdownIt({
@@ -273,6 +276,8 @@ const md = new MarkdownIt({
const scripts = ref<Script[]>([])
// 增加:标记是否已经完成过一次脚本列表加载(成功或失败都算一次)
const loadedOnce = ref(false)
// 当前计划表数据
const currentPlanData = ref<Record<string, any> | null>(null)
const typeSelectVisible = ref(false)
const generalModeSelectVisible = ref(false)
const templateSelectVisible = ref(false)
@@ -312,6 +317,7 @@ const filteredTemplates = computed(() => {
onMounted(() => {
loadScripts()
loadCurrentPlan()
})
const loadScripts = async () => {
@@ -336,6 +342,21 @@ const loadScripts = async () => {
}
}
// 加载当前计划表数据
const loadCurrentPlan = async () => {
try {
const response = await getPlans()
if (response.data && response.index && response.index.length > 0) {
// 获取第一个计划表的数据
const firstPlanId = response.index[0].uid
currentPlanData.value = response.data[firstPlanId] || null
}
} catch (error) {
console.error('加载计划表数据失败:', error)
// 不显示错误消息,因为计划表数据是可选的
}
}
const handleAddScript = () => {
selectedType.value = 'MAA'
typeSelectVisible.value = true

View File

@@ -57,6 +57,7 @@
:current-mode="currentMode"
:view-mode="viewMode"
:options-loaded="!loading"
:plan-id="activePlanId"
@update-table-data="handleTableDataUpdate"
/>
</PlanConfig>
@@ -70,6 +71,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { usePlanApi } from '@/composables/usePlanApi'
import { generateUniquePlanName, validatePlanName, getPlanTypeLabel } from '@/utils/planNameUtils'
import PlanHeader from './components/PlanHeader.vue'
import PlanSelector from './components/PlanSelector.vue'
import PlanConfig from './components/PlanConfig.vue'
@@ -100,6 +102,7 @@ const viewMode = ref<'config' | 'simple'>('config')
const isEditingPlanName = ref<boolean>(false)
const loading = ref(true)
const switching = ref(false) // 添加切换状态
// Use a record to match child component expectations
const tableData = ref<Record<string, any>>({})
@@ -128,13 +131,18 @@ const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): T =
const handleAddPlan = async (planType: string = 'MaaPlanConfig') => {
try {
const response = await createPlan(planType)
const defaultName = getDefaultPlanName(planType)
const newPlan = { id: response.planId, name: defaultName, type: planType }
const uniqueName = getDefaultPlanName(planType)
const newPlan = { id: response.planId, name: uniqueName, type: planType }
planList.value.push(newPlan)
activePlanId.value = newPlan.id
currentPlanName.value = defaultName
currentPlanName.value = uniqueName
await loadPlanData(newPlan.id)
message.info(`已创建新的${getPlanTypeLabel(planType)},建议您修改为更有意义的名称`, 3)
// 如果生成的名称包含数字,说明有重名,提示用户
if (uniqueName.match(/\s\d+$/)) {
message.info(`已创建新的${getPlanTypeLabel(planType)}"${uniqueName}",建议您修改为更有意义的名称`, 4)
} else {
message.success(`已创建新的${getPlanTypeLabel(planType)}"${uniqueName}"`)
}
} catch (error) {
console.error('添加计划失败:', error)
}
@@ -186,6 +194,7 @@ const saveInBackground = async (planId: string) => {
const planData: Record<string, any> = { ...(tableData.value || {}) }
planData.Info = { Mode: currentMode.value, Name: currentPlanName.value, Type: planType }
console.log(`[计划表] 保存数据 (${planId}):`, planData)
await updatePlan(planId, planData)
} catch (error) {
console.error('后台保存计划数据失败:', error)
@@ -214,20 +223,27 @@ const handleSave = async () => {
await debouncedSave()
}
// 优化计划切换逻辑
// 优化计划切换逻辑 - 异步保存,立即切换
const onPlanChange = async (planId: string) => {
if (planId === activePlanId.value) return
// 触发当前计划的异步保存,但不等待完成
if (activePlanId.value) {
saveInBackground(activePlanId.value).catch(error => {
console.warn('切换时保存当前计划失败:', error)
})
}
switching.value = true
try {
// 异步保存当前计划,不等待完成
if (activePlanId.value) {
saveInBackground(activePlanId.value).catch(error => {
console.error('切换时保存当前计划失败:', error)
message.warning('保存当前计划时出现问题,请检查数据是否完整')
})
}
// 立即切换到新计划
activePlanId.value = planId
await loadPlanData(planId)
// 立即切换到新计划,提升响应速度
console.log(`[计划表] 切换到新计划: ${planId}`)
activePlanId.value = planId
await loadPlanData(planId)
} finally {
switching.value = false
}
}
const startEditPlanName = () => {
@@ -242,13 +258,29 @@ const startEditPlanName = () => {
}
const finishEditPlanName = () => {
isEditingPlanName.value = false
if (activePlanId.value) {
const currentPlan = planList.value.find(plan => plan.id === activePlanId.value)
if (currentPlan) {
currentPlan.name = currentPlanName.value || getDefaultPlanName(currentPlan.type)
const newName = currentPlanName.value?.trim() || ''
const existingNames = planList.value.map(plan => plan.name)
// 验证新名称
const validation = validatePlanName(newName, existingNames, currentPlan.name)
if (!validation.isValid) {
// 如果验证失败,显示错误消息并恢复原名称
message.error(validation.message || '计划表名称无效')
currentPlanName.value = currentPlan.name
} else {
// 如果验证成功,更新名称
currentPlan.name = newName
currentPlanName.value = newName
// 触发保存操作,确保名称被保存到后端
handleSave()
}
}
}
isEditingPlanName.value = false
}
const onModeChange = () => {
@@ -263,20 +295,41 @@ const handleTableDataUpdate = async (newData: Record<string, any>) => {
const loadPlanData = async (planId: string) => {
try {
const response = await getPlans(planId)
currentPlanData.value = response.data
if (response.data && response.data[planId]) {
const planData = response.data[planId] as PlanData
// 优化先检查缓存数据避免不必要的API调用
let planData: PlanData | null = null
if (currentPlanData.value && currentPlanData.value[planId]) {
planData = currentPlanData.value[planId] as PlanData
console.log(`[计划表] 使用缓存数据 (${planId})`)
} else {
const response = await getPlans(planId)
currentPlanData.value = response.data
planData = response.data[planId] as PlanData
console.log(`[计划表] 加载新数据 (${planId})`)
}
if (planData) {
if (planData.Info) {
const apiName = planData.Info.Name || ''
if (!apiName && !currentPlanName.value) {
const currentPlan = planList.value.find(plan => plan.id === planId)
if (currentPlan) currentPlanName.value = currentPlan.name
const currentPlan = planList.value.find(plan => plan.id === planId)
// 优先使用planList中的名称
if (currentPlan && currentPlan.name) {
currentPlanName.value = currentPlan.name
if (apiName !== currentPlan.name) {
console.log(`[计划表] 同步名称: API="${apiName}" -> planList="${currentPlan.name}"`)
}
} else if (apiName) {
currentPlanName.value = apiName
if (currentPlan) {
currentPlan.name = apiName
}
}
currentMode.value = planData.Info.Mode || 'ALL'
}
tableData.value = planData
}
} catch (error) {
@@ -288,17 +341,47 @@ const initPlans = async () => {
try {
const response = await getPlans()
if (response.index && response.index.length > 0) {
// 优化预先收集所有名称避免O(n²)复杂度
const allPlanNames: string[] = []
planList.value = response.index.map((item: any) => {
const planId = item.uid
const planData = response.data[planId]
const planType = item.type
const planName = planData?.Info?.Name || getDefaultPlanName(planType)
let planName = planData?.Info?.Name || ''
// 如果API中没有名称或者名称是默认的模板名称则生成唯一名称
if (!planName || planName === '新 MAA 计划表' || planName === '新通用计划表' || planName === '新自定义计划表') {
planName = generateUniquePlanName(planType, allPlanNames)
}
allPlanNames.push(planName)
return { id: planId, name: planName, type: planType }
})
const queryPlanId = (route.query.planId as string) || ''
const target = queryPlanId ? planList.value.find(p => p.id === queryPlanId) : null
activePlanId.value = target ? target.id : planList.value[0].id
await loadPlanData(activePlanId.value)
const selectedPlanId = target ? target.id : planList.value[0].id
// 优化直接使用已获取的数据避免重复API调用
activePlanId.value = selectedPlanId
const planData = response.data[selectedPlanId]
if (planData) {
currentPlanData.value = response.data
// 直接设置数据避免loadPlanData的重复调用
const selectedPlan = planList.value.find(plan => plan.id === selectedPlanId)
if (selectedPlan) {
currentPlanName.value = selectedPlan.name
}
if (planData.Info) {
currentMode.value = planData.Info.Mode || 'ALL'
}
console.log(`[计划表] 初始加载数据 (${selectedPlanId}):`, planData)
tableData.value = planData
}
} else {
currentPlanData.value = null
}
@@ -310,22 +393,12 @@ const initPlans = async () => {
}
}
const getDefaultPlanName = (planType: string) =>
(
({
MaaPlanConfig: '新 MAA 计划表',
GeneralPlan: '新通用计划表',
CustomPlan: '新自定义计划表',
}) as Record<string, string>
)[planType] || '新计划表'
const getPlanTypeLabel = (planType: string) =>
(
({
MaaPlanConfig: 'MAA计划表',
GeneralPlan: '通用计划表',
CustomPlan: '自定义计划表',
}) as Record<string, string>
)[planType] || '计划表'
const getDefaultPlanName = (planType: string) => {
// 保持原来的逻辑,但添加重名检测
const existingNames = planList.value.map(plan => plan.name)
return generateUniquePlanName(planType, existingNames)
}
// getPlanTypeLabel 现在从 @/utils/planNameUtils 导入,删除本地定义
watch(
() => [currentPlanName.value, currentMode.value],

File diff suppressed because it is too large Load Diff

View File

@@ -1,254 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket强制连接测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #0056b3;
}
.force-btn {
background: #dc3545;
}
.force-btn:hover {
background: #c82333;
}
.status {
margin: 10px 0;
padding: 10px;
border-radius: 5px;
}
.success {
background: #d4edda;
color: #155724;
}
.error {
background: #f8d7da;
color: #721c24;
}
.warning {
background: #fff3cd;
color: #856404;
}
.log {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: 10px;
margin-top: 10px;
max-height: 400px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>WebSocket强制连接测试工具</h1>
<div class="status" id="status">状态:未知</div>
<div>
<button onclick="testNormalConnect()">测试正常连接</button>
<button onclick="testForceConnect()" class="force-btn">测试强制连接</button>
<button onclick="testDirectConnect()">测试直接WebSocket连接</button>
<button onclick="checkStatus()">检查连接状态</button>
<button onclick="clearLog()">清空日志</button>
</div>
<div class="log" id="log"></div>
</div>
<script>
let ws = null;
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const timestamp = new Date().toLocaleTimeString();
const className = type === 'error' ? 'color: red;' :
type === 'success' ? 'color: green;' :
type === 'warning' ? 'color: orange;' : '';
logDiv.innerHTML += `<div style="${className}">[${timestamp}] ${message}</div>`;
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.textContent = `状态:${message}`;
statusDiv.className = `status ${type}`;
}
// 测试正常连接(通过前端框架)
async function testNormalConnect() {
log('🔍 测试正常连接模式...');
updateStatus('测试正常连接中...', 'warning');
try {
// 这里需要调用前端框架的连接方法
if (window.wsDebug && window.wsDebug.normalConnect) {
const result = await window.wsDebug.normalConnect();
if (result) {
log('✅ 正常连接成功', 'success');
updateStatus('正常连接成功', 'success');
} else {
log('❌ 正常连接失败', 'error');
updateStatus('正常连接失败', 'error');
}
} else {
log('⚠️ wsDebug.normalConnect 不可用', 'warning');
updateStatus('调试功能不可用', 'warning');
}
} catch (error) {
log(`❌ 正常连接异常: ${error}`, 'error');
updateStatus('正常连接异常', 'error');
}
}
// 测试强制连接(通过前端框架)
async function testForceConnect() {
log('🚀 测试强制连接模式...');
updateStatus('测试强制连接中...', 'warning');
try {
if (window.wsDebug && window.wsDebug.forceConnect) {
const result = await window.wsDebug.forceConnect();
if (result) {
log('✅ 强制连接成功', 'success');
updateStatus('强制连接成功', 'success');
} else {
log('❌ 强制连接失败', 'error');
updateStatus('强制连接失败', 'error');
}
} else {
log('⚠️ wsDebug.forceConnect 不可用', 'warning');
updateStatus('调试功能不可用', 'warning');
}
} catch (error) {
log(`❌ 强制连接异常: ${error}`, 'error');
updateStatus('强制连接异常', 'error');
}
}
// 测试直接WebSocket连接
function testDirectConnect() {
log('🔗 测试直接WebSocket连接...');
updateStatus('测试直接连接中...', 'warning');
try {
if (ws && ws.readyState === WebSocket.OPEN) {
log('⚠️ WebSocket已经连接先关闭', 'warning');
ws.close();
}
ws = new WebSocket('ws://localhost:36163/api/core/ws');
ws.onopen = () => {
log('✅ 直接WebSocket连接成功', 'success');
updateStatus('直接连接成功', 'success');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
log(`📨 收到消息: ${JSON.stringify(data)}`, 'info');
} catch (e) {
log(`📨 收到消息: ${event.data}`, 'info');
}
};
ws.onerror = (error) => {
log(`❌ WebSocket错误: ${error}`, 'error');
updateStatus('直接连接错误', 'error');
};
ws.onclose = (event) => {
log(`🔒 WebSocket关闭: code=${event.code}, reason="${event.reason}"`, 'warning');
updateStatus('连接已关闭', 'warning');
};
} catch (error) {
log(`❌ 直接连接异常: ${error}`, 'error');
updateStatus('直接连接异常', 'error');
}
}
// 检查连接状态
function checkStatus() {
log('🔍 检查连接状态...');
// 检查直接WebSocket
if (ws) {
const states = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
log(`📡 直接WebSocket状态: ${states[ws.readyState]} (${ws.readyState})`);
} else {
log('📡 直接WebSocket: 未创建');
}
// 检查框架WebSocket
if (window.wsDebug) {
try {
const info = window.wsDebug.getConnectionInfo ? window.wsDebug.getConnectionInfo() : {};
log(`🔧 框架WebSocket状态: ${JSON.stringify(info, null, 2)}`);
const storage = window.wsDebug.getGlobalStorage ? window.wsDebug.getGlobalStorage() : {};
log(`💾 全局存储状态: allowNewConnection=${storage.allowNewConnection}, connectionReason="${storage.connectionReason}"`);
if (window.wsDebug.allowedReasons) {
log(`✅ 允许的连接原因: ${JSON.stringify(window.wsDebug.allowedReasons)}`);
}
} catch (error) {
log(`❌ 检查框架状态异常: ${error}`, 'error');
}
} else {
log('🔧 框架WebSocket调试功能不可用');
}
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
// 页面加载时检查状态
window.addEventListener('load', () => {
log('📋 WebSocket强制连接测试工具已加载');
checkStatus();
});
</script>
</body>
</html>

View File

@@ -1,225 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket消息测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.test-section {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #0056b3;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: 5px;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.log {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: 10px;
margin-top: 10px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>WebSocket消息测试工具</h1>
<div class="test-section">
<h3>连接状态</h3>
<button onclick="connectWebSocket()">连接WebSocket</button>
<button onclick="disconnectWebSocket()">断开连接</button>
<div id="status" class="status disconnected">未连接</div>
</div>
<div class="test-section">
<h3>测试消息</h3>
<button onclick="sendQuestionMessage()">发送Question消息</button>
<button onclick="sendObjectMessage()">发送普通对象消息</button>
<button onclick="sendStringMessage()">发送字符串消息</button>
<button onclick="sendMalformedMessage()">发送格式错误消息</button>
</div>
<div class="test-section">
<h3>消息日志</h3>
<button onclick="clearLog()">清空日志</button>
<div id="log" class="log"></div>
</div>
</div>
<script>
let ws = null;
function log(message) {
const logDiv = document.getElementById('log');
const timestamp = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${timestamp}] ${message}\n`;
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateStatus(connected) {
const statusDiv = document.getElementById('status');
if (connected) {
statusDiv.textContent = 'WebSocket已连接';
statusDiv.className = 'status connected';
} else {
statusDiv.textContent = 'WebSocket未连接';
statusDiv.className = 'status disconnected';
}
}
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) {
log('WebSocket已经连接');
return;
}
ws = new WebSocket('ws://localhost:36163/api/core/ws');
ws.onopen = function() {
log('WebSocket连接已建立');
updateStatus(true);
};
ws.onmessage = function(event) {
log('收到消息: ' + event.data);
try {
const data = JSON.parse(event.data);
log('解析后的消息: ' + JSON.stringify(data, null, 2));
} catch (e) {
log('消息解析失败: ' + e.message);
}
};
ws.onclose = function() {
log('WebSocket连接已关闭');
updateStatus(false);
};
ws.onerror = function(error) {
log('WebSocket错误: ' + error);
updateStatus(false);
};
}
function disconnectWebSocket() {
if (ws) {
ws.close();
ws = null;
log('主动断开WebSocket连接');
updateStatus(false);
}
}
function sendMessage(message) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('错误: WebSocket未连接');
return;
}
const messageStr = JSON.stringify(message);
ws.send(messageStr);
log('发送消息: ' + messageStr);
}
function sendQuestionMessage() {
const message = {
id: "test_id_" + Date.now(),
type: "message",
data: {
type: "Question",
message_id: "q_" + Date.now(),
title: "测试问题",
message: "这是一个测试问题,请选择是否继续?"
}
};
sendMessage(message);
}
function sendObjectMessage() {
const message = {
id: "test_obj_" + Date.now(),
type: "message",
data: {
action: "test_action",
status: "running",
content: "这是一个普通的对象消息"
}
};
sendMessage(message);
}
function sendStringMessage() {
const message = {
id: "test_str_" + Date.now(),
type: "message",
data: "这是一个字符串消息"
};
sendMessage(message);
}
function sendMalformedMessage() {
const message = {
id: "test_malformed_" + Date.now(),
type: "message",
data: {
type: "Question",
// 缺少 message_id
title: "格式错误的问题",
message: "这个消息缺少message_id字段"
}
};
sendMessage(message);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
// 页面加载时自动连接
window.onload = function() {
log('页面已加载准备测试WebSocket连接');
};
</script>
</body>
</html>