From dfc50111e737cd7fb2fa89fa9abe54e8067e24dc Mon Sep 17 00:00:00 2001 From: HarcoChen Date: Sun, 6 Sep 2026 15:49:09 +0800 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=E8=A7=A3=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/config.py | 37 +- app/models/schema.py | 13 +- app/task/MaaEnd/AutoProxy.py | 273 +++++-- app/task/MaaEnd/ScriptConfig.py | 94 ++- app/task/MaaEnd/manager.py | 98 ++- app/utils/constants.py | 12 +- frontend/src/components/ScriptTable.vue | 6 +- frontend/src/composables/useScriptApi.ts | 18 +- frontend/src/types/script.ts | 7 +- frontend/src/utils/maaEndProtocolSpace.ts | 10 +- .../views/EditView/User/MaaEndUserEdit.vue | 36 +- .../views/MaaEndUserEdit/BasicInfoSection.vue | 96 ++- .../MaaEndUserEdit/DeliveryConfigSection.vue | 164 ++++ res/version.json | 2 + templates/specialized/README.md | 78 ++ .../specialized/backend/config.py.template | 248 ++++++ .../backend/registration-checklist.md | 52 ++ .../specialized/backend/schema.py.template | 123 +++ .../specialized/backend/task/Xxx/AutoProxy.py | 715 ++++++++++++++++++ .../backend/task/Xxx/ScriptConfig.py | 195 +++++ .../specialized/backend/task/Xxx/__init__.py | 5 + .../specialized/backend/task/Xxx/manager.py | 286 +++++++ .../specialized/frontend/XxxScriptEdit.vue | 536 +++++++++++++ .../specialized/frontend/XxxUserEdit.vue | 392 ++++++++++ .../frontend/XxxUserEdit/BasicInfoSection.vue | 172 +++++ .../XxxUserEdit/NotifyConfigSection.vue | 170 +++++ .../specialized/tests/test_xxx_autoproxy.py | 104 +++ 27 files changed, 3787 insertions(+), 155 deletions(-) create mode 100644 frontend/src/views/MaaEndUserEdit/DeliveryConfigSection.vue create mode 100644 templates/specialized/README.md create mode 100644 templates/specialized/backend/config.py.template create mode 100644 templates/specialized/backend/registration-checklist.md create mode 100644 templates/specialized/backend/schema.py.template create mode 100644 templates/specialized/backend/task/Xxx/AutoProxy.py create mode 100644 templates/specialized/backend/task/Xxx/ScriptConfig.py create mode 100644 templates/specialized/backend/task/Xxx/__init__.py create mode 100644 templates/specialized/backend/task/Xxx/manager.py create mode 100644 templates/specialized/frontend/XxxScriptEdit.vue create mode 100644 templates/specialized/frontend/XxxUserEdit.vue create mode 100644 templates/specialized/frontend/XxxUserEdit/BasicInfoSection.vue create mode 100644 templates/specialized/frontend/XxxUserEdit/NotifyConfigSection.vue create mode 100644 templates/specialized/tests/test_xxx_autoproxy.py diff --git a/app/models/config.py b/app/models/config.py index a27a9c6af..3e3ae3baf 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -32,6 +32,8 @@ from app.utils.constants import ( CYCLE_EMPTY_TIME, MAA_STAGE_KEY, + MAAEND_DELIVERY_COMMISSION_SOURCES, + MAAEND_DELIVERY_TASK, MAAEND_PROTOCOL_SPACE_TASK_OPTIONS, MAAEND_SANITY_TASK_DEFAULTS, MAAEND_SANITY_TASK_DETAIL_LABELS, @@ -126,6 +128,24 @@ def init_maaend_task_config(config) -> None: StringValidator(), ) + ## 抢委托送货最低接取价格(万) + config.Task_SeizeDeliveryJobsReward = ConfigItem( + "Task", "SeizeDeliveryJobsReward", 15.9, RangeValidator(0, 9999) + ) + ## 抢委托送货委托接收点 + config.Task_SeizeDeliveryJobsCommissionSource = ConfigItem( + "Task", + "SeizeDeliveryJobsCommissionSource", + "Unlimited", + OptionsValidator(list(MAAEND_DELIVERY_COMMISSION_SOURCES)), + ) + ## 独立送货任务 + setattr( + config, + f"Task_If{MAAEND_DELIVERY_TASK}", + ConfigItem("Task", f"If{MAAEND_DELIVERY_TASK}", True, BoolValidator()), + ) + for task_name in MAAEND_TASKS: setattr( config, @@ -973,6 +993,18 @@ def __init__(self) -> None: super().__init__() +class MaaEndConfigModeValidator(OptionsValidator): + """兼容旧版来源名称,统一为脚本/用户/直控。""" + + LEGACY_MODE_MAP = {"简洁": "脚本", "详细": "用户", "自定义": "用户"} + + def __init__(self) -> None: + super().__init__(["脚本", "用户", "直控"]) + + def correct(self, value: Any) -> Any: + return self.LEGACY_MODE_MAP.get(value, super().correct(value)) + + class MaaEndUserConfig(ConfigBase): """MaaEnd用户配置""" @@ -991,9 +1023,7 @@ def __init__(self) -> None: ## 密码 self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator()) ## 配置文件来源 - self.Info_Mode = ConfigItem( - "Info", "Mode", "脚本", ScriptUserModeValidator() - ) + self.Info_Mode = ConfigItem("Info", "Mode", "脚本", MaaEndConfigModeValidator()) ## 是否启用快速配置 self.Info_IfQuickConfig = ConfigItem( "Info", "IfQuickConfig", True, BoolValidator() @@ -1094,7 +1124,6 @@ async def load(self, data: dict): ): info_data["Mode"] = "脚本" info_data.pop("IfQuickConfig", None) - task_data = data.get("Task") if isinstance(task_data, dict): _normalize_maaend_sanity_task_type(task_data) diff --git a/app/models/schema.py b/app/models/schema.py index 2f323b7e9..b88de7bb8 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -1225,8 +1225,9 @@ class MaaEndUserConfig_Info(BaseModel): Status: Optional[bool] = Field(default=None, description="用户状态") Id: Optional[str] = Field(default=None, description="用户ID") Password: Optional[str] = Field(default=None, description="密码") - Mode: Optional[Literal["脚本", "用户"]] = Field( - default=None, description="配置来源(脚本/用户)" + Mode: Optional[Literal["脚本", "用户", "直控"]] = Field( + default=None, + description="配置来源(脚本共享、用户独立、脚本直控)", ) IfQuickConfig: Optional[bool] = Field(default=None, description="是否启用快速配置") SanityMode: Optional[str] = Field(default=None, description="理智任务配置模式") @@ -1280,7 +1281,13 @@ class MaaEndUserConfig_Task(BaseModel): IfAutoStockStaple: Optional[bool] = Field(default=None, description="购买稳定物资") IfVisitFriends: Optional[bool] = Field(default=None, description="拜访好友") IfCreditShoppingN2: Optional[bool] = Field(default=None, description="信用点购物") - IfSeizeEntrustTask: Optional[bool] = Field(default=None, description="抢委托") + SeizeDeliveryJobsReward: Optional[float] = Field( + default=None, ge=0, description="抢委托送货最低接取价格(万)" + ) + SeizeDeliveryJobsCommissionSource: Optional[ + Literal["Unlimited", "WulingCity", "TestArea"] + ] = Field(default=None, description="抢委托送货委托接收点") + IfSeizeDeliveryJobs: Optional[bool] = Field(default=None, description="抢委托送货") IfAutoEcoFarm: Optional[bool] = Field(default=None, description="生态农场") IfAutoSell: Optional[bool] = Field(default=None, description="售卖弹性物资") IfEnvironmentMonitoring: Optional[bool] = Field( diff --git a/app/task/MaaEnd/AutoProxy.py b/app/task/MaaEnd/AutoProxy.py index ca0af73d6..13bc85f20 100644 --- a/app/task/MaaEnd/AutoProxy.py +++ b/app/task/MaaEnd/AutoProxy.py @@ -36,17 +36,39 @@ from app.services import Notify, System from app.task.general.tools import execute_script_task from app.utils import LogMonitor, ProcessManager, get_logger, is_process_running -from app.utils.constants import MAAEND_TASKS, UTC4 +from app.utils.constants import ( + MAAEND_DELIVERY_TASK, + MAAEND_RUN_MOOD_BOOK, + MAAEND_TASKS, + UTC4, +) from app.utils.io import read_file, write_file from .resource_loader import ( load_maaend_interface_i18n, load_maaend_task_i18n, ) +from .ScriptConfig import maaend_config_mode, maaend_mas_config_dir from .tools import login, push_notification, replace_account_switch_task logger = get_logger("MaaEnd 自动代理") +_MAAEND_ACCOUNT_SWITCH_TASK = "AccountSwitch" + + +def _task_enabled_for_mode(task_name: object, enabled: object, mode: str) -> bool: + """按送货/日常阶段筛选 MaaEnd 任务。""" + + name = str(task_name) + if mode == "Delivery": + return bool(enabled) and name in { + MAAEND_DELIVERY_TASK, + _MAAEND_ACCOUNT_SWITCH_TASK, + } + if mode == "Routine": + return bool(enabled) and name != MAAEND_DELIVERY_TASK + raise ValueError(f"未知 MaaEnd 运行模式: {mode}") + class AutoProxyTask(TaskExecuteBase): """MaaEnd 自动代理模式""" @@ -75,6 +97,12 @@ def __init__( self.account_switch_task_name = "" self.color_match_failed_message: str | None = None self.retryable = True + self.mode = "Routine" + self.run_book: dict[str, bool] = {mode: False for mode in MAAEND_RUN_MOOD_BOOK} + self.task_dict: dict[str, dict[str, bool]] | None = None + self.unique_task: dict[str, str] = {} + self.maaend_config_file: Path | None = None + self.delivery_stage_enabled = False async def check(self) -> str: @@ -100,15 +128,21 @@ async def check(self) -> str: "请检查账号ID或改用 MAS 自建账号切换" ) - config_user_id = ( - "Default" - if self.cur_user_config.get("Info", "Mode") == "脚本" - else self.cur_user_uid - ) - config_file = ( - Path.cwd() - / f"data/{self.script_info.script_id}/{config_user_id}/ConfigFile/mxu-MaaEnd.json" - ) + config_mode = maaend_config_mode(self.cur_user_config.get("Info", "Mode")) + if config_mode == "直控": + config_file = ( + Path.cwd() / f"data/{self.script_info.script_id}/Temp/mxu-MaaEnd.json" + ) + else: + config_file = ( + maaend_mas_config_dir( + self.script_info.script_id, + str(self.cur_user_uid), + config_mode, + ) + / "mxu-MaaEnd.json" + ) + self.maaend_config_file = config_file if not config_file.exists(): self.cur_user_item.status = "异常" return "未找到 MaaEnd 配置文件, 请先完成「MaaEnd 配置」步骤" @@ -134,7 +168,73 @@ async def prepare(self): (1, 23), "%Y-%m-%d %H:%M:%S.%f", self.check_log ) - self.run_book = False + mode_enabled = { + mode: self._mode_has_enabled_tasks(mode) for mode in MAAEND_RUN_MOOD_BOOK + } + self.delivery_stage_enabled = mode_enabled["Delivery"] + self.run_book = {mode: not enabled for mode, enabled in mode_enabled.items()} + + def _source_maaend_tasks(self) -> list[dict[str, object]] | None: + """读取当前用户所选 MaaEnd 实例的任务列表。""" + + if self.maaend_config_file is None: + return None + try: + source_config = read_file(self.maaend_config_file) + except (OSError, TypeError, ValueError): + return None + if not isinstance(source_config, dict): + return None + + instances = source_config.get("instances") + if not isinstance(instances, list) or not instances: + return None + + selected_instance = None + for instance in instances: + if not isinstance(instance, dict): + continue + if instance.get("id") == "automas" or instance.get("name") == "AUTO-MAS": + selected_instance = instance + break + if instance.get("id") == source_config.get("lastActiveInstanceId"): + selected_instance = instance + break + if selected_instance is None: + selected_instance = instances[0] + tasks = selected_instance.get("tasks") + if not isinstance(tasks, list): + return None + return [task for task in tasks if isinstance(task, dict)] + + def _mode_has_enabled_tasks(self, mode: str) -> bool: + """判断某阶段是否有任务,空阶段直接视为完成。""" + + if self.cur_user_config.get("Info", "IfQuickConfig"): + if mode == "Delivery": + return bool(self.cur_user_config.get("Task", "IfSeizeDeliveryJobs")) + return any( + bool(self.cur_user_config.get("Task", f"If{task_name}")) + for task_name in MAAEND_TASKS + ) + + tasks = self._source_maaend_tasks() + if tasks is None: + # 配置读取失败时交给 MaaEnd 执行并由日志判定,避免误跳过用户任务。 + return True + if mode == "Delivery": + return any( + str(task.get("taskName", "")) == MAAEND_DELIVERY_TASK + and bool(task.get("enabled", False)) + for task in tasks + ) + return any( + _task_enabled_for_mode( + task.get("taskName"), task.get("enabled", False), mode + ) + for task in tasks + if not str(task.get("taskName", "")).startswith("__MXU_") + ) async def main_task(self): """自动代理模式主逻辑""" @@ -162,19 +262,33 @@ async def main_task(self): logger.info(f"开始代理用户 {self.cur_user_uid}") self.cur_user_item.status = "运行" - self.task_dict: dict[str, dict[str, bool]] | None = None - self.unique_task: dict[str, str] = {} - run_times_limit = self.script_config.get("Run", "RunTimesLimit") maaend_update_retry_used = False i = 0 - while i < run_times_limit: - if self.run_book: - break + mode_order = list(MAAEND_RUN_MOOD_BOOK) + mode_index = 0 + while mode_index < len(mode_order): + self.mode = mode_order[mode_index] + if self.run_book[self.mode]: + mode_index += 1 + i = 0 + maaend_update_retry_used = False + self.task_dict = None + continue + if i >= run_times_limit: + logger.warning( + f"用户: {self.cur_user_uid} - {MAAEND_RUN_MOOD_BOOK[self.mode]}阶段重试次数已耗尽" + ) + mode_index += 1 + i = 0 + maaend_update_retry_used = False + self.task_dict = None + continue i += 1 self.retryable = True logger.info( - f"用户 {self.cur_user_item.name} - 尝试次数: {i}/{run_times_limit}" + f"用户 {self.cur_user_item.name} - {MAAEND_RUN_MOOD_BOOK[self.mode]}" + f"阶段尝试次数: {i}/{run_times_limit}" ) self.log_start_time = datetime.now() self.cur_user_item.log_record[self.log_start_time] = self.cur_user_log = ( @@ -314,26 +428,27 @@ async def main_task(self): await self.maaend_log_monitor.stop() if self.cur_user_log.status == "Success!": - self.run_book = True + self.run_book[self.mode] = True self.script_info.log = ( - "检测到 MaaEnd 完成代理任务\n正在等待相关程序结束" + f"检测到 MaaEnd 完成{MAAEND_RUN_MOOD_BOOK[self.mode]}任务\n" + "正在等待相关程序结束" ) # 中止相关程序 await self.maaend_process_manager.kill() await System.kill_process(self.maaend_exe_path) - # 执行任务后脚本 - if self.cur_user_config.get("Info", "IfScriptAfterTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptAfterTask")), - "脚本后任务", - ) + mode_index += 1 + i = 0 + maaend_update_retry_used = False + self.task_dict = None else: if self.cur_user_log.status == "MaaEnd 正在更新": - logger.info("MaaEnd 更新流程已退出,准备自动重试当前用户") - self.script_info.log = "MaaEnd 更新完成,正在自动重试当前用户" + logger.info( + f"MaaEnd 更新流程已退出,准备自动重试{MAAEND_RUN_MOOD_BOOK[self.mode]}阶段" + ) + self.script_info.log = "MaaEnd 更新完成,正在自动重试当前阶段" # MaaEnd 更新后只重启脚本本体,保留 Endfield 进程减少重试成本。 await self.maaend_process_manager.kill() @@ -346,7 +461,8 @@ async def main_task(self): continue logger.warning("MaaEnd 更新后已自动重试一次,跳过后续重试") - break + i = run_times_limit + continue logger.warning( f"用户: {self.cur_user_uid} - 代理任务异常: {self.cur_user_log.status}" @@ -363,16 +479,15 @@ async def main_task(self): 3, ) - # 执行任务后脚本 - if self.cur_user_config.get("Info", "IfScriptAfterTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptAfterTask")), - "脚本后任务", - ) - if not self.retryable: logger.info("检测到游戏画面参数错误,跳过后续重试") - break + i = run_times_limit + + if self.cur_user_config.get("Info", "IfScriptAfterTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) async def handle_pre_maaend_error( self, error_message: str, e: Exception | None = None @@ -441,14 +556,15 @@ async def set_maaend(self, device_info: DeviceInfo | None) -> None: if (self.maaend_set_path / "mxu-MaaEnd.json").exists(): maaend_local_config = read_file(self.maaend_set_path / "mxu-MaaEnd.json") - config_user_id = ( - "Default" - if self.cur_user_config.get("Info", "Mode") == "脚本" - else self.cur_user_uid - ) + config_mode = maaend_config_mode(self.cur_user_config.get("Info", "Mode")) maaend_config_path = ( - Path.cwd() - / f"data/{self.script_info.script_id}/{config_user_id}/ConfigFile" + Path.cwd() / f"data/{self.script_info.script_id}/Temp" + if config_mode == "直控" + else maaend_mas_config_dir( + self.script_info.script_id, + str(self.cur_user_uid), + config_mode, + ) ) maaend_config_file = maaend_config_path / "mxu-MaaEnd.json" if not maaend_config_file.exists(): @@ -571,24 +687,39 @@ def get_task_book_name(task: dict[str, object]) -> str: ) for task in maaend_tasks: - if task["taskName"].startswith("__MXU_"): + task_name_value = str(task.get("taskName")) + if task_name_value.startswith("__MXU_"): continue - task_enabled = task["enabled"] + task_enabled = bool(task.get("enabled", False)) if if_quick_config: - if task["taskName"] in ("ProtocolSpace", "AutoEssence"): + if task_name_value in ("ProtocolSpace", "AutoEssence"): if sanity_managed: task_enabled = ( sanity_switch_enabled - and task["taskName"] == target_task_name + and task_name_value == target_task_name and not sanity_configured ) if task_enabled: sanity_configured = True - elif task["taskName"] in MAAEND_TASKS: + elif task_name_value == MAAEND_DELIVERY_TASK: task_enabled = self.cur_user_config.get( - "Task", f"If{task['taskName']}" + "Task", f"If{MAAEND_DELIVERY_TASK}" ) + elif task_name_value in MAAEND_TASKS: + task_enabled = self.cur_user_config.get( + "Task", f"If{task_name_value}" + ) + + task_enabled = _task_enabled_for_mode( + task_name_value, task_enabled, self.mode + ) + if ( + task_name_value == _MAAEND_ACCOUNT_SWITCH_TASK + and self.mode == "Routine" + and self.delivery_stage_enabled + ): + task_enabled = False task_name = get_task_book_name(task) if task_name not in self.task_dict: @@ -609,19 +740,36 @@ def get_task_book_name(task: dict[str, object]) -> str: # 按本轮任务表写回 MaaEnd 运行配置 for task in maaend_tasks: - if task["taskName"].startswith("__MXU_"): + task_name_value = str(task.get("taskName")) + if task_name_value.startswith("__MXU_"): continue task_name = get_task_book_name(task) if task_name in self.task_dict and task["id"] in self.task_dict[task_name]: task["enabled"] = self.task_dict[task_name][task["id"]] - if not task["enabled"]: + if not task.get("enabled", False): continue - if ( + if if_quick_config and task_name_value == MAAEND_DELIVERY_TASK: + task.setdefault("optionValues", {}) + task["optionValues"]["SeizeDeliveryJobsReward"] = { + "type": "input", + "values": { + "Reward": str( + self.cur_user_config.get("Task", "SeizeDeliveryJobsReward") + ) + }, + } + task["optionValues"]["SeizeDeliveryJobsCommissionSource"] = { + "type": "select", + "caseName": self.cur_user_config.get( + "Task", "SeizeDeliveryJobsCommissionSource" + ), + } + elif ( if_quick_config - and task["taskName"] == target_task_name + and task_name_value == target_task_name and target_task_name == "ProtocolSpace" ): task.setdefault("optionValues", {}) @@ -691,7 +839,7 @@ def get_task_book_name(task: dict[str, object]) -> str: } elif ( if_quick_config - and task["taskName"] == target_task_name + and task_name_value == target_task_name and target_task_name == "AutoEssence" ): task.setdefault("optionValues", {}) @@ -729,6 +877,11 @@ async def monitor_maaend_update_download(self) -> None: self.script_info.log = "检测到 MaaEnd 正在更新,正在等待更新进程退出" if_maaend_updating = True + if if_maaend_updating and not await self.maaend_process_manager.is_running(): + logger.info("MaaEnd 更新进程已退出,后台检测释放日志锁") + self.wait_event.set() + return + await asyncio.sleep(5) async def check_log( @@ -836,7 +989,7 @@ async def final_task(self): await self.maaend_log_monitor.stop() if ( self.script_info.current_index == len(self.script_info.user_list) - 1 - and self.run_book + and all(self.run_book.values()) and not self.script_config.get("Game", "CloseOnFinish") ): try: @@ -889,10 +1042,12 @@ async def final_task(self): statistics["start_time"] = self.user_start_time.strftime("%Y-%m-%d %H:%M:%S") statistics["end_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") statistics["user_result"] = ( - "代理任务全部完成" if self.run_book else self.cur_user_item.result + "代理任务全部完成" + if all(self.run_book.values()) + else self.cur_user_item.result ) - success_symbol = "√" if self.run_book else "X" + success_symbol = "√" if all(self.run_book.values()) else "X" if user_logs_list: try: @@ -912,7 +1067,7 @@ async def final_task(self): ), ) - if self.run_book: + if all(self.run_book.values()): if ( self.cur_user_config.get("Data", "ProxyTimes") == 0 and self.cur_user_config.get("Info", "RemainedDay") != -1 diff --git a/app/task/MaaEnd/ScriptConfig.py b/app/task/MaaEnd/ScriptConfig.py index 2ea4f42cf..8eec07867 100644 --- a/app/task/MaaEnd/ScriptConfig.py +++ b/app/task/MaaEnd/ScriptConfig.py @@ -21,6 +21,7 @@ import asyncio import shutil +import uuid from pathlib import Path from typing import Any @@ -37,6 +38,23 @@ logger = get_logger("MaaEnd 脚本设置") +def maaend_config_mode(raw: object) -> str: + """读取 MaaEnd 配置来源,并兼容旧版名称。""" + + mode = str(raw or "脚本") + return {"简洁": "脚本", "详细": "用户", "自定义": "用户"}.get(mode, mode) + + +def maaend_mas_config_dir(script_id: str, user_id: str, mode: str) -> Path: + """返回脚本共享或用户独立配置目录。""" + + mode = maaend_config_mode(mode) + if mode == "直控": + raise ValueError("直控配置不使用 MAS 配置目录") + owner = "Default" if mode == "脚本" else user_id + return Path.cwd() / "data" / script_id / owner / "ConfigFile" + + def normalize_maaend_config( maaend_set: dict[str, Any], controller_type: str, @@ -99,7 +117,24 @@ def __init__( self.task_info = script_info.task_info self.script_info = script_info self.script_config = script_config + self.user_config = user_config self.cur_user_item = self.script_info.user_list[self.script_info.current_index] + target_user_id = self.cur_user_item.user_id + self.config_mode = "脚本" + if target_user_id != "Default": + self.config_mode = maaend_config_mode( + self.user_config[uuid.UUID(target_user_id)].get("Info", "Mode") + ) + self.use_mas_config = self.config_mode != "直控" + self.config_file_path = ( + maaend_mas_config_dir( + self.script_info.script_id, + target_user_id, + self.config_mode, + ) + if self.use_mas_config + else None + ) async def prepare(self): @@ -109,10 +144,6 @@ async def prepare(self): self.maaend_root_path = Path(self.script_config.get("Info", "Path")) self.maaend_set_path = self.maaend_root_path / "config" self.maaend_exe_path = self.maaend_root_path / "MaaEnd.exe" - self.config_file_path = ( - Path.cwd() - / f"data/{self.script_info.script_id}/{self.cur_user_item.user_id}/ConfigFile" - ) async def main_task(self): @@ -132,10 +163,18 @@ async def set_maaend(self): await self.maaend_process_manager.kill() await System.kill_process(self.maaend_exe_path) - if (self.config_file_path / "mxu-MaaEnd.json").exists(): + if ( + self.use_mas_config + and self.config_file_path + and (self.config_file_path / "mxu-MaaEnd.json").exists() + ): shutil.rmtree(self.maaend_set_path, ignore_errors=True) shutil.copytree(self.config_file_path, self.maaend_set_path) - elif self.maaend_set_path.exists(): + elif ( + self.use_mas_config + and self.config_file_path + and self.maaend_set_path.exists() + ): shutil.copytree( self.maaend_set_path, self.config_file_path, @@ -148,31 +187,36 @@ async def set_maaend(self): "未找到 MaaEnd 配置文件, 请检查 MaaEnd 路径设置或先启动 MaaEnd 完成配置文件生成" ) - maaend_set = read_file(maaend_set_path) - maaend_set = normalize_maaend_config( - maaend_set, - self.script_config.get("Game", "ControllerType"), - ) - - write_file(maaend_set_path, maaend_set) - logger.success( - f"MaaEnd 运行参数配置完成: 设置脚本 {self.cur_user_item.user_id}" - ) + if self.use_mas_config: + maaend_set = read_file(maaend_set_path) + maaend_set = normalize_maaend_config( + maaend_set, + self.script_config.get("Game", "ControllerType"), + ) + write_file(maaend_set_path, maaend_set) + logger.success(f"MaaEnd 运行参数配置完成: {self.config_mode}配置") async def final_task(self): await self.maaend_process_manager.kill() await System.kill_process(self.maaend_exe_path) - shutil.rmtree(self.config_file_path, ignore_errors=True) - self.config_file_path.mkdir(parents=True, exist_ok=True) - shutil.copytree(self.maaend_set_path, self.config_file_path, dirs_exist_ok=True) - config_path = self.config_file_path / "mxu-MaaEnd.json" - maaend_set = read_file(config_path) - maaend_set = normalize_maaend_config( - maaend_set, self.script_config.get("Game", "ControllerType") - ) - write_file(config_path, maaend_set) + if self.use_mas_config and self.config_file_path: + shutil.rmtree(self.config_file_path, ignore_errors=True) + self.config_file_path.mkdir(parents=True, exist_ok=True) + shutil.copytree( + self.maaend_set_path, self.config_file_path, dirs_exist_ok=True + ) + config_path = self.config_file_path / "mxu-MaaEnd.json" + maaend_set = read_file(config_path) + maaend_set = normalize_maaend_config( + maaend_set, self.script_config.get("Game", "ControllerType") + ) + write_file(config_path, maaend_set) + logger.success(f"MaaEnd 配置已保存到: {self.config_file_path}") + else: + logger.success("MaaEnd 直控配置已由脚本原生 GUI 保存") + self.cur_user_item.status = "完成" async def on_crash(self, e: Exception): self.cur_user_item.status = "异常" diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index ac8b6324d..98ca111ae 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -21,6 +21,7 @@ import shutil import uuid +from contextlib import suppress from datetime import datetime from pathlib import Path @@ -39,7 +40,7 @@ from .AutoProxy import AutoProxyTask from .resource_loader import load_maaend_controller_protocol -from .ScriptConfig import ScriptConfigTask +from .ScriptConfig import ScriptConfigTask, maaend_config_mode from .tools import push_notification logger = get_logger("MaaEnd 调度器") @@ -63,6 +64,11 @@ def __init__(self, script_info: ScriptItem): self.script_info = script_info self.check_result = "-" self.controller_protocol = "" + self.user_config: MultipleConfig[MaaEndUserConfig] | None = None + self.maaend_config_dir: Path | None = None + self.temp_path: Path | None = None + self.had_original_script_config = False + self.script_config_mode = "脚本" async def check(self) -> str: if self.task_info.mode not in METHOD_BOOK: @@ -133,15 +139,19 @@ async def prepare(self): shutil.rmtree(self.temp_path, ignore_errors=True) self.temp_path.mkdir(parents=True, exist_ok=True) if self.maaend_config_dir.exists(): + self.had_original_script_config = True shutil.copytree(self.maaend_config_dir, self.temp_path, dirs_exist_ok=True) # 构建用户列表 if self.task_info.mode == "ScriptConfig": + target_user_id = self.task_info.user_id or "Default" self.script_info.user_list = [ - UserItem( - user_id=self.task_info.user_id or "Default", name="", status="等待" - ) + UserItem(user_id=target_user_id, name="", status="等待") ] + if target_user_id != "Default": + self.script_config_mode = maaend_config_mode( + self.user_config[uuid.UUID(target_user_id)].get("Info", "Mode") + ) else: self.script_info.user_list = [ UserItem( @@ -156,6 +166,41 @@ async def prepare(self): f"用户列表加载完成, 已筛选用户数: {len(self.script_info.user_list)}" ) + async def _restore_script_config_from_temp(self) -> None: + """恢复任务开始前的 MaaEnd working 配置。""" + + if ( + not self.temp_path + or not self.temp_path.exists() + or not self.maaend_config_dir + ): + return + if not self.had_original_script_config: + shutil.rmtree(self.maaend_config_dir, ignore_errors=True) + return + + temporary_path = self.maaend_config_dir.with_name( + self.maaend_config_dir.name + ".tmp" + ) + shutil.rmtree(temporary_path, ignore_errors=True) + shutil.copytree(self.temp_path, temporary_path, dirs_exist_ok=True) + shutil.rmtree(self.maaend_config_dir, ignore_errors=True) + temporary_path.rename(self.maaend_config_dir) + + def _cleanup_script_config_temp(self) -> None: + if self.temp_path: + shutil.rmtree(self.temp_path, ignore_errors=True) + + def _keep_script_config_changes(self) -> bool: + """直控配置会话成功时保留 MaaEnd GUI 的写回。""" + + return ( + self.task_info.mode == "ScriptConfig" + and self.script_config_mode == "直控" + and bool(self.script_info.user_list) + and self.script_info.user_list[0].status == "完成" + ) + async def main_task(self): self.check_result = await self.check() @@ -175,13 +220,25 @@ async def main_task(self): raise RuntimeError("脚本配置类型错误, 不是 MaaEnd 脚本类型") for self.script_info.current_index in range(len(self.script_info.user_list)): + current_user = self.script_info.user_list[self.script_info.current_index] + if self.task_info.mode != "ScriptConfig": + current_config = self.user_config[uuid.UUID(current_user.user_id)] + config_mode = maaend_config_mode(current_config.get("Info", "Mode")) + logger.info(f"用户 {current_user.user_id} 配置来源: {config_mode}") + if config_mode == "直控": + await self._restore_script_config_from_temp() + task = METHOD_BOOK[self.task_info.mode]( self.script_info, self.script_config, self.user_config, self.emulator_manager, ) - await self.spawn(task) + try: + await self.spawn(task) + finally: + if self.task_info.mode != "ScriptConfig": + await self._restore_script_config_from_temp() async def final_task(self): @@ -190,6 +247,12 @@ async def final_task(self): return logger.info("MaaEnd 主任务已结束, 开始执行后续操作") + if self._keep_script_config_changes(): + logger.info("直控配置会话成功,保留 MaaEnd 原生配置") + else: + await self._restore_script_config_from_temp() + self._cleanup_script_config_temp() + await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() logger.success(f"已解锁脚本配置 {self.script_info.script_id}") @@ -250,17 +313,28 @@ async def final_task(self): ), ) - # 还原配置 - if (self.temp_path).exists(): - shutil.rmtree(self.maaend_config_dir, ignore_errors=True) - shutil.copytree(self.temp_path, self.maaend_config_dir, dirs_exist_ok=True) - shutil.rmtree(self.temp_path, ignore_errors=True) - - self.script_info.status = "完成" + if any(user.status == "异常" for user in self.script_info.user_list): + self.script_info.status = "异常" + else: + self.script_info.status = "完成" async def on_crash(self, e: Exception): self.script_info.status = "异常" logger.opt(exception=True).warning(f"MaaEnd任务出现异常: {e}") + with suppress(Exception): + await self._restore_script_config_from_temp() + self._cleanup_script_config_temp() + + script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] + if script_config.is_locked: + with suppress(Exception): + await script_config.unlock() + + if self.task_info.mode in ("AutoProxy",) and self.user_config: + with suppress(Exception): + await script_config.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + await Publisher.send( id=self.task_info.task_id, type=protocol.TASK_NOTICE, diff --git a/app/utils/constants.py b/app/utils/constants.py index a43c58be6..476530ea3 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -64,6 +64,15 @@ } """MAA运行模式对应的超时配置项:绿票商店只买一次商店,复用日常时限""" +MAAEND_RUN_MOOD_BOOK = {"Delivery": "送货", "Routine": "日常"} +"""MaaEnd 自动代理运行模式映射表""" + +MAAEND_DELIVERY_TASK = "SeizeDeliveryJobs" +"""MaaEnd 送货阶段使用的任务名称""" + +MAAEND_DELIVERY_COMMISSION_SOURCES = ("Unlimited", "WulingCity", "TestArea") +"""MaaEnd 抢委托送货的委托接收点选项""" + MAA_TASKS = [ "StartUp", "DepotMaintain", @@ -330,7 +339,6 @@ "tasks": ( ("VisitFriends", "拜访好友"), ("CreditShoppingN2", "信用点购物"), - ("SeizeEntrustTask", "抢委托"), ), }, "Frontend": { @@ -362,7 +370,7 @@ for group in MAAEND_TASK_GROUPS.values() for task_name, _ in group["tasks"] ) -"""MaaEnd托管任务列表""" +"""MaaEnd快速控制任务列表(不含独立送货任务)""" MAAEND_SANITY_TASK_DEFAULTS = { "SanityTaskType": "OperatorProgression", diff --git a/frontend/src/components/ScriptTable.vue b/frontend/src/components/ScriptTable.vue index 189aadd9f..ca16137ad 100644 --- a/frontend/src/components/ScriptTable.vue +++ b/frontend/src/components/ScriptTable.vue @@ -736,7 +736,11 @@ const isMaaEndPresetSupported = (script: Script) => { } const shouldShowMaaEndUserConfigButton = (script: Script, user: User) => { - return script.type === 'MaaEnd' && user.Info?.Mode === '用户' + return script.type === 'MaaEnd' && user.Info?.Mode !== '脚本' +} + +const handleSaveMaaEndConfig = (script: Script) => { + emit('saveMaaEndConfig', script) } const handleStartOkwwConfig = (script: Script) => { diff --git a/frontend/src/composables/useScriptApi.ts b/frontend/src/composables/useScriptApi.ts index 9acac6c14..b65a9a200 100644 --- a/frontend/src/composables/useScriptApi.ts +++ b/frontend/src/composables/useScriptApi.ts @@ -608,10 +608,18 @@ export function useScriptApi() { maaEndUserData.Task?.IfCreditShoppingN2 != null ? maaEndUserData.Task.IfCreditShoppingN2 : true, - IfSeizeEntrustTask: - maaEndUserData.Task?.IfSeizeEntrustTask != null - ? maaEndUserData.Task.IfSeizeEntrustTask + IfSeizeDeliveryJobs: + maaEndUserData.Task?.IfSeizeDeliveryJobs != null + ? maaEndUserData.Task.IfSeizeDeliveryJobs : true, + SeizeDeliveryJobsReward: + maaEndUserData.Task?.SeizeDeliveryJobsReward != null + ? maaEndUserData.Task.SeizeDeliveryJobsReward + : 15.9, + SeizeDeliveryJobsCommissionSource: + maaEndUserData.Task?.SeizeDeliveryJobsCommissionSource != null + ? maaEndUserData.Task.SeizeDeliveryJobsCommissionSource + : 'Unlimited', IfAutoEcoFarm: maaEndUserData.Task?.IfAutoEcoFarm != null ? maaEndUserData.Task.IfAutoEcoFarm @@ -640,6 +648,10 @@ export function useScriptApi() { maaEndUserData.Task?.IfResourceRecycleStation != null ? maaEndUserData.Task.IfResourceRecycleStation : true, + IfPullCountCalculator: + maaEndUserData.Task?.IfPullCountCalculator != null + ? maaEndUserData.Task.IfPullCountCalculator + : false, }, Notify: { Enabled: diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index 1fff7be29..f7c69567e 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -13,6 +13,7 @@ import type { } from '@/api' import type { AutoEssenceLocation, + MaaEndDeliveryCommissionSource, MaaEndTaskSwitch, ProtocolSpaceTaskValue, RewardSetOption, @@ -134,9 +135,13 @@ export interface SRCScriptConfig { } } -export type MaaEndTaskSwitchConfig = Record<`If${MaaEndTaskSwitch}`, boolean> +export type MaaEndTaskSwitchConfig = Record<`If${MaaEndTaskSwitch}`, boolean> & { + IfSeizeDeliveryJobs: boolean +} export type MaaEndTaskConfig = MaaEndTaskSwitchConfig & { + SeizeDeliveryJobsReward: number + SeizeDeliveryJobsCommissionSource: MaaEndDeliveryCommissionSource SanityTaskType: SanityTaskType OperatorProgression: ProtocolSpaceTaskValue WeaponProgression: ProtocolSpaceTaskValue diff --git a/frontend/src/utils/maaEndProtocolSpace.ts b/frontend/src/utils/maaEndProtocolSpace.ts index 668554b8c..537f60e75 100644 --- a/frontend/src/utils/maaEndProtocolSpace.ts +++ b/frontend/src/utils/maaEndProtocolSpace.ts @@ -2,6 +2,15 @@ import type { ComboBoxItem } from '@/api' // Frontend mirror of app/utils/constants.py. Keep fixed protocol-space values in sync // with the backend; AutoEssence locations come from MaaEnd's dynamic resource API. +export const MAAEND_DELIVERY_COMMISSION_SOURCE_OPTIONS = [ + { label: '不限', value: 'Unlimited' }, + { label: '武陵城', value: 'WulingCity' }, + { label: '试验园区', value: 'TestArea' }, +] as const + +export type MaaEndDeliveryCommissionSource = + (typeof MAAEND_DELIVERY_COMMISSION_SOURCE_OPTIONS)[number]['value'] + export const PROTOCOL_SPACE_OPTIONS = [ { label: '干员养成', value: 'OperatorProgression' }, { label: '武器养成', value: 'WeaponProgression' }, @@ -115,7 +124,6 @@ export const MAAEND_TASK_GROUPS = [ tasks: [ { name: 'VisitFriends', label: '🤝 拜访好友' }, { name: 'CreditShoppingN2', label: '🛍️ 信用点购物' }, - { name: 'SeizeEntrustTask', label: '🌆 抢委托' }, ], }, { diff --git a/frontend/src/views/EditView/User/MaaEndUserEdit.vue b/frontend/src/views/EditView/User/MaaEndUserEdit.vue index 067943d1b..0ba2df70f 100644 --- a/frontend/src/views/EditView/User/MaaEndUserEdit.vue +++ b/frontend/src/views/EditView/User/MaaEndUserEdit.vue @@ -54,6 +54,13 @@ @configure="handleMaaEndConfig" @import-config="handleImportMaaEndConfig" @script-config="handleScriptConfig" + @mode-change="handleConfigModeChange" + /> + ({ CrisisDrills: 'AdvancedProgression1', RewardsSetOption: 'RewardsSetA', AutoEssenceSpecifiedLocation: '', + SeizeDeliveryJobsReward: 15.9, + SeizeDeliveryJobsCommissionSource: 'Unlimited', IfSanity: true, IfAutoUseSpMedication: true, IfDijiangRewards: true, @@ -196,7 +206,7 @@ const getDefaultMaaEndUserData = () => ({ IfAutoStockStaple: true, IfVisitFriends: true, IfCreditShoppingN2: true, - IfSeizeEntrustTask: true, + IfSeizeDeliveryJobs: true, IfAutoEcoFarm: true, IfAutoSell: true, IfEnvironmentMonitoring: true, @@ -204,6 +214,7 @@ const getDefaultMaaEndUserData = () => ({ IfTrialOfSwordmancy: true, IfDailyRewards: true, IfResourceRecycleStation: true, + IfPullCountCalculator: false, }, Notify: { Enabled: false, @@ -283,6 +294,12 @@ const handleFieldSave = async (key: string, value: any) => { await saveUserFields([{ key, value }]) } +const handleConfigModeChange = async (value: boolean | string) => { + if (typeof value !== 'string' || !['脚本', '用户', '直控'].includes(value)) return + formData.Info.Mode = value + await handleFieldSave('Info.Mode', value) +} + const handleFieldsSave = async (changes: FieldChange[]) => { await saveUserFields(changes) } @@ -433,9 +450,8 @@ const handleMaaEndConfig = async () => { maaEndConfigLoading.value = true cleanupConfigSession() - const configTaskTargetId = formData.Info.Mode === '脚本' ? scriptId : userId const response = await Service.addTaskApiDispatchStartPost({ - taskId: configTaskTargetId, + taskId: userId, mode: TaskCreateIn.mode.SCRIPT_CONFIG, }) @@ -458,9 +474,15 @@ const handleMaaEndConfig = async () => { maaEndSubscriptionIds.value = subscriptionIds maaEndTaskId.value = response.taskId showMaaEndConfigMask.value = true + const configTarget = + formData.Info.Mode === '直控' + ? '脚本直控' + : formData.Info.Mode === '用户' + ? '用户独立' + : '脚本共享' message.success( t('edit.startedP0MaaendConfiguration', { - p0: formData.Info.Mode === '脚本' ? '脚本' : '用户', + p0: configTarget, }) ) @@ -481,6 +503,9 @@ const handleMaaEndConfig = async () => { const handleImportMaaEndConfig = async () => { try { maaEndImportLoading.value = true + if (formData.Info.Mode === '直控') { + throw new Error('脚本直控直接使用 MaaEnd 原有配置,无需导入') + } const response = await importScriptConfigFile( scriptId, formData.Info.Mode === '脚本' ? null : userId @@ -488,8 +513,9 @@ const handleImportMaaEndConfig = async () => { if (response.code !== 200) { throw new Error(response.message || '导入脚本配置文件失败') } + const importTarget = formData.Info.Mode === '脚本' ? '脚本共享' : '用户独立' message.success( - t('edit.importedP0ConfigurationFile', { p0: formData.Info.Mode === '脚本' ? '脚本' : '用户' }) + t('edit.importedP0ConfigurationFile', { p0: importTarget }) ) } catch (error) { message.error(error instanceof Error ? error.message : '导入脚本配置文件失败') diff --git a/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue b/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue index e6d3ba3ca..d9977c7c0 100644 --- a/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue +++ b/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue @@ -89,38 +89,19 @@ + + + - - +
- - - {{ t('edit.editScriptSettings') }} - - - {{ showConfigMask ? '正在配置' : '配置' }} + {{ showConfigMask ? '正在配置' : `配置${currentConfigModeLabel}` }} {{ t('edit.import2') }} + + + {{ t('edit.editScriptSettings') }} +
@@ -243,6 +235,8 @@ import { QuestionCircleOutlined, SettingOutlined, } from '@ant-design/icons-vue' +import { computed } from 'vue' +import GeneralConfigModeSelector from '@/views/EditView/User/GeneralConfigModeSelector.vue' const { t } = useI18n() const emit = defineEmits<{ @@ -250,10 +244,10 @@ const emit = defineEmits<{ configure: [] importConfig: [] scriptConfig: [] + modeChange: [value: boolean | string] }>() const formData = defineModel('formData', { required: true }) - defineProps<{ loading: boolean resourceOptions: Array<{ label: string; value: string }> @@ -263,9 +257,30 @@ defineProps<{ showConfigMask?: boolean }>() -const modeOptions = [ - { label: t('edit.script'), value: '脚本' }, - { label: t('edit.user'), value: '用户' }, +const maaEndConfigModeOptions: Array<{ + value: '脚本' | '用户' | '直控' + title: string + description: string + icon: 'file' | 'database' | 'setting' +}> = [ + { + value: '脚本', + title: '脚本', + description: '使用脚本级共享配置,所有用户共用。', + icon: 'file', + }, + { + value: '用户', + title: '用户', + description: '使用当前用户独立配置,与脚本配置隔离。', + icon: 'database', + }, + { + value: '直控', + title: '直控', + description: '直接使用 MaaEnd 原有配置,由 MaaEnd GUI 维护。', + icon: 'setting', + }, ] const quickConfigOptions = [ @@ -276,6 +291,12 @@ const quickConfigOptions = [ const emitSave = (key: string, value: any) => { emit('save', key, value) } + +const currentConfigModeLabel = computed(() => { + if (formData.value.Info.Mode === '直控') return '脚本直控' + if (formData.value.Info.Mode === '用户') return '用户独立' + return '脚本共享' +}) diff --git a/res/version.json b/res/version.json index 1467a4ae4..a89872263 100644 --- a/res/version.json +++ b/res/version.json @@ -3,6 +3,8 @@ "version_info": { "v5.5.0-beta.3": { "新增功能": [ + "MaaEnd专项 将抢委托送货独立为送货阶段,并支持单独配置接取价格和委托接收点", + "专项适配 提供可复制的 General 能力基线模板,覆盖后端任务、前端编辑页、注册清单与最小回归测试", "MFW 项目支持在脚本运行前或运行后自动更新,可在项目配置中选择时机;升级后已有脚本默认为「运行前更新」,不需要可在项目配置中改为「不更新」 by [@qiyinxi](https://github.com/qiyinxi)", "MAA专项 新增绿票商店开关:开启后每月单独启动一次 MAA 自动购买绿票商店,用户配置页可查看本月状态并手动重置 by [@qiyinxi](https://github.com/qiyinxi)", "调度队列 新增循环队列:队列里的每个任务可单独设定固定时间或间隔重复运行,在调度台以「循环运行」启动后会按各自的周期一直跑下去,并显示接下来要运行的任务与时间 by [@qiyinxi](https://github.com/qiyinxi)", diff --git a/templates/specialized/README.md b/templates/specialized/README.md new file mode 100644 index 000000000..1a6ef1c99 --- /dev/null +++ b/templates/specialized/README.md @@ -0,0 +1,78 @@ +# 专项适配模板 + +这是一套从 `General` 能力线整理出的专项适配骨架。它不是独立运行的脚本,也不依赖专用模板生成器;复制后按注册清单接入 AUTO-MAS 即可。接入新 schema 后,仍按项目流程生成 OpenAPI client。 + +## 占位符 + +- `Xxx`:Python/Vue 的 PascalCase 专项名,例如 `MaaDemo`。 +- `xxx`:路径、测试和路由使用的 kebab-case 或 snake_case 名,例如 `maa_demo`。 +- `专项显示名称`:用户界面和日志中的中文名称。 + +全局替换时请同时检查大小写和中文显示名。`Xxx` 不是最终的 `ScriptType`,最终类型名必须与注册表、路由、schema 和任务调度完全一致。 + +## 目录说明 + +```text +templates/specialized/ +├─ README.md +├─ backend/ +│ ├─ task/Xxx/ +│ │ ├─ __init__.py +│ │ ├─ AutoProxy.py +│ │ ├─ manager.py +│ │ └─ ScriptConfig.py +│ ├─ config.py.template +│ ├─ schema.py.template +│ └─ registration-checklist.md +├─ frontend/ +│ ├─ XxxScriptEdit.vue +│ ├─ XxxUserEdit.vue +│ └─ XxxUserEdit/ +│ ├─ BasicInfoSection.vue +│ └─ NotifyConfigSection.vue +└─ tests/ + └─ test_xxx_autoproxy.py +``` + +后端任务文件保留了 General 的启动、进程追踪、配置交换、游戏/模拟器启动、重试/超时、前后置脚本、日志判态、用户统计、历史记录、通知和 `final_task` / `on_crash` 生命周期。专项差异集中在 `TODO(specialized)` 处;不要把 TODO 留在可运行路径上。 + +## 五步使用流程 + +1. 复制 `backend/task/Xxx` 到 `app/task/新专项名`,并全局替换 `Xxx`、`xxx` 和显示名称。 +2. 复制 `backend/config.py.template`、`backend/schema.py.template` 的片段,填写真实专项字段和验证器。 +3. 复制两个前端编辑页及 `XxxUserEdit/`,保留基本信息、通知和通用配置会话,加入专项表单。 +4. 按 `backend/registration-checklist.md` 补齐 `ScriptType`、`BOOK`、API/core/task 调度、路由、Hub 和前端类型分支。 +5. 将 `tests/test_xxx_autoproxy.py` 移入 `tests/task/test_xxx_autoproxy.py`,替换夹具并运行最小专项测试。 + +前端通常放置为:`XxxScriptEdit.vue` → `frontend/src/views/EditView/Script/`,`XxxUserEdit.vue` → `frontend/src/views/EditView/User/`,`XxxUserEdit/` → `frontend/src/views/XxxUserEdit/`;页面中的相对 import 已按该目录关系书写。 + +## 设计边界 + +- `schema.py` 只描述 API 数据;文件、进程、日志和配置交换留在 task/core。 +- `config.py` 中的所有 `ConfigItem` 必须在 `super().__init__()` 前声明,并配有注释。 +- 用户配置来源默认沿用 General 的“用户独立 / 脚本直控”两态。若上游真实存在脚本共享或其他 owner,先在专项设计中确认,再同步 config、UI、AutoProxy 和 ScriptConfig;不要为了界面统一臆造第三种模式。 +- 运行前备份脚本直控配置,运行中按用户配置原子替换,成功、失败、取消、超时和异常均恢复原配置。需要把运行结果写回用户配置时,必须先更新用户副本,再恢复直控配置。 +- 日志监控使用 `LogMonitor(time_stamp_range, time_format, check_log)` 三参构造;每一轮创建 `LogRecord`,只写 `log_record.status`,不要给 `UserItem.result` 赋值。 +- 生成的 OpenAPI 文件不得手改。schema 接入后由开发者从 `frontend/` 运行 `yarn openapi`。 + +## 必填专项决策 + +复制前先写下四个答案: + +1. 脚本根目录、主程序和目标进程的真实哨兵文件是什么?自动发现和手动选择必须复用同一组哨兵。 +2. 自动任务的启动参数如何构造?没有稳定 CLI 时,改为写入上游约定的运行配置,不要猜参数。 +3. 哪些配置由上游脚本拥有,哪些由 MAS 用户副本拥有?配置会话、AutoProxy 和恢复逻辑必须一致。 +4. 哪些日志明确代表成功、失败、运行中和提前退出?把失败/回退路径写入测试。 + +## 验证 + +```powershell +# 后端:在替换占位符并完成注册后 +python -m pytest tests/task/test_xxx_autoproxy.py -q + +# 前端:接入 schema 后由开发者执行生成器,再检查类型和 lint +yarn openapi +yarn lint +``` + +模板本身只提供最小纯逻辑回归测试;未完成上游契约、注册和夹具替换前,不要把模板测试当作专项已适配的证明。 diff --git a/templates/specialized/backend/config.py.template b/templates/specialized/backend/config.py.template new file mode 100644 index 000000000..52737657d --- /dev/null +++ b/templates/specialized/backend/config.py.template @@ -0,0 +1,248 @@ +"""专项配置片段。 + +将本文件中的两个 ConfigBase 子类复制到 ``app/models/config.py``,并复用该文件 +已有的 ConfigItem、Validator、MultipleConfig、Webhook 和 UTC4 imports。模板只放 +真实运行时会被 AutoProxy/ScriptConfig 消费的字段;专项字段必须同时出现在 schema、 +前端表单和任务逻辑中。 +""" + + +class XxxUserConfig(ConfigBase): + """专项用户配置:从 General 用户配置开始,再加入专项字段。""" + + def __init__(self) -> None: + ## Info ------------------------------------------------------------ + ## 用户名称 + self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator()) + ## 是否启用 + self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) + ## 剩余天数,-1 表示无限 + self.Info_RemainedDay = ConfigItem( + "Info", "RemainedDay", -1, RangeValidator(-1, 9999) + ) + ## 是否使用用户独立脚本配置;关闭时直接使用脚本原配置 + self.Info_IfUseMasConfig = ConfigItem( + "Info", "IfUseMasConfig", True, BoolValidator() + ) + ## 是否在任务前执行自定义脚本 + self.Info_IfScriptBeforeTask = ConfigItem( + "Info", "IfScriptBeforeTask", False, BoolValidator() + ) + ## 任务前脚本路径 + self.Info_ScriptBeforeTask = ConfigItem( + "Info", "ScriptBeforeTask", "", FileValidator() + ) + ## 是否在任务后执行自定义脚本 + self.Info_IfScriptAfterTask = ConfigItem( + "Info", "IfScriptAfterTask", False, BoolValidator() + ) + ## 任务后脚本路径 + self.Info_ScriptAfterTask = ConfigItem( + "Info", "ScriptAfterTask", "", FileValidator() + ) + ## 用户备注 + self.Info_Notes = ConfigItem("Info", "Notes", "无") + ## 用户列表展示标签 + self.Info_Tag = ConfigItem( + "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags) + ) + + ## Task ------------------------------------------------------------ + # TODO(specialized): 增加专项任务字段,并在 AutoProxy/前端真正消费。 + # self.Task_Example = ConfigItem( + # "Task", "Example", False, BoolValidator() + # ) + + ## Data ------------------------------------------------------------ + ## 上次代理日期 + self.Data_LastProxyDate = ConfigItem( + "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") + ) + ## 当日代理次数 + self.Data_ProxyTimes = ConfigItem( + "Data", "ProxyTimes", 0, RangeValidator(0, 9999) + ) + + ## Notify ---------------------------------------------------------- + ## 是否启用用户通知 + 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", "") + ## 是否发送 Server 酱 + self.Notify_IfServerChan = ConfigItem( + "Notify", "IfServerChan", False, BoolValidator() + ) + ## Server 酱密钥 + self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "") + ## 自定义 Webhook + self.Notify_CustomWebhooks = MultipleConfig([Webhook]) + + super().__init__() + + def getTags(self) -> str: + """生成用户列表需要的简短状态标签。""" + tags = [] + if ( + datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date() + == datetime.now(tz=UTC4).date() + ): + tags.append( + {"text": f"任务:已代理{self.get('Data', 'ProxyTimes')}次", "color": "green"} + ) + else: + tags.append({"text": "任务:未代理", "color": "orange"}) + + remained_day = self.get("Info", "RemainedDay") + if remained_day == -1: + color = "gold" + elif remained_day == 0: + color = "red" + elif remained_day <= 3: + color = "orange" + elif remained_day <= 7: + color = "yellow" + elif remained_day <= 30: + color = "blue" + else: + color = "green" + tags.append( + { + "text": ( + f"剩余天数:{remained_day}天" + if remained_day >= 0 + else "剩余天数:无期限" + ), + "color": color, + } + ) + + notes = self.get("Info", "Notes") + tags.append( + {"text": f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}...", "color": "pink"} + ) + return json.dumps(tags, ensure_ascii=False) + + +class XxxConfig(ConfigBase): + """专项脚本配置:保留 General 运行基线。""" + + related_config: dict[str, MultipleConfig] = {} + + def __init__(self) -> None: + ## Info ------------------------------------------------------------ + ## 脚本显示名称 + self.Info_Name = ConfigItem("Info", "Name", "新专项脚本") + ## 脚本根目录 + self.Info_RootPath = ConfigItem("Info", "RootPath", "", FileValidator()) + + ## Script ---------------------------------------------------------- + ## 脚本主程序 + self.Script_ScriptPath = ConfigItem( + "Script", "ScriptPath", "", FileValidator() + ) + ## 通用启动参数;专项参数在 AutoProxy 中构造 + self.Script_Arguments = ConfigItem( + "Script", "Arguments", "", AdvancedArgumentValidator() + ) + ## 是否追踪目标进程 + self.Script_IfTrackProcess = ConfigItem( + "Script", "IfTrackProcess", False, BoolValidator() + ) + ## 目标进程名称 + self.Script_TrackProcessName = ConfigItem("Script", "TrackProcessName", "") + ## 目标进程可执行文件 + self.Script_TrackProcessExe = ConfigItem("Script", "TrackProcessExe", "") + ## 目标进程命令行 + self.Script_TrackProcessCmdline = ConfigItem( + "Script", "TrackProcessCmdline", "", ArgumentValidator() + ) + ## 脚本配置文件或目录 + self.Script_ConfigPath = ConfigItem( + "Script", "ConfigPath", "", 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", "", 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", "") + + ## Game ------------------------------------------------------------ + ## 是否由 MAS 启动游戏或模拟器 + self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator()) + ## Emulator / Client / URL + self.Game_Type = ConfigItem( + "Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client", "URL"]) + ) + ## PC 游戏或启动器路径 + self.Game_Path = ConfigItem("Game", "Path", "", FileValidator()) + ## URL 协议 + self.Game_URL = ConfigItem("Game", "URL", "") + ## URL/客户端进程名称 + self.Game_ProcessName = ConfigItem("Game", "ProcessName", "") + ## 游戏启动参数 + self.Game_Arguments = ConfigItem("Game", "Arguments", "", ArgumentValidator()) + ## 启动等待时间(秒) + self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999)) + ## 是否强制关闭游戏 + self.Game_IfForceClose = ConfigItem( + "Game", "IfForceClose", False, BoolValidator() + ) + ## 模拟器实例关系 + self.Game_EmulatorId = ConfigItem( + "Game", "EmulatorId", "-", MultipleUIDValidator("-", self.related_config, "EmulatorConfig") + ) + self.Game_EmulatorIndex = ConfigItem("Game", "EmulatorIndex", "-") + + ## Task ------------------------------------------------------------ + # TODO(specialized): 增加脚本实际需要的专项级配置。 + + ## Run ------------------------------------------------------------- + ## 单用户每日代理次数上限,0 表示不限制 + 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([XxxUserConfig]) + + super().__init__() diff --git a/templates/specialized/backend/registration-checklist.md b/templates/specialized/backend/registration-checklist.md new file mode 100644 index 000000000..1651f0463 --- /dev/null +++ b/templates/specialized/backend/registration-checklist.md @@ -0,0 +1,52 @@ +# 专项注册清单 + +复制模板后逐项勾选。没有真实消费者的字段、按钮或模式不要注册。 + +## 1. 配置与 schema + +- [ ] 将 `XxxConfig`、`XxxUserConfig` 复制进 `app/models/config.py`,所有 `ConfigItem` 位于 `super().__init__()` 前。 +- [ ] 将 `Xxx*` schema 复制进 `app/models/schema.py`,补齐真实专项字段、`Literal` 和描述。 +- [ ] `Config.ScriptConfig` / `GlobalConfig` / 相关 `MultipleConfig` 允许新类型。 +- [ ] `app/models/config.py` 的类映射、序列化和默认配置分支包含新类型。 +- [ ] `app/utils/constants.py` 的 `TYPE_BOOK["XxxConfig"]` 有用户可见文案。 + +## 2. API 与核心调度 + +- [ ] `app/api/scripts.py` 的 `SCRIPT_BOOK` 增加 `XxxConfig`。 +- [ ] `app/api/scripts.py` 的 `USER_BOOK` 增加 `XxxConfig: XxxUserConfig`。 +- [ ] 任何专项 API 只做请求校验/响应整形;文件交换、任务循环和日志判态留在 core/task。 +- [ ] `app/core/config.py` 的加载、创建、删除、用户增删和类型 union 分支包含新类型。 +- [ ] `app/core/task_manager.py` 导入 `XxxManager`,在脚本类型 dispatch 中注册。 +- [ ] 若接入计划表/任务队列,单独补 `PLAN_BOOK`、consumer、队列类型和对应前端表面;不要复制无关专项能力。 + +## 3. 任务模块 + +- [ ] 将 `task/Xxx/` 复制到 `app/task/Xxx/`,并全局替换类名、导入和 logger。 +- [ ] `manager.py` 的 `METHOD_BOOK` 至少包含 `AutoProxy` 与 `ScriptConfig`,并核对任务模式是否真实支持。 +- [ ] `AutoProxy.py` 的 `check()` 使用用户可操作的失败提示。 +- [ ] `AutoProxy.py` 的 `LogMonitor` 使用时间范围、时间格式、回调三参构造。 +- [ ] 每轮从 `log_record[start_time] = LogRecord()` 开始,只写 `log_record.status`。 +- [ ] `final_task` 与 `on_crash` 都停止监控、停止/清理进程、恢复配置、释放锁,并在需要时写历史记录。 +- [ ] 进程追踪至少有一个非空 `ProcessInfo` 字段;不要把空追踪条件交给运行时猜测。 +- [ ] 配置目录/文件复制使用临时路径后替换;逐步清理失败分别记录日志。 +- [ ] 多用户任务中单个用户检查失败只标记当前用户并继续后续用户。 + +## 4. 前端 Hub、路由和类型 + +- [ ] `frontend/src/types/script.ts` 增加 `ScriptType`、脚本/用户结构和默认值。 +- [ ] `frontend/src/composables/useScriptApi.ts` 增加脚本类型映射、默认配置和 `XxxUserConfig -> users[]` 分支。 +- [ ] `frontend/src/router/index.ts` 增加脚本编辑、用户新增、用户编辑路由;路径使用 lowercase kebab-case。 +- [ ] `frontend/src/views/Scripts.vue` 的编辑、添加用户、编辑用户、创建/复制脚本分支全部补齐。 +- [ ] `frontend/src/components/ScriptTable.vue` 增加图标、类型文案和专项操作(若确有专项动作)。 +- [ ] `frontend/src/views/EditView/Script/XxxScriptEdit.vue` 与 `EditView/User/XxxUserEdit.vue` 接入真实 API。 +- [ ] 新增用户流程先 `addUser`,再 `router.replace` 到带 `userId` 的编辑路由。 +- [ ] 若使用 ScriptConfig 遮罩,启动、完成、错误、取消、超时、卸载都停止任务并清理 WebSocket 订阅。 +- [ ] 自动发现与手动选择复用同一组哨兵文件;保存失败恢复旧值并显示原因。 + +## 5. 生成代码与验证 + +- [ ] 后端 schema 接入并重启后,确认 `openapi.json` 文本包含 `XxxConfig` / `XxxUserConfig`。 +- [ ] 在 `frontend/` 运行 `yarn openapi`;禁止手改 `frontend/src/api/**`。 +- [ ] 将测试移动到 `tests/task/test_xxx_autoproxy.py`,替换占位夹具,先运行该最小文件。 +- [ ] 前端改动至少运行 `yarn lint`;路由/类型/构建改动再运行相关 build/type 命令。 +- [ ] 版本记录在 `res/version.json` 下一个未发布版本中,说明新增专项模板或用户可见能力。 diff --git a/templates/specialized/backend/schema.py.template b/templates/specialized/backend/schema.py.template new file mode 100644 index 000000000..4325d00fa --- /dev/null +++ b/templates/specialized/backend/schema.py.template @@ -0,0 +1,123 @@ +"""专项 schema 片段。 + +将这些模型复制到 ``app/models/schema.py``,并在文件底部所有相关 union、index +和 API response model 中加入 XxxConfig/XxxUserConfig。schema 只描述数据契约, +不要在这里加入路径检查、进程启动或日志判态。 +""" + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class XxxUserConfig_Info(BaseModel): + Name: Optional[str] = Field(default=None, description="用户名") + Status: Optional[bool] = Field(default=None, description="是否启用") + RemainedDay: Optional[int] = Field(default=None, description="剩余天数,-1 表示无限") + IfUseMasConfig: Optional[bool] = Field( + default=None, description="是否使用用户独立脚本配置" + ) + IfScriptBeforeTask: Optional[bool] = Field( + default=None, description="是否在任务前执行脚本" + ) + ScriptBeforeTask: Optional[str] = Field(default=None, description="任务前脚本路径") + IfScriptAfterTask: Optional[bool] = Field( + default=None, description="是否在任务后执行脚本" + ) + ScriptAfterTask: Optional[str] = Field(default=None, description="任务后脚本路径") + Notes: Optional[str] = Field(default=None, description="备注") + Tag: Optional[str] = Field(default=None, description="用户标签 JSON 字符串") + + # TODO(specialized): 加入专项用户字段,并同步前端 BasicInfoSection。 + + +class XxxUserConfig_Data(BaseModel): + LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期") + ProxyTimes: Optional[int] = Field(default=None, description="代理次数") + + +class XxxUserConfig_Notify(BaseModel): + Enabled: Optional[bool] = Field(default=None, description="是否启用通知") + IfSendStatistic: Optional[bool] = Field( + default=None, description="是否发送统计信息" + ) + IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件") + ToAddress: Optional[str] = Field(default=None, description="邮件收件地址") + IfServerChan: Optional[bool] = Field( + default=None, description="是否使用 Server 酱" + ) + ServerChanKey: Optional[str] = Field(default=None, description="Server 酱密钥") + + +class XxxUserConfig(BaseModel): + Info: Optional[XxxUserConfig_Info] = Field(default=None, description="用户信息") + Data: Optional[XxxUserConfig_Data] = Field(default=None, description="用户数据") + Notify: Optional[XxxUserConfig_Notify] = Field( + default=None, description="用户通知" + ) + + +class XxxConfig_Info(BaseModel): + Name: Optional[str] = Field(default=None, description="脚本名称") + RootPath: Optional[str] = Field(default=None, description="脚本根目录") + + +class XxxConfig_Script(BaseModel): + ScriptPath: Optional[str] = Field(default=None, description="脚本主程序路径") + Arguments: Optional[str] = Field(default=None, description="通用启动参数") + IfTrackProcess: Optional[bool] = Field( + default=None, description="是否追踪目标进程" + ) + TrackProcessName: Optional[str] = Field(default=None, description="目标进程名称") + TrackProcessExe: Optional[str] = Field(default=None, description="目标进程路径") + TrackProcessCmdline: Optional[str] = Field( + default=None, description="目标进程命令行" + ) + ConfigPath: Optional[str] = Field(default=None, description="配置文件或目录") + ConfigPathMode: Optional[Literal["File", "Folder"]] = Field( + default=None, description="配置路径类型" + ) + UpdateConfigMode: Optional[Literal["Never", "Success", "Failure", "Always"]] = ( + Field(default=None, description="用户配置回写时机") + ) + LogPath: Optional[str] = Field(default=None, description="日志文件路径") + LogPathFormat: Optional[str] = Field(default=None, description="日志文件名格式") + LogTimeStart: Optional[int] = Field(default=None, description="日志时间戳起点") + LogTimeEnd: Optional[int] = Field(default=None, description="日志时间戳终点") + LogTimeFormat: Optional[str] = Field(default=None, description="日志时间戳格式") + SuccessLog: Optional[str] = Field(default=None, description="成功日志关键词") + ErrorLog: Optional[str] = Field(default=None, description="失败日志关键词") + + +class XxxConfig_Game(BaseModel): + Enabled: Optional[bool] = Field(default=None, description="是否由 MAS 启动游戏") + Type: Optional[Literal["Emulator", "Client", "URL"]] = Field( + default=None, description="游戏启动类型" + ) + Path: Optional[str] = Field(default=None, description="游戏或启动器路径") + URL: Optional[str] = Field(default=None, description="游戏 URL 协议") + ProcessName: Optional[str] = Field(default=None, description="游戏进程名称") + Arguments: Optional[str] = Field(default=None, description="游戏启动参数") + WaitTime: Optional[int] = Field(default=None, description="游戏启动等待秒数") + IfForceClose: Optional[bool] = Field(default=None, description="是否强制关闭游戏") + EmulatorId: Optional[str] = Field(default=None, description="模拟器 ID") + EmulatorIndex: Optional[str] = Field(default=None, description="模拟器实例索引") + + +class XxxConfig_Run(BaseModel): + ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数上限") + RunTimesLimit: Optional[int] = Field(default=None, description="单用户重试次数") + RunTimeLimit: Optional[int] = Field(default=None, description="日志无变化超时分钟") + + +class XxxConfig_Task(BaseModel): + # TODO(specialized): 加入真实专项任务字段,并确保每个字段都有运行时消费者。 + pass + + +class XxxConfig(BaseModel): + Info: Optional[XxxConfig_Info] = Field(default=None, description="脚本基础信息") + Script: Optional[XxxConfig_Script] = Field(default=None, description="脚本配置") + Game: Optional[XxxConfig_Game] = Field(default=None, description="游戏配置") + Task: Optional[XxxConfig_Task] = Field(default=None, description="专项任务配置") + Run: Optional[XxxConfig_Run] = Field(default=None, description="运行配置") diff --git a/templates/specialized/backend/task/Xxx/AutoProxy.py b/templates/specialized/backend/task/Xxx/AutoProxy.py new file mode 100644 index 000000000..52fda0870 --- /dev/null +++ b/templates/specialized/backend/task/Xxx/AutoProxy.py @@ -0,0 +1,715 @@ +"""Xxx 专项自动代理任务。 + +这是从 ``app/task/general/AutoProxy.py`` 收敛出的可复制基线。复制后先完成 +``TODO(specialized)``,再接入真实 ScriptType;不要把这里的默认日志关键词或 +启动参数直接当成上游脚本契约。 +""" + +from __future__ import annotations + +import asyncio +import re +import shlex +import shutil +import uuid +from contextlib import suppress +from datetime import datetime, timedelta +from pathlib import Path + +from app.core import Config +from app.models.ConfigBase import MultipleConfig +from app.models.config import XxxConfig, XxxUserConfig +from app.models.emulator import DeviceBase +from app.models.task import LogRecord, ScriptItem, TaskExecuteBase +from app.services import Notify, System +from app.utils import ( + LogMonitor, + ProcessInfo, + ProcessManager, + get_logger, + is_process_running, + strptime, +) +from app.utils.constants import UTC4 +from app.task.general.tools import execute_script_task, push_notification + + +logger = get_logger("专项显示名称自动代理") + +_PREFIX_SENTINEL = "******" +_STRPTIME_DIRECTIVES: dict[str, str] = { + "%Y": r"\d{4}", + "%y": r"\d{2}", + "%m": r"\d{1,2}", + "%d": r"\d{1,2}", + "%H": r"\d{1,2}", + "%I": r"\d{1,2}", + "%M": r"\d{1,2}", + "%S": r"\d{1,2}", + "%f": r"\d+", + "%j": r"\d{1,3}", + "%U": r"\d{1,2}", + "%W": r"\d{1,2}", + "%w": r"\d", + "%A": r"\w+", + "%a": r"\w+", + "%B": r"\w+", + "%b": r"\w+", + "%p": r"[APap][Mm]", + "%%": r"%", +} + + +def _format_to_prefix_regex(fmt: str) -> re.Pattern[str]: + """将 strptime 格式转换成日志文件名前缀正则。""" + + parts: list[str] = [] + index = 0 + while index < len(fmt): + if fmt[index] == "%" and index + 1 < len(fmt): + directive = fmt[index : index + 2] + if directive in _STRPTIME_DIRECTIVES: + parts.append(_STRPTIME_DIRECTIVES[directive]) + index += 2 + continue + parts.append(re.escape(fmt[index])) + index += 1 + return re.compile("^" + "".join(parts)) + + +def _split_script_arguments(raw: str, script_path: Path) -> tuple[list[Path], list[list[str]]]: + """解析 General 的 ``path%args|path%args`` 参数格式。""" + + executable_paths: list[Path] = [] + arguments: list[list[str]] = [] + for item in (part.strip() for part in str(raw).split("|") if part.strip()): + parts = [part.strip() for part in item.split("%", 1) if part.strip()] + executable_paths.append( + (script_path / parts[0] if len(parts) > 1 else script_path).resolve() + ) + arguments.append(shlex.split(parts[-1], posix=False)) + return executable_paths, arguments + + +class AutoProxyTask(TaskExecuteBase): + """专项自动代理:一名用户串行执行多次,直到成功或达到重试上限。""" + + def __init__( + self, + script_info: ScriptItem, + script_config: XxxConfig, + user_config: MultipleConfig[XxxUserConfig], + game_manager: ProcessManager | DeviceBase | None, + ) -> None: + super().__init__() + if script_info.task_info is None: + raise RuntimeError("ScriptItem 未绑定到 TaskItem") + + self.task_info = script_info.task_info + self.script_info = script_info + self.script_config = script_config + self.user_config = user_config + self.game_manager = game_manager + self.cur_user_item = self.script_info.user_list[self.script_info.current_index] + self.cur_user_uid = uuid.UUID(self.cur_user_item.user_id) + self.cur_user_config = self.user_config[self.cur_user_uid] + self.use_mas_config = bool( + self.cur_user_config.get("Info", "IfUseMasConfig") + ) + + # 跨回调、final_task 和 on_crash 使用的状态全部显式初始化。 + self.check_result = "-" + self.run_book = False + self.wait_event: asyncio.Event | None = None + self.general_process_manager: ProcessManager | None = None + self.general_log_monitor: LogMonitor | None = None + self.script_exe_path: Path | None = None + self.script_path: Path | None = None + self.script_arguments: list[str] = [] + self.script_set_arguments: list[str] = [] + self.script_target_process_info: ProcessInfo | None = None + self.script_config_path: Path | None = None + self.script_log_path: Path | None = None + self.log_format = "" + self.log_use_prefix = False + self.game_path: Path | None = None + self.game_url = "" + self.game_process_name = "" + self.log_time_range = (0, 1) + self.success_log: list[str] = [] + self.error_log: list[str] = [] + self.curdate = "" + self.user_start_time = datetime.now() + self.log_start_time = datetime.now() + self.cur_user_log: LogRecord | None = None + + # 直控配置快照:任务结束、取消、超时和异常都从这里恢复。 + self.temp_path: Path | None = None + self.external_config_exists = False + self.external_config_snapshot_ready = False + + async def check(self) -> str: + """检查用户状态和专项运行前置条件。""" + + proxy_limit = self.script_config.get("Run", "ProxyTimesLimit") + if proxy_limit != 0 and self.cur_user_config.get("Data", "ProxyTimes") >= proxy_limit: + self.cur_user_item.status = "跳过" + return "今日代理次数已达上限, 跳过该用户" + + if self.use_mas_config and not self._user_config_source_path().exists(): + self.cur_user_item.status = "异常" + return "请先在用户配置页完成「专项显示名称配置」步骤" + + # TODO(specialized): 修改脚本路径校验 + script_path = Path(self.script_config.get("Script", "ScriptPath")) + if not script_path.exists(): + self.cur_user_item.status = "异常" + return "请设置脚本路径" + if not self.script_config.get("Info", "RootPath"): + self.cur_user_item.status = "异常" + return "请设置脚本根目录" + return "Pass" + + def _user_config_path(self) -> Path: + return ( + Path.cwd() + / "data" + / self.script_info.script_id + / str(self.cur_user_uid) + / "ConfigFile" + ) + + def _user_config_source_path(self) -> Path: + """返回当前用户副本中与脚本配置模式对应的源路径。""" + + user_path = self._user_config_path() + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + return user_path + config_path = Path(self.script_config.get("Script", "ConfigPath")) + return user_path / config_path.name + + def _remove_path(self, path: Path) -> None: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + def _copy_path_atomic(self, source: Path, destination: Path, mode: str) -> None: + """以临时路径替换配置,避免中断留下半份配置。""" + + if mode == "Folder": + temporary = destination.with_name(destination.name + ".tmp") + shutil.rmtree(temporary, ignore_errors=True) + if source.exists(): + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, temporary, dirs_exist_ok=True) + self._remove_path(destination) + if temporary.exists(): + temporary.rename(destination) + return + + temporary = destination.with_name(destination.name + ".tmp") + if temporary.exists(): + temporary.unlink() + if source.exists(): + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, temporary) + temporary.replace(destination) + else: + self._remove_path(destination) + + def _snapshot_external_config(self) -> None: + """备份任务开始前的脚本直控配置。""" + + if self.script_config_path is None or self.temp_path is None: + return + shutil.rmtree(self.temp_path, ignore_errors=True) + self.external_config_exists = self.script_config_path.exists() + self.temp_path.mkdir(parents=True, exist_ok=True) + if self.external_config_exists: + mode = self.script_config.get("Script", "ConfigPathMode") + if mode == "Folder": + shutil.copytree(self.script_config_path, self.temp_path, dirs_exist_ok=True) + else: + shutil.copy2(self.script_config_path, self.temp_path / "config.temp") + self.external_config_snapshot_ready = True + + def _restore_external_config(self) -> None: + """恢复任务开始前的脚本直控配置。""" + + if ( + not self.external_config_snapshot_ready + or self.script_config_path is None + or self.temp_path is None + ): + return + self._remove_path(self.script_config_path) + if not self.external_config_exists: + return + mode = self.script_config.get("Script", "ConfigPathMode") + if mode == "Folder": + self._copy_path_atomic(self.temp_path, self.script_config_path, "Folder") + else: + self._copy_path_atomic( + self.temp_path / "config.temp", self.script_config_path, "File" + ) + + def _cleanup_external_config_snapshot(self) -> None: + if self.temp_path is not None: + shutil.rmtree(self.temp_path, ignore_errors=True) + self.external_config_snapshot_ready = False + + def _build_specialized_arguments(self) -> list[str]: + # TODO(specialized): 构造专项启动参数 + return [] + + async def prepare(self) -> None: + """加载运行参数、进程追踪信息、日志判态和配置快照。""" + + self.wait_event = asyncio.Event() + self.general_process_manager = ProcessManager() + self.user_start_time = datetime.now() + self.script_path = Path(self.script_config.get("Script", "ScriptPath")) + argument_paths, argument_lists = _split_script_arguments( + self.script_config.get("Script", "Arguments"), self.script_path + ) + self.script_exe_path = argument_paths[0] if argument_paths else self.script_path + self.script_arguments = ( + argument_lists[0] if argument_lists else [] + ) + self._build_specialized_arguments() + self.script_set_arguments = argument_lists[1] if len(argument_lists) > 1 else [] + + if self.script_config.get("Script", "IfTrackProcess"): + self.script_target_process_info = ProcessInfo( + name=self.script_config.get("Script", "TrackProcessName") or None, + exe=self.script_config.get("Script", "TrackProcessExe") or None, + cmdline=shlex.split( + self.script_config.get("Script", "TrackProcessCmdline"), + posix=False, + ) + or None, + ) + + self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) + self.temp_path = ( + Path.cwd() + / "data" + / self.script_info.script_id + / "Temp" + / str(self.cur_user_uid) + ) + self.script_log_path = Path(self.script_config.get("Script", "LogPath")) + self.log_format = self.script_config.get("Script", "LogPathFormat") or "" + self.log_use_prefix = self.log_format.endswith(_PREFIX_SENTINEL) + if self.log_use_prefix: + prefix_re = _format_to_prefix_regex( + self.log_format[: -len(_PREFIX_SENTINEL)] + ) + if not prefix_re.match(self.script_log_path.stem): + logger.warning( + f"LogPathFormat 与 LogPath 不匹配: {self.log_format} vs {self.script_log_path}" + ) + elif self.log_format: + with suppress(ValueError): + datetime.strptime(self.script_log_path.stem, self.log_format) + self.log_format += self.script_log_path.suffix + else: + self.log_format = self.script_log_path.name + + self.game_path = Path(self.script_config.get("Game", "Path")) + self.game_url = self.script_config.get("Game", "URL") + self.game_process_name = self.script_config.get("Game", "ProcessName") + self.log_time_range = ( + self.script_config.get("Script", "LogTimeStart") - 1, + self.script_config.get("Script", "LogTimeEnd"), + ) + self.success_log = [ + item.strip() + for item in self.script_config.get("Script", "SuccessLog").split("|") + if item.strip() + ] + self.error_log = [ + item.strip() + for item in self.script_config.get("Script", "ErrorLog").split("|") + if item.strip() + ] + self.general_log_monitor = LogMonitor( + self.log_time_range, + self.script_config.get("Script", "LogTimeFormat"), + self.check_log, + ) + self._snapshot_external_config() + + async def _start_game(self) -> None: + if self.game_manager is None: + return + try: + if isinstance(self.game_manager, ProcessManager): + if self.script_config.get("Game", "Type") == "URL": + if self.game_process_name and is_process_running(self.game_process_name): + logger.info(f"游戏进程已运行,跳过重复启动: {self.game_process_name}") + else: + await self.game_manager.open_protocol( + self.game_url, + ProcessInfo(name=self.game_process_name or None), + ) + else: + game_process_name = self.game_path.name if self.game_path else "" + if game_process_name and is_process_running(game_process_name): + logger.info(f"游戏进程已运行,跳过重复启动: {game_process_name}") + else: + await self.game_manager.open_process( + self.game_path, + *str(self.script_config.get("Game", "Arguments")).split(" "), + ) + await asyncio.sleep(self.script_config.get("Game", "WaitTime")) + elif isinstance(self.game_manager, DeviceBase): + await self.game_manager.open( + self.script_config.get("Game", "EmulatorIndex") + ) + except Exception as error: + await self.handle_pre_script_error("游戏/模拟器启动失败", error) + raise + + async def main_task(self) -> None: + """执行前置脚本、游戏、专项程序、日志监控和后置脚本。""" + + self.curdate = datetime.now(tz=UTC4).strftime("%Y-%m-%d") + if self.cur_user_config.get("Data", "LastProxyDate") != self.curdate: + await self.cur_user_config.set("Data", "LastProxyDate", self.curdate) + await self.cur_user_config.set("Data", "ProxyTimes", 0) + + self.check_result = await self.check() + if self.check_result != "Pass": + if self.cur_user_item.status == "异常": + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": self.check_result}, + ) + return + + await self.prepare() + self.cur_user_item.status = "运行" + run_limit = self.script_config.get("Run", "RunTimesLimit") + + for attempt in range(run_limit): + if self.run_book: + break + self.log_start_time = datetime.now() + self.cur_user_item.log_record[self.log_start_time] = self.cur_user_log = ( + LogRecord() + ) + logger.info(f"用户 {self.cur_user_item.name} - 尝试次数: {attempt + 1}/{run_limit}") + + if self.cur_user_config.get("Info", "IfScriptBeforeTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptBeforeTask")), + "脚本前任务", + ) + + try: + await self._start_game() + await self.set_script_config() + if ( + self.general_process_manager is None + or self.script_exe_path is None + or self.wait_event is None + or self.general_log_monitor is None + ): + raise RuntimeError("专项任务未完成初始化") + self.wait_event.clear() + process_started_at = datetime.now() + await self.general_process_manager.open_process( + self.script_exe_path, + *self.script_arguments, + target_process=self.script_target_process_info, + ) + self.script_info.log = "正在等待脚本日志文件生成" + log_path = await self._wait_for_log_file(process_started_at) + if log_path is None: + await self.handle_pre_script_error("未找到日志文件") + continue + self.script_log_path = log_path + await self.general_log_monitor.start_monitor_file( + self.script_log_path, self.log_start_time + ) + await self.wait_event.wait() + await self.general_log_monitor.stop() + except Exception as error: + await self.handle_pre_script_error("专项任务启动失败", error) + continue + + if self.cur_user_log.status == "Success!": + self.run_book = True + self.script_info.log = "检测到专项任务完成,正在等待相关程序结束" + await self.kill_managed_process() + await asyncio.sleep(1) + if self.script_config.get("Script", "UpdateConfigMode") in ( + "Success", + "Always", + ): + await self.update_config() + else: + logger.warning( + f"用户 {self.cur_user_uid} - 代理任务异常: {self.cur_user_log.status}" + ) + self.script_info.log = f"{self.cur_user_log.status}\n正在中止相关程序" + await self.kill_managed_process() + await Notify.push_plyer( + "用户自动代理出现异常!", + f"用户 {self.cur_user_item.name} 的自动代理出现一次异常", + f"{self.cur_user_item.name}的自动代理出现异常", + 3, + ) + if self.script_config.get("Script", "UpdateConfigMode") in ( + "Failure", + "Always", + ): + await self.update_config() + + if self.cur_user_config.get("Info", "IfScriptAfterTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) + + async def _wait_for_log_file(self, started_at: datetime) -> Path | None: + """等待固定日志或按日期/序号生成的日志文件。""" + + if self.script_log_path is None: + return None + target_suffix: int | None = None + prefix_re = ( + _format_to_prefix_regex(self.log_format[: -len(_PREFIX_SENTINEL)]) + if self.log_use_prefix + else None + ) + for _ in range(60): + if self.script_log_path.exists() and not self.log_use_prefix: + return self.script_log_path + if prefix_re is not None and self.script_log_path.parent.exists(): + current_suffix = 0 + current_file: Path | None = None + for candidate in self.script_log_path.parent.iterdir(): + if not candidate.is_file(): + continue + match = prefix_re.match(candidate.name) + if not match: + continue + with suppress(ValueError): + file_time = strptime( + match.group(0), self.log_format[: -len(_PREFIX_SENTINEL)], started_at + ) + if file_time.date() != started_at.date(): + continue + suffix_match = re.search( + r"(\d+)\s*$", candidate.name[match.end() :].rsplit(".", 1)[0] + ) + suffix = int(suffix_match.group(1)) if suffix_match else 0 + if suffix > current_suffix: + current_suffix = suffix + current_file = candidate + if target_suffix is None: + target_suffix = current_suffix + 1 + if current_file is not None and current_suffix >= target_suffix: + return current_file + await asyncio.sleep(1) + return None + + async def handle_pre_script_error( + self, error_message: str, error: Exception | None = None + ) -> None: + message = error_message if error is None else f"{error_message}: {error}" + logger.warning(f"用户 {self.cur_user_uid} - {message}") + self.script_info.log = message + if self.cur_user_log is not None: + self.cur_user_log.content = [f"{message}, 无日志记录"] + self.cur_user_log.status = error_message + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": message}, + ) + await self.kill_managed_process() + + async def set_script_config(self) -> None: + """将用户副本导入脚本配置路径。""" + + if self.script_config_path is None: + return + await System.kill_process(self.script_exe_path) + if not self.use_mas_config: + logger.info("脚本直控配置:跳过写入用户配置") + return + # TODO(specialized): 写入专项配置 + self._copy_path_atomic( + self._user_config_source_path(), + self.script_config_path, + self.script_config.get("Script", "ConfigPathMode"), + ) + + async def update_config(self) -> None: + """按脚本设置把运行后的配置写回用户副本。""" + + if not self.use_mas_config or self.script_config_path is None: + return + self._copy_path_atomic( + self.script_config_path, + self._user_config_source_path(), + self.script_config.get("Script", "ConfigPathMode"), + ) + logger.success("专项脚本配置已更新") + + async def kill_managed_process(self) -> None: + """分别清理专项程序和游戏/模拟器,单步失败不阻塞后续清理。""" + + if self.general_process_manager is not None: + try: + await self.general_process_manager.kill() + except Exception as error: + logger.warning(f"中止专项进程管理器失败: {error}") + if self.script_exe_path is not None: + try: + await System.kill_process(self.script_exe_path) + except Exception as error: + logger.warning(f"中止专项主进程失败: {error}") + if self.game_manager is None: + return + try: + if isinstance(self.game_manager, ProcessManager): + await self.game_manager.kill() + if ( + self.script_config.get("Game", "Type") == "Client" + and self.script_config.get("Game", "IfForceClose") + and self.game_path is not None + ): + await System.kill_process(self.game_path) + elif isinstance(self.game_manager, DeviceBase): + await self.game_manager.close( + self.script_config.get("Game", "EmulatorIndex") + ) + except Exception as error: + logger.warning(f"关闭游戏/模拟器失败: {error}") + + async def check_log(self, log_content: list[str], latest_time: datetime) -> None: + """根据日志文本、时间戳和进程状态更新本轮结果。""" + + if self.cur_user_log is None or self.wait_event is None: + return + log = "".join(log_content) + self.cur_user_log.content = log_content + self.script_info.log = log + + # TODO(specialized): 定义成功与失败条件 + if any(marker in log for marker in self.success_log): + self.cur_user_log.status = "Success!" + elif datetime.now() - latest_time > timedelta( + minutes=self.script_config.get("Run", "RunTimeLimit") + ): + self.cur_user_log.status = "脚本进程超时" + elif any(marker in log for marker in self.error_log): + self.cur_user_log.status = "异常日志" + elif self.general_process_manager and await self.general_process_manager.is_running(): + self.cur_user_log.status = "专项脚本正常运行中" + elif self.success_log: + self.cur_user_log.status = "脚本在完成任务前退出" + else: + self.cur_user_log.status = "Success!" + + if self.cur_user_log.status != "专项脚本正常运行中": + self.wait_event.set() + + async def _stop_log_monitor(self) -> None: + if self.general_log_monitor is not None: + with suppress(Exception): + await self.general_log_monitor.stop() + + async def _save_history(self) -> None: + user_logs: list[Path] = [] + for start_time, log_item in self.cur_user_item.log_record.items(): + if log_item.status == "专项脚本正常运行中": + log_item.status = "任务被用户手动中止" + if not log_item.content: + log_item.content = ["未捕获到任何日志内容"] + log_item.status = "未捕获到日志" + log_time = start_time.replace( + tzinfo=datetime.now().astimezone().tzinfo + ).astimezone(UTC4) + history_path = Config.build_history_log_path( + script_name=self.script_info.name, + user_name=self.cur_user_item.name, + log_time=log_time, + ) + await Config.save_general_log(history_path, log_item.content, log_item.status) + user_logs.append(history_path.with_suffix(".json")) + + statistics = await Config.merge_statistic_info(user_logs) + statistics["user_info"] = self.cur_user_item.name + statistics["start_time"] = self.user_start_time.strftime("%Y-%m-%d %H:%M:%S") + statistics["end_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + statistics["user_result"] = ( + "代理任务全部完成" if self.run_book else self.cur_user_item.result + ) + try: + await push_notification( + "统计信息", + f"{datetime.now().strftime('%m-%d')} |{'√' if self.run_book else 'X'}| " + f"{self.cur_user_item.name} 的自动代理统计报告", + statistics, + self.cur_user_config, + ) + except Exception as error: + logger.warning(f"推送通知时出现异常: {error}") + + async def final_task(self) -> None: + """停止运行、写历史记录、恢复配置并更新用户状态。""" + + await self._stop_log_monitor() + await self.kill_managed_process() + if self.check_result == "Pass": + await self._save_history() + # 回写用户副本后恢复原配置;直控配置始终不被任务结果污染。 + self._restore_external_config() + self._cleanup_external_config_snapshot() + + if self.run_book: + if ( + self.cur_user_config.get("Data", "ProxyTimes") == 0 + and self.cur_user_config.get("Info", "RemainedDay") != -1 + ): + await self.cur_user_config.set( + "Info", + "RemainedDay", + self.cur_user_config.get("Info", "RemainedDay") - 1, + ) + await self.cur_user_config.set( + "Data", + "ProxyTimes", + self.cur_user_config.get("Data", "ProxyTimes") + 1, + ) + self.cur_user_item.status = "完成" + await Notify.push_plyer( + "成功完成一个自动代理任务!", + f"已完成用户 {self.cur_user_item.name} 的自动代理任务", + f"已完成 {self.cur_user_item.name} 的自动代理任务", + 3, + ) + elif self.check_result == "Pass": + self.cur_user_item.status = "异常" + + async def on_crash(self, error: Exception) -> None: + """异常路径必须可重复执行,并向调度台报告 Error。""" + + self.cur_user_item.status = "异常" + logger.opt(exception=True).warning(f"专项自动代理任务出现异常: {error}") + await self._stop_log_monitor() + await self.kill_managed_process() + self._restore_external_config() + self._cleanup_external_config_snapshot() + with suppress(Exception): + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"专项自动代理任务出现异常: {error}"}, + ) diff --git a/templates/specialized/backend/task/Xxx/ScriptConfig.py b/templates/specialized/backend/task/Xxx/ScriptConfig.py new file mode 100644 index 000000000..9ebc65d37 --- /dev/null +++ b/templates/specialized/backend/task/Xxx/ScriptConfig.py @@ -0,0 +1,195 @@ +"""Xxx 原生配置会话任务。""" + +from __future__ import annotations + +import asyncio +import shutil +import uuid +from contextlib import suppress +from pathlib import Path + +from app.core import Config +from app.models.ConfigBase import MultipleConfig +from app.models.config import XxxConfig, XxxUserConfig +from app.models.emulator import DeviceBase +from app.models.task import ScriptItem, TaskExecuteBase +from app.services import System +from app.utils import ProcessManager, get_logger +from .AutoProxy import _split_script_arguments + + +logger = get_logger("专项显示名称配置") + + +class ScriptConfigTask(TaskExecuteBase): + """启动上游配置界面,停止任务时把配置保存回用户副本。""" + + def __init__( + self, + script_info: ScriptItem, + script_config: XxxConfig, + user_config: MultipleConfig[XxxUserConfig], + game_manager: ProcessManager | DeviceBase | None, + ) -> None: + super().__init__() + if script_info.task_info is None: + raise RuntimeError("ScriptItem 未绑定到 TaskItem") + + self.task_info = script_info.task_info + self.script_info = script_info + self.script_config = script_config + self.user_config = user_config + self.game_manager = game_manager + self.cur_user_item = self.script_info.user_list[self.script_info.current_index] + self.use_mas_config = True + if self.cur_user_item.user_id != "Default": + self.use_mas_config = bool( + self.user_config[uuid.UUID(self.cur_user_item.user_id)].get( + "Info", "IfUseMasConfig" + ) + ) + + self.general_process_manager: ProcessManager | None = None + self.wait_event: asyncio.Event | None = None + self.script_path: Path | None = None + self.script_set_exe_path: Path | None = None + self.script_set_arguments: list[str] = [] + self.script_config_path: Path | None = None + self.configuration_started = False + + def _user_config_path(self) -> Path: + if self.cur_user_item.user_id == "Default": + return Path.cwd() / "data" / self.script_info.script_id / "Default" / "ConfigFile" + return ( + Path.cwd() + / "data" + / self.script_info.script_id + / self.cur_user_item.user_id + / "ConfigFile" + ) + + def _user_config_source_path(self) -> Path: + user_path = self._user_config_path() + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + return user_path + return user_path / Path(self.script_config.get("Script", "ConfigPath")).name + + def _remove_path(self, path: Path) -> None: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + def _copy_path_atomic(self, source: Path, destination: Path, mode: str) -> None: + if mode == "Folder": + temporary = destination.with_name(destination.name + ".tmp") + shutil.rmtree(temporary, ignore_errors=True) + if source.exists(): + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, temporary, dirs_exist_ok=True) + self._remove_path(destination) + if temporary.exists(): + temporary.rename(destination) + return + + temporary = destination.with_name(destination.name + ".tmp") + if temporary.exists(): + temporary.unlink() + if source.exists(): + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, temporary) + temporary.replace(destination) + + async def prepare(self) -> None: + self.general_process_manager = ProcessManager() + self.wait_event = asyncio.Event() + self.script_path = Path(self.script_config.get("Script", "ScriptPath")) + argument_paths, argument_lists = _split_script_arguments( + self.script_config.get("Script", "Arguments"), self.script_path + ) + self.script_set_exe_path = ( + argument_paths[1] if len(argument_paths) > 1 else self.script_path + ) + self.script_set_arguments = argument_lists[1] if len(argument_lists) > 1 else [] + self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) + + async def set_script_config(self) -> None: + """配置会话启动前导入用户副本。""" + + if self.script_config_path is None: + return + await System.kill_process(self.script_set_exe_path) + if not self.use_mas_config: + logger.info("脚本直控配置:跳过导入用户配置") + return + source = self._user_config_source_path() + if not source.exists(): + logger.info("用户副本尚未创建,沿用脚本当前配置并在会话结束时保存") + return + self._copy_path_atomic( + source, + self.script_config_path, + self.script_config.get("Script", "ConfigPathMode"), + ) + + async def main_task(self) -> None: + await self.prepare() + await self.set_script_config() + if ( + self.general_process_manager is None + or self.script_set_exe_path is None + or self.wait_event is None + ): + raise RuntimeError("专项配置会话未完成初始化") + logger.info( + f"启动专项配置会话: {self.script_set_exe_path}, 参数: {self.script_set_arguments}" + ) + await self.general_process_manager.open_process( + self.script_set_exe_path, + *self.script_set_arguments, + ) + self.configuration_started = True + await self.wait_event.wait() + + async def final_task(self) -> None: + """停止原生配置进程并按模式保存用户副本。""" + + if self.general_process_manager is not None: + try: + await self.general_process_manager.kill() + except Exception as error: + logger.warning(f"停止专项配置进程失败: {error}") + if self.script_set_exe_path is not None: + try: + await System.kill_process(self.script_set_exe_path) + except Exception as error: + logger.warning(f"清理专项配置进程失败: {error}") + + if ( + not self.configuration_started + or not self.use_mas_config + or self.script_config_path is None + ): + logger.info("脚本直控配置:跳过保存用户副本") + return + self._copy_path_atomic( + self.script_config_path, + self._user_config_source_path(), + self.script_config.get("Script", "ConfigPathMode"), + ) + logger.success("专项配置已保存到用户副本") + + async def on_crash(self, error: Exception) -> None: + self.cur_user_item.status = "异常" + logger.opt(exception=True).warning(f"专项配置会话出现异常: {error}") + if self.script_set_exe_path is not None: + try: + await System.kill_process(self.script_set_exe_path) + except Exception as cleanup_error: + logger.warning(f"清理专项配置进程失败: {cleanup_error}") + with suppress(Exception): + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"专项配置会话出现异常: {error}"}, + ) diff --git a/templates/specialized/backend/task/Xxx/__init__.py b/templates/specialized/backend/task/Xxx/__init__.py new file mode 100644 index 000000000..e4b6d96c8 --- /dev/null +++ b/templates/specialized/backend/task/Xxx/__init__.py @@ -0,0 +1,5 @@ +"""Xxx 专项任务入口。""" + +from .manager import XxxManager + +__all__ = ["XxxManager"] diff --git a/templates/specialized/backend/task/Xxx/manager.py b/templates/specialized/backend/task/Xxx/manager.py new file mode 100644 index 000000000..5f26589e8 --- /dev/null +++ b/templates/specialized/backend/task/Xxx/manager.py @@ -0,0 +1,286 @@ +"""Xxx 专项调度器。""" + +from __future__ import annotations + +import shutil +import uuid +from contextlib import suppress +from datetime import datetime +from pathlib import Path + +from app.core import Config, EmulatorManager +from app.models.ConfigBase import MultipleConfig +from app.models.config import XxxConfig, XxxUserConfig +from app.models.emulator import DeviceBase +from app.models.task import ScriptItem, TaskExecuteBase, UserItem +from app.services import Notify +from app.utils import ProcessManager, get_logger +from .AutoProxy import AutoProxyTask +from .ScriptConfig import ScriptConfigTask + + +logger = get_logger("专项显示名称调度器") + +METHOD_BOOK: dict[str, type[AutoProxyTask | ScriptConfigTask]] = { + "AutoProxy": AutoProxyTask, + "ScriptConfig": ScriptConfigTask, +} + + +class XxxManager(TaskExecuteBase): + """协调脚本锁、用户列表、任务子类和脚本直控配置快照。""" + + def __init__(self, script_info: ScriptItem) -> None: + super().__init__() + if script_info.task_info is None: + raise RuntimeError("ScriptItem 未绑定到 TaskItem") + + self.task_info = script_info.task_info + self.script_info = script_info + self.check_result = "-" + self.script_config: XxxConfig | None = None + self.user_config: MultipleConfig[XxxUserConfig] | None = None + self.script_config_path: Path | None = None + self.temp_path: Path | None = None + self.external_config_exists = False + self.external_config_snapshot_ready = False + self.emulator_manager: DeviceBase | None = None + self.game_process_manager: ProcessManager | None = None + self.begin_time = "" + + async def check(self) -> str: + """检查任务模式、脚本类型和游戏配置。""" + + if self.task_info.mode not in METHOD_BOOK: + return "当前专项不支持该任务模式" + + script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] + if not isinstance(script_config, XxxConfig): + return "脚本配置类型错误,请重新选择专项脚本" + + if ( + script_config.get("Script", "IfTrackProcess") + and not script_config.get("Script", "TrackProcessName") + and not script_config.get("Script", "TrackProcessExe") + and not script_config.get("Script", "TrackProcessCmdline") + ): + return "请至少填写一项目标进程信息" + + if not script_config.get("Game", "Enabled"): + return "Pass" + game_type = script_config.get("Game", "Type") + if game_type == "Emulator" and ( + script_config.get("Game", "EmulatorId") == "-" + or script_config.get("Game", "EmulatorIndex") in ("", "-") + ): + return "请完成模拟器配置" + if game_type == "Client" and not Path(script_config.get("Game", "Path")).exists(): + return "请设置游戏或启动器路径" + if game_type == "URL" and not ( + script_config.get("Game", "URL") + and script_config.get("Game", "ProcessName") + ): + return "请填写游戏 URL 和进程名称" + return "Pass" + + def _remove_path(self, path: Path) -> None: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + def _copy_path_atomic(self, source: Path, destination: Path, mode: str) -> None: + """恢复直控配置时先写临时路径,再替换目标。""" + + if mode == "Folder": + temporary = destination.with_name(destination.name + ".tmp") + shutil.rmtree(temporary, ignore_errors=True) + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, temporary, dirs_exist_ok=True) + self._remove_path(destination) + temporary.rename(destination) + return + + temporary = destination.with_name(destination.name + ".tmp") + if temporary.exists(): + temporary.unlink() + temporary.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, temporary) + temporary.replace(destination) + + def _snapshot_external_config(self) -> None: + """保存脚本直控配置,隔离多个用户之间的配置变更。""" + + if self.script_config is None or self.script_config_path is None: + return + self.temp_path = Path.cwd() / "data" / self.script_info.script_id / "Temp" / "manager" + shutil.rmtree(self.temp_path, ignore_errors=True) + self.external_config_exists = self.script_config_path.exists() + self.temp_path.mkdir(parents=True, exist_ok=True) + if self.external_config_exists: + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + shutil.copytree(self.script_config_path, self.temp_path, dirs_exist_ok=True) + else: + shutil.copy2(self.script_config_path, self.temp_path / "config.temp") + self.external_config_snapshot_ready = True + + def _restore_external_config(self) -> None: + if ( + not self.external_config_snapshot_ready + or self.script_config is None + or self.script_config_path is None + or self.temp_path is None + ): + return + self._remove_path(self.script_config_path) + if not self.external_config_exists: + return + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + self._copy_path_atomic(self.temp_path, self.script_config_path, "Folder") + else: + self._copy_path_atomic( + self.temp_path / "config.temp", self.script_config_path, "File" + ) + + def _cleanup_external_config_snapshot(self) -> None: + if self.temp_path is not None: + shutil.rmtree(self.temp_path, ignore_errors=True) + self.external_config_snapshot_ready = False + + def _user_uses_mas_config(self) -> bool: + if self.user_config is None: + return True + user_id = self.script_info.user_list[self.script_info.current_index].user_id + if user_id == "Default": + return True + return bool(self.user_config[uuid.UUID(user_id)].get("Info", "IfUseMasConfig")) + + async def prepare(self) -> None: + """锁定脚本并构建本次任务的用户列表。""" + + script_uid = uuid.UUID(self.script_info.script_id) + await Config.ScriptConfig[script_uid].lock() + self.script_config = Config.ScriptConfig[script_uid] + self.user_config = MultipleConfig([XxxUserConfig]) + await self.user_config.load(await self.script_config.UserData.toDict()) + self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) + + if self.script_config.get("Game", "Enabled"): + game_type = self.script_config.get("Game", "Type") + if game_type == "Emulator": + self.emulator_manager = await EmulatorManager.get_emulator_instance( + self.script_config.get("Game", "EmulatorId") + ) + elif game_type in ("Client", "URL"): + self.game_process_manager = ProcessManager() + + if self.task_info.mode == "ScriptConfig": + self.script_info.user_list = [ + UserItem( + user_id=self.task_info.user_id or "Default", + name="", + status="等待", + ) + ] + else: + self.script_info.user_list = [ + UserItem(user_id=str(uid), name=config.get("Info", "Name"), status="等待") + for uid, config in self.user_config.items() + if config.get("Info", "Status") and config.get("Info", "RemainedDay") != 0 + ] + self._snapshot_external_config() + logger.info(f"专项用户列表加载完成: {len(self.script_info.user_list)}") + + async def main_task(self) -> None: + self.check_result = await self.check() + if self.check_result != "Pass": + self.script_info.status = "异常" + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": self.check_result}, + ) + return + + self.begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + await self.prepare() + if self.script_config is None or self.user_config is None: + raise RuntimeError("专项配置未初始化") + + for self.script_info.current_index in range(len(self.script_info.user_list)): + use_mas_config = self._user_uses_mas_config() + user_id = self.script_info.user_list[self.script_info.current_index].user_id + logger.info( + f"用户 {user_id} 配置来源: {'MAS 独立配置' if use_mas_config else '脚本直控配置'}" + ) + if not use_mas_config: + self._restore_external_config() + + task = METHOD_BOOK[self.task_info.mode]( + self.script_info, + self.script_config, + self.user_config, + ( + self.emulator_manager + if self.script_config.get("Game", "Type") == "Emulator" + else self.game_process_manager + ) + if self.script_config.get("Game", "Enabled") + else None, + ) + try: + await self.spawn(task) + finally: + if not use_mas_config: + self._snapshot_external_config() + + async def final_task(self) -> None: + """解锁、写回用户数据并聚合脚本状态。""" + + if self.check_result != "Pass": + self.script_info.status = "异常" + return + + self._restore_external_config() + self._cleanup_external_config_snapshot() + script_uid = uuid.UUID(self.script_info.script_id) + script_config = Config.ScriptConfig[script_uid] + if script_config.is_locked: + await script_config.unlock() + + # unlock-then-write:ConfigBase 在锁定状态下拒绝 load。 + if self.task_info.mode == "AutoProxy" and self.user_config is not None: + await script_config.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + + has_error = any(user.status == "异常" for user in self.script_info.user_list) + has_success = any(user.status == "完成" for user in self.script_info.user_list) + self.script_info.status = "异常" if has_error else "完成" + await Notify.push_plyer( + "专项自动代理任务已结束", + f"已完成用户数: {sum(user.status == '完成' for user in self.script_info.user_list)}," + f"异常用户数: {sum(user.status == '异常' for user in self.script_info.user_list)}", + self.script_info.result, + 10 if has_success else 3, + ) + + async def on_crash(self, error: Exception) -> None: + self.script_info.status = "异常" + logger.opt(exception=True).warning(f"专项调度任务出现异常: {error}") + try: + self._restore_external_config() + self._cleanup_external_config_snapshot() + except Exception as restore_error: + logger.warning(f"恢复专项脚本配置失败: {restore_error}") + try: + script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] + if script_config.is_locked: + await script_config.unlock() + except Exception as unlock_error: + logger.warning(f"解锁专项脚本配置失败: {unlock_error}") + with suppress(Exception): + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"专项调度任务出现异常: {error}"}, + ) diff --git a/templates/specialized/frontend/XxxScriptEdit.vue b/templates/specialized/frontend/XxxScriptEdit.vue new file mode 100644 index 000000000..93f3204e4 --- /dev/null +++ b/templates/specialized/frontend/XxxScriptEdit.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/templates/specialized/frontend/XxxUserEdit.vue b/templates/specialized/frontend/XxxUserEdit.vue new file mode 100644 index 000000000..e37ac4158 --- /dev/null +++ b/templates/specialized/frontend/XxxUserEdit.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/templates/specialized/frontend/XxxUserEdit/BasicInfoSection.vue b/templates/specialized/frontend/XxxUserEdit/BasicInfoSection.vue new file mode 100644 index 000000000..6b7dfab4a --- /dev/null +++ b/templates/specialized/frontend/XxxUserEdit/BasicInfoSection.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/templates/specialized/frontend/XxxUserEdit/NotifyConfigSection.vue b/templates/specialized/frontend/XxxUserEdit/NotifyConfigSection.vue new file mode 100644 index 000000000..2ca4fe27d --- /dev/null +++ b/templates/specialized/frontend/XxxUserEdit/NotifyConfigSection.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/templates/specialized/tests/test_xxx_autoproxy.py b/templates/specialized/tests/test_xxx_autoproxy.py new file mode 100644 index 000000000..0c6a2af19 --- /dev/null +++ b/templates/specialized/tests/test_xxx_autoproxy.py @@ -0,0 +1,104 @@ +"""Xxx AutoProxy 最小回归测试。 + +复制到 ``tests/task/test_xxx_autoproxy.py`` 后,将 import 路径和夹具替换为真实 +专项配置。测试不启动外部脚本,只固定参数解析、日志文件名前缀和配置恢复边界。 +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +try: + from app.task.Xxx.AutoProxy import ( + AutoProxyTask, + _format_to_prefix_regex, + _split_script_arguments, + ) +except ImportError: + # 模板尚未复制进 app/task 时允许仓库全量 pytest 收集并跳过本文件。 + AutoProxyTask = None + + +class _ScriptConfigStub: + def __init__(self, mode: str) -> None: + self.mode = mode + + def get(self, section: str, key: str): + if (section, key) == ("Script", "ConfigPathMode"): + return self.mode + raise AssertionError(f"unexpected config lookup: {section}.{key}") + + +def _build_task(root: Path, mode: str) -> AutoProxyTask: + task = object.__new__(AutoProxyTask) + task.script_config = _ScriptConfigStub(mode) + task.script_config_path = root / "script-config" + task.temp_path = root / "temp" + task.external_config_exists = False + task.external_config_snapshot_ready = False + return task + + +@unittest.skipUnless(AutoProxyTask is not None, "请先将模板复制并注册为 Xxx 专项") +class XxxAutoProxyTest(unittest.TestCase): + def test_split_script_arguments_keeps_executable_and_arguments(self) -> None: + paths, arguments = _split_script_arguments( + "runner.exe%--headless --task 1|config.exe%--settings", + Path("C:/Xxx"), + ) + self.assertEqual(paths[0], Path("C:/Xxx/runner.exe").resolve()) + self.assertEqual(arguments[0], ["--headless", "--task", "1"]) + self.assertEqual(arguments[1], ["--settings"]) + + def test_log_prefix_pattern_supports_strptime_directives(self) -> None: + pattern = _format_to_prefix_regex("%Y-%m-%d") + self.assertIsNotNone(pattern.match("2026-08-21-1.log")) + self.assertIsNone(pattern.match("not-a-date.log")) + + def test_folder_snapshot_restores_original_config(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + task = _build_task(root, "Folder") + task.script_config_path.mkdir() + (task.script_config_path / "keep.json").write_text("direct", encoding="utf-8") + + task._snapshot_external_config() + (task.script_config_path / "keep.json").unlink() + (task.script_config_path / "managed.json").write_text("managed", encoding="utf-8") + task._restore_external_config() + + self.assertEqual( + (task.script_config_path / "keep.json").read_text(encoding="utf-8"), + "direct", + ) + self.assertFalse((task.script_config_path / "managed.json").exists()) + + def test_file_snapshot_restores_original_config(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + task = _build_task(root, "File") + task.script_config_path.write_text("direct", encoding="utf-8") + + task._snapshot_external_config() + task.script_config_path.write_text("managed", encoding="utf-8") + task._restore_external_config() + + self.assertEqual(task.script_config_path.read_text(encoding="utf-8"), "direct") + + def test_missing_config_remains_missing_after_restore(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + task = _build_task(root, "Folder") + task._snapshot_external_config() + task.script_config_path.mkdir() + (task.script_config_path / "managed.json").write_text("managed", encoding="utf-8") + + task._restore_external_config() + + self.assertFalse(task.script_config_path.exists()) + + +if __name__ == "__main__": + unittest.main() From a6a527d71d84a64adb87f51bc83cc731a8e66977 Mon Sep 17 00:00:00 2001 From: HarcoChen Date: Sun, 6 Sep 2026 18:38:02 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=87=87=E9=9B=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/config.py | 33 +++ app/models/schema.py | 38 +++ app/task/MaaEnd/AutoProxy.py | 102 ++++++- app/utils/constants.py | 37 ++- frontend/src/composables/useScriptApi.ts | 26 ++ frontend/src/i18n/locales/en-US.ts | 36 +++ frontend/src/i18n/locales/zh-CN.ts | 33 +++ frontend/src/types/script.ts | 7 + frontend/src/utils/maaEndProtocolSpace.ts | 50 +++- .../views/EditView/User/MaaEndUserEdit.vue | 35 +++ .../AutoCollectConfigSection.vue | 261 ++++++++++++++++++ res/version.json | 1 + 12 files changed, 649 insertions(+), 10 deletions(-) create mode 100644 frontend/src/views/MaaEndUserEdit/AutoCollectConfigSection.vue diff --git a/app/models/config.py b/app/models/config.py index 3e3ae3baf..800386ff3 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -32,6 +32,9 @@ from app.utils.constants import ( CYCLE_EMPTY_TIME, MAA_STAGE_KEY, + MAAEND_AUTO_COLLECT_MODES, + MAAEND_AUTO_COLLECT_ROUTE_OPTIONS, + MAAEND_AUTO_COLLECT_TASK, MAAEND_DELIVERY_COMMISSION_SOURCES, MAAEND_DELIVERY_TASK, MAAEND_PROTOCOL_SPACE_TASK_OPTIONS, @@ -146,6 +149,36 @@ def init_maaend_task_config(config) -> None: ConfigItem("Task", f"If{MAAEND_DELIVERY_TASK}", True, BoolValidator()), ) + ## 独立自动采集任务 + config.Task_IfAutoCollect = ConfigItem( + "Task", f"If{MAAEND_AUTO_COLLECT_TASK}", True, BoolValidator() + ) + ## 自动采集路线安排:分散为三日轮换,集中为每三日执行一次 + config.Task_AutoCollectMode = ConfigItem( + "Task", + "AutoCollectMode", + "Distributed", + OptionsValidator(list(MAAEND_AUTO_COLLECT_MODES)), + ) + ## 自动采集区域资源路线 + config.Task_AutoCollectRoutes = ConfigItem( + "Task", + "AutoCollectRoutes", + list(MAAEND_AUTO_COLLECT_ROUTE_OPTIONS["AutoCollectRoutes"]), + MultipleOptionsValidator( + list(MAAEND_AUTO_COLLECT_ROUTE_OPTIONS["AutoCollectRoutes"]) + ), + ) + ## 自动采集通用资源路线 + config.Task_AutoCollectCommonRoutes = ConfigItem( + "Task", + "AutoCollectCommonRoutes", + list(MAAEND_AUTO_COLLECT_ROUTE_OPTIONS["AutoCollectCommonRoutes"]), + MultipleOptionsValidator( + list(MAAEND_AUTO_COLLECT_ROUTE_OPTIONS["AutoCollectCommonRoutes"]) + ), + ) + for task_name in MAAEND_TASKS: setattr( config, diff --git a/app/models/schema.py b/app/models/schema.py index b88de7bb8..802972a0c 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -1245,6 +1245,35 @@ class MaaEndUserConfig_Info(BaseModel): Tag: Optional[str] = Field(default=None, description="用户标签信息") +MaaEndAutoCollectRoute = Literal[ + "Route1", + "Route2", + "Route3", + "Route4", + "Route5", + "Route6", + "Route7", + "Route8", + "Route9", + "Route10", + "Route11", + "Route12", + "Route13", + "Route14", + "Route15", +] +MaaEndAutoCollectCommonRoute = Literal[ + "CommonRoute1", + "CommonRoute2", + "CommonRoute3", + "CommonRoute4", + "CommonRoute5", + "CommonRoute6", + "CommonRoute7", + "CommonRoute8", +] + + class MaaEndUserConfig_Task(BaseModel): SanityTaskType: Optional[ Literal["OperatorProgression", "WeaponProgression", "CrisisDrills", "Essence"] @@ -1294,6 +1323,15 @@ class MaaEndUserConfig_Task(BaseModel): default=None, description="环境监测" ) IfAutoCollect: Optional[bool] = Field(default=None, description="自动采集") + AutoCollectMode: Optional[Literal["Distributed", "Concentrated"]] = Field( + default=None, description="自动采集路线安排:分散或集中" + ) + AutoCollectRoutes: Optional[list[MaaEndAutoCollectRoute]] = Field( + default=None, description="自动采集区域资源路线" + ) + AutoCollectCommonRoutes: Optional[list[MaaEndAutoCollectCommonRoute]] = Field( + default=None, description="自动采集通用资源路线" + ) IfTrialOfSwordmancy: Optional[bool] = Field(default=None, description="选剑演武") IfDailyRewards: Optional[bool] = Field(default=None, description="日常奖励领取") IfResourceRecycleStation: Optional[bool] = Field( diff --git a/app/task/MaaEnd/AutoProxy.py b/app/task/MaaEnd/AutoProxy.py index 13bc85f20..fd1733e53 100644 --- a/app/task/MaaEnd/AutoProxy.py +++ b/app/task/MaaEnd/AutoProxy.py @@ -37,6 +37,10 @@ from app.task.general.tools import execute_script_task from app.utils import LogMonitor, ProcessManager, get_logger, is_process_running from app.utils.constants import ( + MAAEND_AUTO_COLLECT_MODES, + MAAEND_AUTO_COLLECT_ROUTE_OPTIONS, + MAAEND_AUTO_COLLECT_SCHEDULE_OPTIONS, + MAAEND_AUTO_COLLECT_TASK, MAAEND_DELIVERY_TASK, MAAEND_RUN_MOOD_BOOK, MAAEND_TASKS, @@ -57,7 +61,7 @@ def _task_enabled_for_mode(task_name: object, enabled: object, mode: str) -> bool: - """按送货/日常阶段筛选 MaaEnd 任务。""" + """按送货/日常/自动采集阶段筛选 MaaEnd 任务。""" name = str(task_name) if mode == "Delivery": @@ -65,11 +69,49 @@ def _task_enabled_for_mode(task_name: object, enabled: object, mode: str) -> boo MAAEND_DELIVERY_TASK, _MAAEND_ACCOUNT_SWITCH_TASK, } + if mode == "AutoCollect": + return bool(enabled) and name in { + MAAEND_AUTO_COLLECT_TASK, + _MAAEND_ACCOUNT_SWITCH_TASK, + } if mode == "Routine": - return bool(enabled) and name != MAAEND_DELIVERY_TASK + return bool(enabled) and name not in { + MAAEND_DELIVERY_TASK, + MAAEND_AUTO_COLLECT_TASK, + } raise ValueError(f"未知 MaaEnd 运行模式: {mode}") +def _select_auto_collect_routes( + mode: str, + routes: list[str], + common_routes: list[str], + now: datetime, +) -> dict[str, list[str]]: + """按东四区日期选择本轮需要执行的自动采集路线。""" + + cycle_index = now.date().toordinal() % 3 + + def select_routes(option_name: str, selected: list[str]) -> list[str]: + selected_set = set(selected) + return [ + route + for index, route in enumerate(MAAEND_AUTO_COLLECT_ROUTE_OPTIONS[option_name]) + if route in selected_set + and ( + mode == "Concentrated" and cycle_index == 0 + or mode == "Distributed" and index % 3 == cycle_index + ) + ] + + return { + "AutoCollectRoutes": select_routes("AutoCollectRoutes", routes), + "AutoCollectCommonRoutes": select_routes( + "AutoCollectCommonRoutes", common_routes + ), + } + + class AutoProxyTask(TaskExecuteBase): """MaaEnd 自动代理模式""" @@ -102,7 +144,11 @@ def __init__( self.task_dict: dict[str, dict[str, bool]] | None = None self.unique_task: dict[str, str] = {} self.maaend_config_file: Path | None = None - self.delivery_stage_enabled = False + self.account_switch_mode: str | None = None + self.auto_collect_run_at: datetime | None = None + self.auto_collect_routes: dict[str, list[str]] = { + option_name: [] for option_name in MAAEND_AUTO_COLLECT_ROUTE_OPTIONS + } async def check(self) -> str: @@ -168,10 +214,27 @@ async def prepare(self): (1, 23), "%Y-%m-%d %H:%M:%S.%f", self.check_log ) + if self.cur_user_config.get("Info", "IfQuickConfig"): + auto_collect_mode = self.cur_user_config.get("Task", "AutoCollectMode") + if auto_collect_mode not in MAAEND_AUTO_COLLECT_MODES: + auto_collect_mode = MAAEND_AUTO_COLLECT_MODES[0] + self.auto_collect_run_at = datetime.now(tz=UTC4) + self.auto_collect_routes = _select_auto_collect_routes( + auto_collect_mode, + list(self.cur_user_config.get("Task", "AutoCollectRoutes") or []), + list( + self.cur_user_config.get("Task", "AutoCollectCommonRoutes") or [] + ), + self.auto_collect_run_at, + ) + mode_enabled = { mode: self._mode_has_enabled_tasks(mode) for mode in MAAEND_RUN_MOOD_BOOK } - self.delivery_stage_enabled = mode_enabled["Delivery"] + self.account_switch_mode = next( + (mode for mode, enabled in mode_enabled.items() if enabled), + None, + ) self.run_book = {mode: not enabled for mode, enabled in mode_enabled.items()} def _source_maaend_tasks(self) -> list[dict[str, object]] | None: @@ -213,6 +276,11 @@ def _mode_has_enabled_tasks(self, mode: str) -> bool: if self.cur_user_config.get("Info", "IfQuickConfig"): if mode == "Delivery": return bool(self.cur_user_config.get("Task", "IfSeizeDeliveryJobs")) + if mode == "AutoCollect": + return bool( + self.cur_user_config.get("Task", "IfAutoCollect") + and any(self.auto_collect_routes.values()) + ) return any( bool(self.cur_user_config.get("Task", f"If{task_name}")) for task_name in MAAEND_TASKS @@ -228,12 +296,19 @@ def _mode_has_enabled_tasks(self, mode: str) -> bool: and bool(task.get("enabled", False)) for task in tasks ) + if mode == "AutoCollect": + return any( + str(task.get("taskName", "")) == MAAEND_AUTO_COLLECT_TASK + and bool(task.get("enabled", False)) + for task in tasks + ) return any( _task_enabled_for_mode( task.get("taskName"), task.get("enabled", False), mode ) for task in tasks if not str(task.get("taskName", "")).startswith("__MXU_") + and str(task.get("taskName", "")) != _MAAEND_ACCOUNT_SWITCH_TASK ) async def main_task(self): @@ -706,6 +781,11 @@ def get_task_book_name(task: dict[str, object]) -> str: task_enabled = self.cur_user_config.get( "Task", f"If{MAAEND_DELIVERY_TASK}" ) + elif task_name_value == MAAEND_AUTO_COLLECT_TASK: + task_enabled = bool( + self.cur_user_config.get("Task", "IfAutoCollect") + and any(self.auto_collect_routes.values()) + ) elif task_name_value in MAAEND_TASKS: task_enabled = self.cur_user_config.get( "Task", f"If{task_name_value}" @@ -716,8 +796,7 @@ def get_task_book_name(task: dict[str, object]) -> str: ) if ( task_name_value == _MAAEND_ACCOUNT_SWITCH_TASK - and self.mode == "Routine" - and self.delivery_stage_enabled + and self.mode != self.account_switch_mode ): task_enabled = False @@ -767,6 +846,17 @@ def get_task_book_name(task: dict[str, object]) -> str: "Task", "SeizeDeliveryJobsCommissionSource" ), } + elif if_quick_config and task_name_value == MAAEND_AUTO_COLLECT_TASK: + task.setdefault("optionValues", {}) + task["optionValues"]["AutoCollectSchedule"] = { + "type": "checkbox", + "caseNames": list(MAAEND_AUTO_COLLECT_SCHEDULE_OPTIONS), + } + for option_name, route_names in self.auto_collect_routes.items(): + task["optionValues"][option_name] = { + "type": "checkbox", + "caseNames": route_names, + } elif ( if_quick_config and task_name_value == target_task_name diff --git a/app/utils/constants.py b/app/utils/constants.py index 476530ea3..92a1fbd24 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -64,15 +64,47 @@ } """MAA运行模式对应的超时配置项:绿票商店只买一次商店,复用日常时限""" -MAAEND_RUN_MOOD_BOOK = {"Delivery": "送货", "Routine": "日常"} +MAAEND_RUN_MOOD_BOOK = { + "Delivery": "送货", + "Routine": "日常", + "AutoCollect": "自动采集", +} """MaaEnd 自动代理运行模式映射表""" MAAEND_DELIVERY_TASK = "SeizeDeliveryJobs" """MaaEnd 送货阶段使用的任务名称""" +MAAEND_AUTO_COLLECT_TASK = "AutoCollect" +"""MaaEnd 自动采集阶段使用的任务名称""" + MAAEND_DELIVERY_COMMISSION_SOURCES = ("Unlimited", "WulingCity", "TestArea") """MaaEnd 抢委托送货的委托接收点选项""" +MAAEND_AUTO_COLLECT_MODES = ("Distributed", "Concentrated") +"""MaaEnd 自动采集的三日周期模式""" + +MAAEND_AUTO_COLLECT_SCHEDULE_OPTIONS = tuple( + f"AutoCollectSchedule{weekday}" + for weekday in ( + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ) +) +"""MaaEnd 自动采集任务的星期计划选项""" + +MAAEND_AUTO_COLLECT_ROUTE_OPTIONS = { + "AutoCollectRoutes": tuple(f"Route{index}" for index in range(1, 16)), + "AutoCollectCommonRoutes": tuple( + f"CommonRoute{index}" for index in range(1, 9) + ), +} +"""MaaEnd 自动采集两类路线选项""" + MAA_TASKS = [ "StartUp", "DepotMaintain", @@ -347,7 +379,6 @@ ("AutoEcoFarm", "生态农场"), ("AutoSell", "售卖弹性物资"), ("EnvironmentMonitoring", "环境监测"), - ("AutoCollect", "自动采集"), ("TrialOfSwordmancy", "选剑演武"), ), }, @@ -370,7 +401,7 @@ for group in MAAEND_TASK_GROUPS.values() for task_name, _ in group["tasks"] ) -"""MaaEnd快速控制任务列表(不含独立送货任务)""" +"""MaaEnd快速控制任务列表(不含独立送货和自动采集阶段)""" MAAEND_SANITY_TASK_DEFAULTS = { "SanityTaskType": "OperatorProgression", diff --git a/frontend/src/composables/useScriptApi.ts b/frontend/src/composables/useScriptApi.ts index b65a9a200..55178d182 100644 --- a/frontend/src/composables/useScriptApi.ts +++ b/frontend/src/composables/useScriptApi.ts @@ -15,6 +15,10 @@ import { } from '@/api' import type { ScriptDetail, ScriptType, User } from '@/types/script' import { useAudioPlayer } from '@/composables/useAudioPlayer' +import { + MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS, + MAAEND_AUTO_COLLECT_ROUTE_OPTIONS, +} from '@/utils/maaEndProtocolSpace' const logger = window.electronAPI.getLogger('脚本API') @@ -60,6 +64,16 @@ const resolveScriptType = (configType: string): ScriptType => { return SCRIPT_TYPE_BY_CONFIG_TYPE[configType] ?? 'General' } +const normalizeMaaEndOptionArray = ( + value: unknown, + fallback: readonly T[] +): T[] => { + if (!Array.isArray(value)) return [...fallback] + return value.filter( + (item): item is T => typeof item === 'string' && fallback.includes(item as T) + ) +} + export function useScriptApi() { const loading = ref(false) const error = ref(null) @@ -636,6 +650,18 @@ export function useScriptApi() { maaEndUserData.Task?.IfAutoCollect != null ? maaEndUserData.Task.IfAutoCollect : true, + AutoCollectMode: + maaEndUserData.Task?.AutoCollectMode === 'Concentrated' + ? 'Concentrated' + : 'Distributed', + AutoCollectRoutes: normalizeMaaEndOptionArray( + maaEndUserData.Task?.AutoCollectRoutes, + MAAEND_AUTO_COLLECT_ROUTE_OPTIONS.map(option => option.value) + ), + AutoCollectCommonRoutes: normalizeMaaEndOptionArray( + maaEndUserData.Task?.AutoCollectCommonRoutes, + MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS.map(option => option.value) + ), IfTrialOfSwordmancy: maaEndUserData.Task?.IfTrialOfSwordmancy != null ? maaEndUserData.Task.IfTrialOfSwordmancy diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 801b42102..d804ff19a 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -432,6 +432,42 @@ export default { noConfigurationImportedYet: 'No configuration imported yet', whenHighTrafficSettings: 'When on, the high-traffic settings on this page override the MaaEnd tasks before a run; when off, the full task configuration from the file is used as is', + maaEndAutoCollectConfig: 'Auto-collect configuration', + maaEndAutoCollectEnabled: 'Auto-collect', + maaEndAutoCollectEnabledHint: + 'Runs auto-collect as an independent stage; the settings below are ignored when disabled.', + maaEndAutoCollectMode: 'Collection schedule', + maaEndAutoCollectModeDistributed: 'Distributed collection', + maaEndAutoCollectModeConcentrated: 'Concentrated collection', + maaEndAutoCollectModeDistributedHint: + 'Rotates routes on a three-day cycle and runs one group each day.', + maaEndAutoCollectModeConcentratedHint: + 'Runs all enabled routes together once every three days.', + maaEndAutoCollectRoutes: 'Regional resource routes', + maaEndAutoCollectCommonRoutes: 'Common resource routes', + maaEndAutoCollectRoute1: 'Route 1: Wuling City - Eroded Yuhua Leaf', + maaEndAutoCollectRoute2: 'Route 2: Wuling City - Rock Angel Leaf', + maaEndAutoCollectRoute3: 'Route 3: Wuling City - Zhijing Polytooth Leaf', + maaEndAutoCollectRoute4: 'Route 4: No. 4 Valley - Pure Crystal Polytooth Leaf', + maaEndAutoCollectRoute5: 'Route 5: No. 4 Valley - Light Dark Stone', + maaEndAutoCollectRoute6: 'Route 6: No. 4 Valley - Light Red Column Fungus', + maaEndAutoCollectRoute7: 'Route 7: Wuling City - Blood Fungus Wuling Stone', + maaEndAutoCollectRoute8: 'Route 8: Wuling City - Star Gate Fungus', + maaEndAutoCollectRoute9: 'Route 9: Wuling City - Liao Stone', + maaEndAutoCollectRoute10: 'Route 10: Wuling - Heavy Red Column Fungus', + maaEndAutoCollectRoute11: 'Route 11: Wuling - Heavy Dark Stone', + maaEndAutoCollectRoute12: 'Route 12: Wuling - Medium Red Column Fungus', + maaEndAutoCollectRoute13: 'Route 13: No. 4 Valley - Medium Dark Stone', + maaEndAutoCollectRoute14: 'Route 14: No. 4 Valley - Crystallized Polytooth Leaf', + maaEndAutoCollectRoute15: 'Route 15: Wuling - Red Spear Leaf', + maaEndAutoCollectCommonRoute1: 'Route 1: Yinghuo Buckwheat Flower', + maaEndAutoCollectCommonRoute2: 'Route 2: Dark Silver Citrus Fruit', + maaEndAutoCollectCommonRoute3: 'Route 3: Scorch Shell Bug', + maaEndAutoCollectCommonRoute4: 'Route 4: Firefly Shell Bug', + maaEndAutoCollectCommonRoute5: 'Route 5: Pengrong Brocade Grass', + maaEndAutoCollectCommonRoute6: 'Route 6: Qiongyesheng', + maaEndAutoCollectCommonRoute7: 'Route 7: Thorn Bud Needle', + maaEndAutoCollectCommonRoute8: 'Route 8: Golden Stone Rice', markAsDone2: 'Mark as done', takeOverTaskConfiguration: 'Take over the task configuration', usedWhenThereNo: 'Used when there is no event, or the shop is cleared — a normal farming run', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b7d82922f..7c56d742c 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -418,6 +418,39 @@ export default { noConfigurationImportedYet: '尚未导入配置', whenHighTrafficSettings: '开启后运行前会用本页高频配置项覆盖 MaaEnd 任务;关闭后直接运行配置文件内的完整任务配置', + maaEndAutoCollectConfig: '自动采集配置', + maaEndAutoCollectEnabled: '自动采集', + maaEndAutoCollectEnabledHint: '自动采集作为独立阶段运行;关闭后下方安排不会生效', + maaEndAutoCollectMode: '采集安排', + maaEndAutoCollectModeDistributed: '分散采集', + maaEndAutoCollectModeConcentrated: '集中采集', + maaEndAutoCollectModeDistributedHint: '按三日周期轮换路线,每天执行其中一组', + maaEndAutoCollectModeConcentratedHint: '每三天集中执行全部已启用路线', + maaEndAutoCollectRoutes: '区域资源路线', + maaEndAutoCollectCommonRoutes: '通用资源路线', + maaEndAutoCollectRoute1: '线路1:武陵城-受蚀玉华叶', + maaEndAutoCollectRoute2: '线路2:武陵城-岩天使叶', + maaEndAutoCollectRoute3: '线路3:武陵城-至晶多齿叶', + maaEndAutoCollectRoute4: '路线4:四号谷地-纯晶多齿叶', + maaEndAutoCollectRoute5: '路线5:四号谷地-轻黯石', + maaEndAutoCollectRoute6: '路线6:四号谷地-轻红柱状菌', + maaEndAutoCollectRoute7: '路线7:武陵城-血菌武陵石', + maaEndAutoCollectRoute8: '路线8:武陵城-星门菌', + maaEndAutoCollectRoute9: '路线9:武陵城-燎石', + maaEndAutoCollectRoute10: '路线10:武陵-重红柱状菌', + maaEndAutoCollectRoute11: '路线11:武陵-重黯石', + maaEndAutoCollectRoute12: '路线12:武陵-中红柱状菌', + maaEndAutoCollectRoute13: '路线13:四号谷地-中黯石', + maaEndAutoCollectRoute14: '路线14:四号谷地-晶化多齿叶', + maaEndAutoCollectRoute15: '路线15:武陵-红矛叶', + maaEndAutoCollectCommonRoute1: '线路1:映火荞花', + maaEndAutoCollectCommonRoute2: '线路2:黯银柑实', + maaEndAutoCollectCommonRoute3: '线路3:灼壳虫', + maaEndAutoCollectCommonRoute4: '线路4:萤壳虫', + maaEndAutoCollectCommonRoute5: '线路5:蓬茸锦草', + maaEndAutoCollectCommonRoute6: '线路6:琼叶参', + maaEndAutoCollectCommonRoute7: '线路7:荆刺芽针', + maaEndAutoCollectCommonRoute8: '线路8:金石稻', markAsDone2: '手动完成', takeOverTaskConfiguration: '接管具体任务配置', usedWhenThereNo: '无活动或换完商店时使用,进行常规刷取', diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index f7c69567e..ceef90866 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -13,6 +13,9 @@ import type { } from '@/api' import type { AutoEssenceLocation, + MaaEndAutoCollectCommonRoute, + MaaEndAutoCollectMode, + MaaEndAutoCollectRoute, MaaEndDeliveryCommissionSource, MaaEndTaskSwitch, ProtocolSpaceTaskValue, @@ -140,8 +143,12 @@ export type MaaEndTaskSwitchConfig = Record<`If${MaaEndTaskSwitch}`, boolean> & } export type MaaEndTaskConfig = MaaEndTaskSwitchConfig & { + IfAutoCollect: boolean SeizeDeliveryJobsReward: number SeizeDeliveryJobsCommissionSource: MaaEndDeliveryCommissionSource + AutoCollectMode: MaaEndAutoCollectMode + AutoCollectRoutes: MaaEndAutoCollectRoute[] + AutoCollectCommonRoutes: MaaEndAutoCollectCommonRoute[] SanityTaskType: SanityTaskType OperatorProgression: ProtocolSpaceTaskValue WeaponProgression: ProtocolSpaceTaskValue diff --git a/frontend/src/utils/maaEndProtocolSpace.ts b/frontend/src/utils/maaEndProtocolSpace.ts index 537f60e75..038ae8099 100644 --- a/frontend/src/utils/maaEndProtocolSpace.ts +++ b/frontend/src/utils/maaEndProtocolSpace.ts @@ -11,6 +11,55 @@ export const MAAEND_DELIVERY_COMMISSION_SOURCE_OPTIONS = [ export type MaaEndDeliveryCommissionSource = (typeof MAAEND_DELIVERY_COMMISSION_SOURCE_OPTIONS)[number]['value'] +export const MAAEND_AUTO_COLLECT_MODE_OPTIONS = [ + { + labelKey: 'edit.maaEndAutoCollectModeDistributed', + value: 'Distributed', + }, + { + labelKey: 'edit.maaEndAutoCollectModeConcentrated', + value: 'Concentrated', + }, +] as const + +export type MaaEndAutoCollectMode = + (typeof MAAEND_AUTO_COLLECT_MODE_OPTIONS)[number]['value'] + +export const MAAEND_AUTO_COLLECT_ROUTE_OPTIONS = [ + { value: 'Route1', labelKey: 'edit.maaEndAutoCollectRoute1' }, + { value: 'Route2', labelKey: 'edit.maaEndAutoCollectRoute2' }, + { value: 'Route3', labelKey: 'edit.maaEndAutoCollectRoute3' }, + { value: 'Route4', labelKey: 'edit.maaEndAutoCollectRoute4' }, + { value: 'Route5', labelKey: 'edit.maaEndAutoCollectRoute5' }, + { value: 'Route6', labelKey: 'edit.maaEndAutoCollectRoute6' }, + { value: 'Route7', labelKey: 'edit.maaEndAutoCollectRoute7' }, + { value: 'Route8', labelKey: 'edit.maaEndAutoCollectRoute8' }, + { value: 'Route9', labelKey: 'edit.maaEndAutoCollectRoute9' }, + { value: 'Route10', labelKey: 'edit.maaEndAutoCollectRoute10' }, + { value: 'Route11', labelKey: 'edit.maaEndAutoCollectRoute11' }, + { value: 'Route12', labelKey: 'edit.maaEndAutoCollectRoute12' }, + { value: 'Route13', labelKey: 'edit.maaEndAutoCollectRoute13' }, + { value: 'Route14', labelKey: 'edit.maaEndAutoCollectRoute14' }, + { value: 'Route15', labelKey: 'edit.maaEndAutoCollectRoute15' }, +] as const + +export type MaaEndAutoCollectRoute = + (typeof MAAEND_AUTO_COLLECT_ROUTE_OPTIONS)[number]['value'] + +export const MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS = [ + { value: 'CommonRoute1', labelKey: 'edit.maaEndAutoCollectCommonRoute1' }, + { value: 'CommonRoute2', labelKey: 'edit.maaEndAutoCollectCommonRoute2' }, + { value: 'CommonRoute3', labelKey: 'edit.maaEndAutoCollectCommonRoute3' }, + { value: 'CommonRoute4', labelKey: 'edit.maaEndAutoCollectCommonRoute4' }, + { value: 'CommonRoute5', labelKey: 'edit.maaEndAutoCollectCommonRoute5' }, + { value: 'CommonRoute6', labelKey: 'edit.maaEndAutoCollectCommonRoute6' }, + { value: 'CommonRoute7', labelKey: 'edit.maaEndAutoCollectCommonRoute7' }, + { value: 'CommonRoute8', labelKey: 'edit.maaEndAutoCollectCommonRoute8' }, +] as const + +export type MaaEndAutoCollectCommonRoute = + (typeof MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS)[number]['value'] + export const PROTOCOL_SPACE_OPTIONS = [ { label: '干员养成', value: 'OperatorProgression' }, { label: '武器养成', value: 'WeaponProgression' }, @@ -133,7 +182,6 @@ export const MAAEND_TASK_GROUPS = [ { name: 'AutoEcoFarm', label: '🌾 生态农场' }, { name: 'AutoSell', label: '💰 售卖弹性物资' }, { name: 'EnvironmentMonitoring', label: '🌿 环境监测' }, - { name: 'AutoCollect', label: '🧺 自动采集' }, { name: 'TrialOfSwordmancy', label: '🗡️ 选剑演武' }, ], }, diff --git a/frontend/src/views/EditView/User/MaaEndUserEdit.vue b/frontend/src/views/EditView/User/MaaEndUserEdit.vue index 0ba2df70f..42f51770e 100644 --- a/frontend/src/views/EditView/User/MaaEndUserEdit.vue +++ b/frontend/src/views/EditView/User/MaaEndUserEdit.vue @@ -62,6 +62,12 @@ :loading="loading" @save="handleFieldSave" /> + ({ AutoEssenceSpecifiedLocation: '', SeizeDeliveryJobsReward: 15.9, SeizeDeliveryJobsCommissionSource: 'Unlimited', + AutoCollectMode: 'Distributed', + AutoCollectRoutes: [ + 'Route1', + 'Route2', + 'Route3', + 'Route4', + 'Route5', + 'Route6', + 'Route7', + 'Route8', + 'Route9', + 'Route10', + 'Route11', + 'Route12', + 'Route13', + 'Route14', + 'Route15', + ], + AutoCollectCommonRoutes: [ + 'CommonRoute1', + 'CommonRoute2', + 'CommonRoute3', + 'CommonRoute4', + 'CommonRoute5', + 'CommonRoute6', + 'CommonRoute7', + 'CommonRoute8', + ], IfSanity: true, IfAutoUseSpMedication: true, IfDijiangRewards: true, diff --git a/frontend/src/views/MaaEndUserEdit/AutoCollectConfigSection.vue b/frontend/src/views/MaaEndUserEdit/AutoCollectConfigSection.vue new file mode 100644 index 000000000..5c85d7b29 --- /dev/null +++ b/frontend/src/views/MaaEndUserEdit/AutoCollectConfigSection.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/res/version.json b/res/version.json index a89872263..76620069b 100644 --- a/res/version.json +++ b/res/version.json @@ -4,6 +4,7 @@ "v5.5.0-beta.3": { "新增功能": [ "MaaEnd专项 将抢委托送货独立为送货阶段,并支持单独配置接取价格和委托接收点", + "MaaEnd专项 将自动采集独立为三日周期阶段,可选择分散或集中采集并配置两类路线", "专项适配 提供可复制的 General 能力基线模板,覆盖后端任务、前端编辑页、注册清单与最小回归测试", "MFW 项目支持在脚本运行前或运行后自动更新,可在项目配置中选择时机;升级后已有脚本默认为「运行前更新」,不需要可在项目配置中改为「不更新」 by [@qiyinxi](https://github.com/qiyinxi)", "MAA专项 新增绿票商店开关:开启后每月单独启动一次 MAA 自动购买绿票商店,用户配置页可查看本月状态并手动重置 by [@qiyinxi](https://github.com/qiyinxi)", From 2a923c7f17e6193ff0b1b58b011eb8234d6050ab Mon Sep 17 00:00:00 2001 From: HarcoChen Date: Sun, 6 Sep 2026 19:37:05 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/EditView/User/MaaEndUserEdit.vue | 75 +++++++++++++------ .../MaaEndUserEdit/DeliveryConfigSection.vue | 55 +++++--------- res/version.json | 1 + 3 files changed, 72 insertions(+), 59 deletions(-) diff --git a/frontend/src/views/EditView/User/MaaEndUserEdit.vue b/frontend/src/views/EditView/User/MaaEndUserEdit.vue index 42f51770e..3e5badc3e 100644 --- a/frontend/src/views/EditView/User/MaaEndUserEdit.vue +++ b/frontend/src/views/EditView/User/MaaEndUserEdit.vue @@ -142,15 +142,15 @@ const logger = window.electronAPI.getLogger('MaaEnd用户编辑') const router = useRouter() const route = useRoute() -const { addUser, updateUser, getUsers, loading: userLoading } = useUserApi() +const { addUser, updateUser, getUsers } = useUserApi() const { getScript, getMaaEndOptions, importScriptConfigFile } = useScriptApi() const { getPlans } = usePlanApi() const { subscribe, unsubscribe } = useWebSocket() const formRef = ref() const isInitializing = ref(true) -const isSaving = ref(false) -const loading = computed(() => isInitializing.value || userLoading.value) +// 保存请求不再驱动整页 loading,避免每次自动保存都让表单快速闪动。 +const loading = computed(() => isInitializing.value) const maaEndOptionsLoading = ref(false) const maaEndOptionsLoaded = ref(false) @@ -270,6 +270,10 @@ interface FieldChange { value: any } +// 保存中的后续修改按字段合并,避免输入过程中被前一个请求丢弃或重复发送旧值。 +const pendingFieldSaves = new Map() +let fieldSavePromise: Promise | null = null + const formData = reactive({ userName: '', ...getDefaultMaaEndUserData(), @@ -301,31 +305,55 @@ const setNestedValue = (target: Record, path: string, value: any) = } const saveUserFields = async (changes: FieldChange[]) => { - if (isInitializing.value || isSaving.value || !userId || !changes.length) return - - isSaving.value = true - try { - const userData: Record = {} - - changes.forEach(change => { - if (change.key === 'userName') { - syncUserName() - setNestedValue(userData, 'Info.Name', formData.Info.Name) - return - } - - setNestedValue(userData, change.key, change.value) - }) + if (isInitializing.value || !userId || !changes.length) return false - await updateUser(scriptId, userId, userData) - } catch (error) { - logger.error(`保存用户字段失败: ${error instanceof Error ? error.message : String(error)}`) - } finally { - isSaving.value = false + for (const change of changes) { + pendingFieldSaves.set(change.key, change.value) } + if (fieldSavePromise) return fieldSavePromise + + const savePromise = (async (): Promise => { + try { + while (pendingFieldSaves.size > 0) { + const userData: Record = {} + const currentChanges = Array.from(pendingFieldSaves.entries()) + pendingFieldSaves.clear() + + currentChanges.forEach(([key, value]) => { + if (key === 'userName') { + syncUserName() + setNestedValue(userData, 'Info.Name', formData.Info.Name) + return + } + + setNestedValue(userData, key, value) + }) + + if (!(await updateUser(scriptId, userId, userData))) { + pendingFieldSaves.clear() + return false + } + } + return true + } catch (error) { + pendingFieldSaves.clear() + logger.error(`保存用户字段失败: ${error instanceof Error ? error.message : String(error)}`) + return false + } finally { + fieldSavePromise = null + } + })() + fieldSavePromise = savePromise + return savePromise } const handleFieldSave = async (key: string, value: any) => { + if (key === 'userName') { + formData.userName = value + syncUserName() + } else { + setNestedValue(formData, key, value) + } await saveUserFields([{ key, value }]) } @@ -336,6 +364,7 @@ const handleConfigModeChange = async (value: boolean | string) => { } const handleFieldsSave = async (changes: FieldChange[]) => { + changes.forEach(change => setNestedValue(formData, change.key, change.value)) await saveUserFields(changes) } diff --git a/frontend/src/views/MaaEndUserEdit/DeliveryConfigSection.vue b/frontend/src/views/MaaEndUserEdit/DeliveryConfigSection.vue index 947a65a56..54b5f7447 100644 --- a/frontend/src/views/MaaEndUserEdit/DeliveryConfigSection.vue +++ b/frontend/src/views/MaaEndUserEdit/DeliveryConfigSection.vue @@ -15,12 +15,16 @@ - +
- + @@ -45,7 +50,7 @@ diff --git a/res/version.json b/res/version.json index 76620069b..096671d3a 100644 --- a/res/version.json +++ b/res/version.json @@ -44,6 +44,7 @@ "BetterGI专项 重新生成与后端接口定义脱节的前端接口代码 by [@qiyinxi](https://github.com/qiyinxi)" ], "修复BUG": [ + "MaaEnd专项 修复修改送货配置时页面快速刷新、导致输入过程不稳定的问题", "OK-NTE专项 加固切换账号流程:预防弹窗及屏保等意外情况 by [@AthenaHibou](https://github.com/AthenaHibou)", "修复同时打开两个前端窗口时互相抢占后端连接、每隔几秒断线并反复弹出提示的问题:后开的窗口接管连接,先开的窗口停止重连并只提示一次 by [@qiyinxi](https://github.com/qiyinxi)", "修复与后端断开连接时立即弹出阻塞式弹窗的问题:改为右上角非阻塞提示并在重连成功后自动收起,生产模式仅在整轮重连失败后才升级为弹窗,开发模式下开发者手动重启后端不再被弹窗打断 by [@qiyinxi](https://github.com/qiyinxi)", From f0d2e146bd88e64179fb627269ef14a9b5cdd6fc Mon Sep 17 00:00:00 2001 From: HarcoChen Date: Sun, 6 Sep 2026 21:11:25 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/EditView/User/MaaEndUserEdit.vue | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/EditView/User/MaaEndUserEdit.vue b/frontend/src/views/EditView/User/MaaEndUserEdit.vue index 3e5badc3e..7979e0e32 100644 --- a/frontend/src/views/EditView/User/MaaEndUserEdit.vue +++ b/frontend/src/views/EditView/User/MaaEndUserEdit.vue @@ -56,11 +56,19 @@ @script-config="handleScriptConfig" @mode-change="handleConfigModeChange" /> - - Date: Sun, 6 Sep 2026 22:04:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?AI:=20=E8=AE=A9Kimi=E9=87=8D=E6=9E=84UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ExtraScriptSection.vue | 4 +- frontend/src/components/UserNotifyConfig.vue | 5 +- frontend/src/i18n/locales/zh-CN.ts | 12 + frontend/src/utils/maaEndProtocolSpace.ts | 38 ++- .../views/EditView/User/MaaEndUserEdit.vue | 215 ++++++++++---- .../AutoCollectConfigSection.vue | 265 +++++++++++++----- .../views/MaaEndUserEdit/BasicInfoSection.vue | 56 +--- .../MaaEndUserEdit/DeliveryConfigSection.vue | 112 +++----- .../MaaEndUserEdit/TaskConfigSection.vue | 56 +--- res/version.json | 1 + 10 files changed, 449 insertions(+), 315 deletions(-) diff --git a/frontend/src/components/ExtraScriptSection.vue b/frontend/src/components/ExtraScriptSection.vue index 7ef950650..7f8b7b83e 100644 --- a/frontend/src/components/ExtraScriptSection.vue +++ b/frontend/src/components/ExtraScriptSection.vue @@ -1,6 +1,6 @@ @@ -97,8 +147,10 @@ import { MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS, MAAEND_AUTO_COLLECT_MODE_OPTIONS, MAAEND_AUTO_COLLECT_ROUTE_OPTIONS, + MAAEND_AUTO_COLLECT_ROUTE_REGIONS, type MaaEndAutoCollectCommonRoute, type MaaEndAutoCollectMode, + type MaaEndAutoCollectRegionKey, type MaaEndAutoCollectRoute, } from '@/utils/maaEndProtocolSpace' @@ -146,6 +198,7 @@ const routeOptions = computed(() => MAAEND_AUTO_COLLECT_ROUTE_OPTIONS.map(option => ({ value: option.value, label: t(option.labelKey), + regionKey: option.regionKey, })) ) const commonRouteOptions = computed(() => @@ -160,6 +213,74 @@ const modeHint = computed(() => : t('edit.maaEndAutoCollectModeDistributedHint') ) +interface RegionPanel { + key: MaaEndAutoCollectRegionKey + label: string + options: Array<{ value: MaaEndAutoCollectRoute; label: string }> +} + +const regionPanels = computed(() => + MAAEND_AUTO_COLLECT_ROUTE_REGIONS.map(regionKey => ({ + key: regionKey, + label: t(regionKey), + options: routeOptions.value.filter(option => option.regionKey === regionKey), + })) +) + +const panelValues = (panel: RegionPanel) => + panel.options.filter(option => routes.value.includes(option.value)).map(option => option.value) + +const panelSelectedCount = (panel: RegionPanel) => panelValues(panel).length + +const commonSelectedCount = computed( + () => + commonRouteOptions.value.filter(option => commonRoutes.value.includes(option.value)).length +) + +// 区域勾选结果与其余区域已选合并后按选项源顺序重组,保证保存的始终是完整有序数组 +const applyRegionValues = (panel: RegionPanel, values: Array) => { + const regionValueSet = new Set(panel.options.map(option => option.value)) + const nextSet = new Set([ + ...routes.value.filter(value => !regionValueSet.has(value)), + ...values.map(String), + ]) + routes.value = MAAEND_AUTO_COLLECT_ROUTE_OPTIONS.map(option => option.value).filter(value => + nextSet.has(value) + ) + emit('save', 'Task.AutoCollectRoutes', routes.value) +} + +const handleRegionChange = (panel: RegionPanel, values: Array) => { + applyRegionValues(panel, values) +} + +const handleSelectAll = (panel: RegionPanel) => { + applyRegionValues( + panel, + panel.options.map(option => option.value) + ) +} + +const handleClear = (panel: RegionPanel) => { + applyRegionValues(panel, []) +} + +const applyCommonRoutes = (values: MaaEndAutoCollectCommonRoute[]) => { + const nextSet = new Set(values) + commonRoutes.value = MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS.map(option => + option.value + ).filter(value => nextSet.has(value)) + emit('save', 'Task.AutoCollectCommonRoutes', commonRoutes.value) +} + +const handleCommonSelectAll = () => { + applyCommonRoutes(commonRouteOptions.value.map(option => option.value)) +} + +const handleCommonClear = () => { + applyCommonRoutes([]) +} + watch( () => props.formData.Task?.IfAutoCollect, value => { @@ -200,11 +321,6 @@ const handleModeChange = (value: MaaEndAutoCollectMode) => { emit('save', 'Task.AutoCollectMode', value) } -const handleRoutesChange = (value: Array) => { - routes.value = normalizeRoutes(value, MAAEND_AUTO_COLLECT_ROUTE_OPTIONS) - emit('save', 'Task.AutoCollectRoutes', routes.value) -} - const handleCommonRoutesChange = (value: Array) => { commonRoutes.value = normalizeRoutes(value, MAAEND_AUTO_COLLECT_COMMON_ROUTE_OPTIONS) emit('save', 'Task.AutoCollectCommonRoutes', commonRoutes.value) @@ -212,50 +328,59 @@ const handleCommonRoutesChange = (value: Array) => { diff --git a/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue b/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue index d9977c7c0..8eb4eeb9d 100644 --- a/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue +++ b/frontend/src/views/MaaEndUserEdit/BasicInfoSection.vue @@ -1,9 +1,5 @@