diff --git a/app/api/scripts.py b/app/api/scripts.py index 0261db0c5..f8cdd9db8 100644 --- a/app/api/scripts.py +++ b/app/api/scripts.py @@ -30,6 +30,7 @@ from fastapi.responses import FileResponse from app.core import Config +from app.models.config import BetterGIConfig as RuntimeBetterGIConfig from app.models.config import HSRConfig as RuntimeHSRConfig from app.models.config import MaaFWConfig as RuntimeMaaFWConfig from app.models.config import OkNteConfig as RuntimeOkNteConfig @@ -62,6 +63,15 @@ def _hsr_script_config(script_id: str): return script_config +def _bettergi_script_config(script_id: str): + """Resolve a BetterGI script and reject cross-type IDs before domain access.""" + + script_config = Config.ScriptConfig[uuid.UUID(script_id)] + if not isinstance(script_config, RuntimeBetterGIConfig): + raise TypeError("脚本配置类型错误, 不是 BetterGI 类型") + return script_config + + def _hsr_user_config(script_config: RuntimeHSRConfig, user_id: str): user_config = script_config.UserData[uuid.UUID(user_id)] return user_config @@ -145,6 +155,7 @@ def _maafw_update_source_config(script_config: RuntimeMaaFWConfig) -> dict[str, "OkwwConfig": OkwwConfig, "OkNteConfig": OkNteConfig, "HSRConfig": HSRConfig, + "BetterGIConfig": BetterGIConfig, } USER_BOOK = { "MaaConfig": MaaUserConfig, @@ -156,6 +167,7 @@ def _maafw_update_source_config(script_config: RuntimeMaaFWConfig) -> dict[str, "OkwwConfig": OkwwUserConfig, "OkNteConfig": OkNteUserConfig, "HSRConfig": HSRUserConfig, + "BetterGIConfig": BetterGIUserConfig, } @@ -1133,6 +1145,117 @@ async def get_hsr_stage_options_api( ) +@router.get( + "/bettergi/strategies", + tags=["BetterGI"], + summary="获取 BetterGI 自动战斗策略选项", + response_model=ComboBoxOut, + status_code=200, +) +async def get_bettergi_strategies_api(scriptId: str) -> ComboBoxOut: + """返回 BetterGI 可用自动战斗策略:内置「根据队伍自动选择」+ ``{RootPath}/User/AutoFight/*.txt`` 文件名。""" + + try: + script_config = _bettergi_script_config(scriptId) + root = Path(script_config.get("Info", "RootPath")).expanduser() + from app.task.BetterGI.tools import one_dragon + + names = one_dragon.list_auto_boss_strategies(root) + data = [ComboBoxItem(label=n, value=n) for n in names] + return ComboBoxOut( + code=200, + status="success", + message=f"共 {len(data)} 个自动战斗策略选项", + data=data, + ) + except Exception as e: + return ComboBoxOut( + code=400 if isinstance(e, (ValueError, KeyError, TypeError, RuntimeError)) + else 500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + data=[], + ) + + +@router.get( + "/bettergi/one-dragon/custom-groups", + tags=["BetterGI"], + summary="获取 BetterGI 一条龙自定义配置组", + response_model=BetterGICustomGroupsOut, + status_code=200, +) +async def get_bettergi_custom_groups_api( + scriptId: str, configName: str = "", useMasConfig: bool = False +) -> BetterGICustomGroupsOut: + """返回指定一条龙配置里的自定义配置组(非内置 8 组)及其启用状态,供前端表格自动加载。 + + ``useMasConfig=True``(用户独立配置)时改读 MAS 运行时槽位「MAS独立配置」:独立模式的 + per-user 配置物化在槽位而非 {configName} 实配,读槽位才能列到用户刚在 BGI GUI 里往 + 独立配置添加的自定义组。 + """ + + try: + script_config = _bettergi_script_config(scriptId) + root = Path(script_config.get("Info", "RootPath")).expanduser() + from app.task.BetterGI.tools import one_dragon + + read_name = ( + one_dragon.launch_slot_name() + if useMasConfig + else one_dragon.resolve_config_name(configName) + ) + items = one_dragon.list_custom_groups(root, read_name) + data = [BetterGICustomGroupOut(**item) for item in items] + return BetterGICustomGroupsOut( + code=200, + status="success", + message=f"共 {len(data)} 个自定义配置组", + data=data, + ) + except Exception as e: + return BetterGICustomGroupsOut( + code=400 if isinstance(e, (ValueError, KeyError, TypeError, RuntimeError)) + else 500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + data=[], + ) + + +@router.get( + "/bettergi/one-dragon/configs", + tags=["BetterGI"], + summary="获取 BetterGI 一条龙配置名列表", + response_model=ComboBoxOut, + status_code=200, +) +async def get_bettergi_one_dragon_configs_api(scriptId: str) -> ComboBoxOut: + """返回 BetterGI 可选一条龙配置名:{RootPath}/User/OneDragon/*.json 文件名(默认配置置顶)。""" + + try: + script_config = _bettergi_script_config(scriptId) + root = Path(script_config.get("Info", "RootPath")).expanduser() + from app.task.BetterGI.tools import one_dragon + + names = one_dragon.list_one_dragon_configs(root) + data = [ComboBoxItem(label=n, value=n) for n in names] + return ComboBoxOut( + code=200, + status="success", + message=f"共 {len(data)} 个一条龙配置", + data=data, + ) + except Exception as e: + return ComboBoxOut( + code=400 if isinstance(e, (ValueError, KeyError, TypeError, RuntimeError)) + else 500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + data=[], + ) + + @router.get( "/hsr/capabilities", tags=["HSR"], diff --git a/app/core/config.py b/app/core/config.py index 915ecac43..a9818e419 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -49,6 +49,7 @@ OkwwConfig, OkNteConfig, HSRConfig, + BetterGIConfig, HSRUserConfig, MaaPlanConfig, MaaEndPlanConfig, @@ -62,6 +63,7 @@ GeneralUserConfig, OkwwUserConfig, OkNteUserConfig, + BetterGIUserConfig, GlobalConfig, CLASS_BOOK, PLAN_BOOK, @@ -739,6 +741,7 @@ async def add_script( "Okww", "OkNte", "HSR", + "BetterGI", ], script_id: str | None = None, ) -> tuple[ @@ -751,7 +754,8 @@ async def add_script( | MaaFWConfig | OkwwConfig | OkNteConfig - | HSRConfig, + | HSRConfig + | BetterGIConfig, ]: """添加脚本配置""" @@ -1051,7 +1055,8 @@ async def add_user( | MaaFWUserConfig | OkwwUserConfig | OkNteUserConfig - | HSRUserConfig, + | HSRUserConfig + | BetterGIUserConfig, ]: """添加用户配置""" @@ -1088,6 +1093,8 @@ async def add_user( uid, config = await script_config.UserData.add(MaaFWUserConfig) elif isinstance(script_config, HSRConfig): uid, config = await script_config.UserData.add(HSRUserConfig) + elif isinstance(script_config, BetterGIConfig): + uid, config = await script_config.UserData.add(BetterGIUserConfig) else: raise TypeError(f"不支持的脚本配置类型: {type(script_config)}") diff --git a/app/core/task_manager.py b/app/core/task_manager.py index cde10bb0a..4b095044b 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -40,6 +40,7 @@ OkNteConfig, HSRConfig, MaaFWConfig, + BetterGIConfig, ) # 延迟加载 System,避免 app.services 初始化期间触发循环导入; @@ -407,6 +408,8 @@ async def _run_main_task(self): task_item = task.M9AManager(script_item) elif isinstance(script_config, HSRConfig): task_item = task.HSRManager(script_item) + elif isinstance(script_config, BetterGIConfig): + task_item = task.BetterGIManager(script_item) elif isinstance(script_config, MaaFWConfig): task_item = task.MaaFWEmbeddedManager(script_item) else: diff --git a/app/models/config.py b/app/models/config.py index e023f5ede..73e655904 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3218,6 +3218,214 @@ def getTags(self) -> str: return json.dumps(tags, ensure_ascii=False) +# BetterGI 一条龙内置配置组(按 BetterGI 默认顺序,与 tools/one_dragon.py 保持同步) +_BGI_BUILTIN_ONE_DRAGON_GROUPS = [ + "领取邮件", + "合成树脂", + "自动地脉花", + "自动秘境", + "自动首领讨伐", + "自动幽境危战", + "领取每日奖励", + "领取尘歌壶奖励", +] + +# 旧版「国际服服务器(Servers)」→ 新版「游戏资源(Resource)」的映射。 +# 用于加载旧配置时迁移(GlobalAccount=True 且 Servers 未知/「不切换服务器」时兜底为亚服)。 +_BGI_LEGACY_SERVERS_TO_RESOURCE = { + "Asia": "亚服", + "Europe": "欧服", + "America": "美服", + "TW,HK,MO": "港澳台服", +} + + +class BetterGIUserConfig(ConfigBase): + """BetterGI 用户配置(更好的原神)""" + + def __init__(self) -> None: + + ## Info ------------------------------------------------------------ + self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator()) + self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) + self.Info_Id = ConfigItem("Info", "Id", "") + self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator()) + self.Info_RemainedDay = ConfigItem( + "Info", "RemainedDay", -1, RangeValidator(-1, 9999) + ) + 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) + ) + ## 是否使用用户独立一条龙配置(借鉴通用脚本 IfUseMasConfig) + self.Info_IfUseMasConfig = ConfigItem( + "Info", "IfUseMasConfig", True, BoolValidator() + ) + + ## Task ------------------------------------------------------------ + ## BetterGI「一条龙」配置名,对应脚本一条龙页面中已保存的配置名称 + self.Task_OneDragonConfigName = ConfigItem( + "Task", "OneDragonConfigName", "" + ) + + ## OneDragon ------------------------------------------------------- + ## 一条龙要执行的内置配置组(按组名,默认全部 8 组开启) + self.OneDragon_Groups = ConfigItem( + "OneDragon", + "Groups", + list(_BGI_BUILTIN_ONE_DRAGON_GROUPS), + MultipleOptionsValidator(_BGI_BUILTIN_ONE_DRAGON_GROUPS), + ) + ## 领取奖励队伍(对应 BetterGI 一条龙的 DailyRewardPartyName,留空不覆盖) + self.OneDragon_DailyRewardPartyName = ConfigItem( + "OneDragon", "DailyRewardPartyName", "" + ) + ## 战斗队伍(对应 BetterGI 一条龙的通用 PartyName,留空不覆盖) + self.OneDragon_PartyName = ConfigItem("OneDragon", "PartyName", "") + ## 战斗策略(对应 BetterGI 一条龙的 AutoBossStrategyName,留空不覆盖) + ## 默认「根据队伍自动选择」为 BetterGI 内置策略名 + self.OneDragon_AutoBossStrategyName = ConfigItem( + "OneDragon", "AutoBossStrategyName", "" + ) + ## 是否管理自定义配置组(总开关;OFF 时沿 BetterGI 原生设置,自定义组原样保留) + self.OneDragon_IfUseCustomGroups = ConfigItem( + "OneDragon", "IfUseCustomGroups", False, BoolValidator() + ) + ## 自定义配置组列表:JSON 数组字符串,元素为 {"name": str, "enabled": bool} + self.OneDragon_CustomGroups = ConfigItem( + "OneDragon", "CustomGroups", "[]", JSONValidator(list) + ) + + ## Switch ---------------------------------------------------------- + ## 切换账号配置(BetterGI「切换账号多模式」脚本专项适配) + ## 账号/密码复用 Info.Id / Info.Password(密码经 EncryptValidator 加密) + ## 切换模式不再由用户配置,运行时按密码是否填写推断: + ## 填密码 → 「账号+密码+OCR」,未填 → 「下拉列表」;B服 强制「B服切换另一个账号匹配+键鼠」。 + ## 游戏服务器(账号所在服务器:官服/B服/国际服各服务器) + self.Switch_Resource = ConfigItem( + "Switch", + "Resource", + "官服", + OptionsValidator(["官服", "B服", "亚服", "欧服", "美服", "港澳台服"]), + ) + ## 账号 UID(可不填,切换前识别一致将不执行切换动作) + self.Switch_Uid = ConfigItem("Switch", "Uid", "") + + ## Data ------------------------------------------------------------ + self.Data_LastProxyDate = ConfigItem( + "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") + ) + self.Data_ProxyTimes = ConfigItem( + "Data", "ProxyTimes", 0, RangeValidator(0, 9999) + ) + self.Data_LastProxyStatus = ConfigItem( + "Data", + "LastProxyStatus", + "未知", + OptionsValidator(["未知", "成功", "失败"]), + ) + self.Data_LastOneDragonConfig = ConfigItem( + "Data", "LastOneDragonConfig", "" + ) + + ## 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__() + + async def load(self, data: dict) -> bool: + """加载配置前,把旧版「国际服账号 + 国际服服务器 / B服切换模式」迁移为「游戏服务器」。""" + normalized_data = deepcopy(data) if isinstance(data, dict) else {} + switch = normalized_data.get("Switch") + if isinstance(switch, dict) and "Resource" not in switch: + if switch.get("Modes") == "B服切换另一个账号匹配+键鼠": + # 旧版 B服 是切换模式,现作为游戏服务器 + switch["Resource"] = "B服" + elif switch.get("GlobalAccount"): + switch["Resource"] = _BGI_LEGACY_SERVERS_TO_RESOURCE.get( + switch.get("Servers"), "亚服" + ) + else: + switch["Resource"] = "官服" + return await super().load(normalized_data) + + def getTags(self) -> str: + tags = [] + + last_status = self.get("Data", "LastProxyStatus") + tags.append({"text": f"上次:{last_status}", "color": "green"}) + + config_name = self.get("Task", "OneDragonConfigName") or "未设置" + tags.append({"text": f"一条龙:{config_name}", "color": "orange"}) + + remained_day = self.get("Info", "RemainedDay") + if remained_day == -1: + tag_color = "gold" + elif remained_day == 0: + tag_color = "red" + elif remained_day <= 3: + tag_color = "orange" + elif remained_day <= 7: + tag_color = "yellow" + elif remained_day <= 30: + tag_color = "blue" + else: + tag_color = "green" + tags.append( + { + "text": ( + f"剩余天数:{remained_day}天" + if remained_day >= 0 + else "剩余天数:无期限" + ), + "color": tag_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 GeneralConfig(ConfigBase): """通用配置""" @@ -3507,6 +3715,46 @@ def __init__(self) -> None: super().__init__() +class BetterGIConfig(ConfigBase): + """BetterGI 配置(更好的原神,原生 GUI 直控 + 仅一条龙任务)""" + + def __init__(self) -> None: + + ## Info ------------------------------------------------------------ + self.Info_Name = ConfigItem("Info", "Name", "新 BetterGI 脚本") + self.Info_RootPath = ConfigItem("Info", "RootPath", "", FileValidator()) + + ## Run ------------------------------------------------------------- + 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) + ) + + ## Game ------------------------------------------------------------ + ## 控制器(游戏控制方式:电脑端-前台 / 电脑端-云原神 / 电脑端-桌面分身) + ## ⚠️ 预留字段:当前运行时不读取(BetterGI 自行管理游戏控制),云原神 / 桌面分身 + ## 尚未开发。为将来支持而保留占位并持久化,恒为默认「电脑端-前台」,勿被判定死代码误删。 + self.Game_Controller = ConfigItem( + "Game", + "Controller", + "电脑端-前台", + OptionsValidator(["电脑端-前台", "电脑端-云原神", "电脑端-桌面分身"]), + ) + ## 任务结束后关闭游戏 + self.Game_CloseOnFinish = ConfigItem( + "Game", "CloseOnFinish", True, BoolValidator() + ) + + self.UserData = MultipleConfig([BetterGIUserConfig]) + + super().__init__() + + class GameSignAccountGroup(ConfigBase): """游戏签到账号组配置""" @@ -3896,6 +4144,7 @@ def __init__(self): OkwwConfig, OkNteConfig, HSRConfig, + BetterGIConfig, ] ) ## 队列配置列表 @@ -4004,6 +4253,7 @@ def getStage(self) -> str: "Okww": OkwwConfig, "OkNte": OkNteConfig, "HSR": HSRConfig, + "BetterGI": BetterGIConfig, } """配置类映射表""" diff --git a/app/models/schema.py b/app/models/schema.py index 5965da35a..d57049c49 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -90,6 +90,19 @@ class ComboBoxOut(OutBase): data: List[ComboBoxItem] = Field(..., description="下拉框选项") +class BetterGICustomGroupOut(BaseModel): + """BetterGI 一条龙自定义配置组(非内置 8 组)""" + + name: str = Field(..., description="配置组名称") + enabled: bool = Field(..., description="启用状态") + + +class BetterGICustomGroupsOut(OutBase): + data: List[BetterGICustomGroupOut] = Field( + default_factory=list, description="一条龙自定义配置组列表" + ) + + class MaaEndOptionsOut(OutBase): controllers: List[ComboBoxItem] = Field(..., description="MaaEnd 控制器选项") controllerTypes: dict[str, str] = Field(..., description="控制器协议类型映射") @@ -449,6 +462,7 @@ class ScriptIndexItem(BaseModel): "M9AConfig", "MaaFWConfig", "HSRConfig", + "BetterGIConfig", ] = Field(..., description="配置类型") @@ -464,6 +478,7 @@ class UserIndexItem(BaseModel): "M9AUserConfig", "MaaFWUserConfig", "HSRUserConfig", + "BetterGIUserConfig", ] = Field(..., description="配置类型") @@ -791,6 +806,96 @@ class OkNteUserConfig(BaseModel): ) +class BetterGIUserConfig_Task(BaseModel): + OneDragonConfigName: Optional[str] = Field( + default=None, description="BetterGI「一条龙」配置名" + ) + + +class BetterGIUserConfig_Switch(BaseModel): + """BetterGI 切换账号配置(切换账号多模式脚本专项适配)""" + + Resource: Optional[str] = Field( + default=None, description="游戏服务器:官服/B服/亚服/欧服/美服/港澳台服" + ) + Uid: Optional[str] = Field( + default=None, description="账号 UID(可不填,切换前识别一致将不执行切换动作)" + ) + + +class BetterGIUserConfig_Info(BaseModel): + """BetterGI 用户信息(原生 GUI 直控,账号由 BetterGI 原生管理)""" + + Name: Optional[str] = Field(default=None, description="用户名") + Status: Optional[bool] = Field(default=None, description="用户状态") + Id: Optional[str] = Field(default=None, description="账号") + Password: Optional[str] = Field(default=None, description="密码") + RemainedDay: Optional[int] = 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字符串,TagItem的dict列表)" + ) + IfUseMasConfig: Optional[bool] = Field( + default=None, description="是否使用用户独立一条龙配置" + ) + + +class BetterGIUserConfig_OneDragon(BaseModel): + """BetterGI 一条龙配置""" + + Groups: Optional[List[str]] = Field( + default=None, description="一条龙要执行的内置配置组名列表" + ) + DailyRewardPartyName: Optional[str] = Field( + default=None, description="领取奖励队伍(对应一条龙 DailyRewardPartyName,留空不覆盖)" + ) + PartyName: Optional[str] = Field( + default=None, description="战斗队伍(对应一条龙通用 PartyName,留空不覆盖)" + ) + AutoBossStrategyName: Optional[str] = Field( + default=None, description="战斗策略(对应一条龙 AutoBossStrategyName,留空不覆盖)" + ) + IfUseCustomGroups: Optional[bool] = Field( + default=None, description="是否管理自定义配置组(总开关)" + ) + CustomGroups: Optional[Union[str, List]] = Field( + default=None, + description="自定义配置组 JSON 列表字符串,元素含 name/enabled", + ) + + +class BetterGIUserConfig_Data(GeneralUserConfig_Data): + """BetterGI 用户数据(复用通用字段)""" + + LastProxyStatus: Optional[str] = Field( + default=None, description="上次代理状态(未知/成功/失败)" + ) + LastOneDragonConfig: Optional[str] = Field( + default=None, description="上次运行的一条龙配置名" + ) + + +class BetterGIUserConfig_Notify(GeneralUserConfig_Notify): + """BetterGI 用户通知(复用通用字段)""" + + +class BetterGIUserConfig(BaseModel): + Info: Optional[BetterGIUserConfig_Info] = Field(default=None, description="用户信息") + Task: Optional[BetterGIUserConfig_Task] = Field(default=None, description="任务配置") + Switch: Optional[BetterGIUserConfig_Switch] = Field(default=None, description="切换账号配置") + OneDragon: Optional[BetterGIUserConfig_OneDragon] = Field(default=None, description="一条龙配置") + Data: Optional[BetterGIUserConfig_Data] = Field(default=None, description="用户数据") + Notify: Optional[BetterGIUserConfig_Notify] = Field(default=None, description="单独通知") + + class GeneralConfig_Info(BaseModel): Name: Optional[str] = Field(default=None, description="脚本名称") RootPath: Optional[str] = Field(default=None, description="脚本根目录") @@ -994,6 +1099,31 @@ class OkNteConfig(BaseModel): Run: Optional[OkNteConfig_Run] = Field(default=None, description="运行配置") +class BetterGIConfig_Info(GeneralConfig_Info): + """BetterGI 脚本基础信息(复用通用字段)""" + + +class BetterGIConfig_Run(GeneralConfig_Run): + """BetterGI 运行配置(复用通用字段)""" + + +class BetterGIConfig_Game(BaseModel): + """BetterGI 游戏配置""" + + Controller: Optional[str] = Field( + default=None, description="控制器:电脑端-前台/电脑端-云原神/电脑端-桌面分身" + ) + CloseOnFinish: Optional[bool] = Field( + default=None, description="任务结束后是否关闭游戏" + ) + + +class BetterGIConfig(BaseModel): + Info: Optional[BetterGIConfig_Info] = Field(default=None, description="脚本基础信息") + Run: Optional[BetterGIConfig_Run] = Field(default=None, description="运行配置") + Game: Optional[BetterGIConfig_Game] = Field(default=None, description="游戏配置") + + class MaaEndUserConfig_Info(BaseModel): Name: Optional[str] = Field(default=None, description="用户名") Status: Optional[bool] = Field(default=None, description="用户状态") @@ -2453,10 +2583,10 @@ class HistoryData(BaseModel): class ScriptCreateIn(BaseModel): type: Literal[ - "MAA", "SRC", "General", "Okww", "OkNte", "MaaEnd", "M9A", "MaaFW", "HSR" + "MAA", "SRC", "General", "Okww", "OkNte", "MaaEnd", "M9A", "MaaFW", "HSR", "BetterGI" ] = Field( ..., - description="脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本", + description="脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本, BetterGI脚本", ) scriptId: str | None = Field( default=None, description="直接从该脚本ID复制创建, 仅在复制创建时使用" @@ -2475,7 +2605,10 @@ class ScriptCreateOut(OutBase): M9AConfig, MaaFWConfig, HSRConfig, - ] = Field(..., description="脚本配置数据") + BetterGIConfig, + ] = Field( + ..., description="脚本配置数据" + ) class ScriptGetIn(BaseModel): @@ -2498,6 +2631,7 @@ class ScriptGetOut(OutBase): M9AConfig, MaaFWConfig, HSRConfig, + BetterGIConfig, ], ] = Field(..., description="脚本数据字典, key来自于index列表的uid") @@ -2514,7 +2648,10 @@ class ScriptUpdateIn(BaseModel): M9AConfig, MaaFWConfig, HSRConfig, - ] = Field(..., description="脚本更新数据") + BetterGIConfig, + ] = Field( + ..., description="脚本更新数据" + ) class ScriptDeleteIn(BaseModel): @@ -2572,6 +2709,7 @@ class UserGetOut(OutBase): M9AUserConfig, MaaFWUserConfig, HSRUserConfig, + BetterGIUserConfig, ], ] = Field(..., description="用户数据字典, key来自于index列表的uid") @@ -2588,6 +2726,7 @@ class UserCreateOut(OutBase): M9AUserConfig, MaaFWUserConfig, HSRUserConfig, + BetterGIUserConfig, ] = Field(..., description="用户配置数据") @@ -2603,6 +2742,7 @@ class UserUpdateIn(UserInBase): M9AUserConfig, MaaFWUserConfig, HSRUserConfig, + BetterGIUserConfig, ] = Field(..., description="用户更新数据") diff --git a/app/services/notification.py b/app/services/notification.py index e984cef2f..36fa4e6df 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -38,7 +38,7 @@ from plyer import notification from app.models.config import Webhook -from app.utils import LazyProxy, get_logger +from app.utils import LazyProxy, get_logger, ImageUtils from app.utils.constants import UTC4 logger = get_logger("通知服务") diff --git a/app/services/update.py b/app/services/update.py index e51027272..3e86a250a 100644 --- a/app/services/update.py +++ b/app/services/update.py @@ -51,7 +51,6 @@ # 延迟加载 Config,避免 app.services 初始化期间触发 app.core 循环导入 Config = LazyProxy("app.core", "Config") - @dataclass(frozen=True) class _DownloadJob: """下载任务启动时冻结的版本、来源与 URL。""" @@ -61,7 +60,6 @@ class _DownloadJob: mirror_chyan_download_url: Optional[str] download_url: Optional[str] - class _UpdateHandler: def __init__(self) -> None: self.is_locked: bool = False diff --git a/app/task/BetterGI/AutoProxy.py b/app/task/BetterGI/AutoProxy.py new file mode 100644 index 000000000..ba0ddf362 --- /dev/null +++ b/app/task/BetterGI/AutoProxy.py @@ -0,0 +1,893 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +import asyncio +import re +import uuid +from contextlib import suppress +from datetime import datetime, timedelta +from pathlib import Path + +from app.core import Config +from app.models.task import TaskExecuteBase, ScriptItem, UserItem, LogRecord +from app.models.ConfigBase import MultipleConfig +from app.models.config import BetterGIConfig, BetterGIUserConfig +from app.services import Notify, System +from app.utils import get_logger, ProcessManager, ProcessInfo, ProcessRunner +from app.utils.LogMonitor import LogMonitor +from app.utils.constants import UTC4 +from app.task.general.tools import execute_script_task + +from .tools import push_notification +from .tools import account_switch +from .tools import one_dragon +from .tools.one_dragon_report import _parse_one_dragon_report + +logger = get_logger("BetterGI 自动代理") + +# BetterGI 项目结构固定相对路径(从 RootPath 派生,不依赖用户存储值) +# ⚠️ 与前端 BetterGIScriptEdit.vue 的 BGI_EXE_NAME 保持同步,改这里时需同步改前端 +_BGI_REL_EXE = "BetterGI.exe" +_BGI_TRACK_PROCESS_NAME = "BetterGI.exe" +# BetterGI 的 Serilog 日志按天滚动,实际文件名为 better-genshin-impact{yyyyMMdd}.log +# (不存在无日期后缀的 better-genshin-impact.log) +_BGI_REL_LOG_DIR = "log" +_BGI_LOG_FILE_PREFIX = "better-genshin-impact" + +# ── BetterGI 专项硬编码(不存 ConfigItem,随 MAS 版本同步)────────────── +# BetterGI 使用 Serilog 文件日志,行格式: +# [{HH:mm:ss.fff}] [{Level:u3}] [{BgiInstance}] {SourceContext}\n{Message} +# 成功/失败判定取自 BetterGI 的统一日志片段,BetterGI 不向用户暴露关键词配置。 +# +# 进程级致命只有下面两种,会直接判异常: +# [FTL] —— BetterGI 自己的 Fatal 级别,出现即进程级崩溃; +# 「任务启动失败」—— 任务锁被占用(多半残留进程占锁),单条被跳过但整条可信度存疑。 +# ⚠️ 不得把 [ERR] 放进此表:它是 TaskRunner.Run 在每个【子任务】的 catch(Exception) 里打印的 +# 「任务级」异常(TaskRunner.cs 的 Run 包裹每条任务/配置组,捕获后不 rethrow,一条龙继续跑 +# 下一条)。直接 BGI 中这是可恢复的“跳过该步继续跑”,一条龙仍会正常走完收尾行;若 MAS 按 +# [ERR] 判 fatal,会把本可直接跑完的一条龙中途强杀(本次已实际复现)。真正跑不动由「收尾行 +# 缺失 + 进程提前退出 / 卡死 / 超时」兜底(见 check_log)。 +_BGI_BUILTIN_FATAL: tuple[tuple[str, str], ...] = ( + ("[FTL]", "BetterGI 出现致命错误"), + ("任务启动失败", "BetterGI 任务启动失败"), +) +# 唯一权威收尾是 OneDragonFlowViewModel.RunThreadAsync 在全部任务 + 配置组任务 + +# CheckRewardsTask 完成后打印的固定行「一条龙和配置组任务结束」(仅正常完成路径,取消/异常 +# 分支会在其前 return,不打印该行)。run 中即使有杂散 [ERR],只要最终走到收尾行,即证明各步 +# 异常均可恢复,按成功处理。命中条件见 _one_dragon_sequence_done。 +_BGI_SEQUENCE_DONE_MARKER = "一条龙和配置组任务结束" +# 出现过 [ERR] 后、若连续这么久没有任何新日志行(BGI 既未完成也未退出),判定卡死提前失败。 +# 仅在出错后静默触发,正常推进时 latest_time 会被新行持续刷新、不会误触。 +_BGI_ERR_STALL_MINUTES = 5 +_BGI_LOG_TIME_START = 1 +_BGI_LOG_TIME_END = 13 +_BGI_LOG_TIME_FORMAT = "%H:%M:%S.%f" + +# 切换账号单独执行的超时(秒),超时视为失败并继续一条龙 +_BGI_SWITCH_TIMEOUT_SECONDS = 600 + +# BetterGI 管理的原神游戏进程名(不含 .exe),与 BetterGI 源码 +# TaskContext.GetGenshinGameProcessNameList() 保持一致;任务结束后按此顺序逐一尝试关闭。 +_BGI_GAME_PROCESS_NAMES: tuple[str, ...] = ( + "YuanShen", # 官服 / B服(国服) + "GenshinImpact", # 国际服 + "Genshin Impact Cloud Game", # 云原神(国际) + "Genshin Impact Cloud", # 云原神(备用进程名) +) +# 优雅关闭游戏后等待退出时间(秒),超时未退出则强制结束 +_BGI_GAME_CLOSE_WAIT_SECONDS = 5 + + +def _one_dragon_sequence_done(log: str) -> bool: + """判定整条一条龙序列是否完成。 + + 以 BetterGI 唯一权威收尾行为准:``一条龙和配置组任务结束``。它只在全部任务 + + 配置组任务 + CheckRewardsTask 都完成后打印;任何子任务(含切换账号配置组)边界的 + 「→ 任务结束」或「配置组任务执行: X/Y」都不代表整条完成,不得据此判成功。 + + Args: + log: 本次运行的累计日志文本。 + + Returns: + True 表示整条一条龙已完成。 + """ + return _BGI_SEQUENCE_DONE_MARKER in log + + +# 脚本仓库更新/下载进展消息(去重展示用)。BGI 把它打在不带方括号前缀的消息行。 +# 这些行若能转述给用户,切号/一条龙启动时「正在下载脚本」就不会被误认为卡死。 +_REPO_PROGRESS_CATEGORY = ( + "浅克隆仓库", + "拉取对象", + "开始静默更新脚本仓库", + "自动更新订阅脚本完成", + "本地仓库已是最新", +) + + +def _latest_repo_progress(log: str) -> str | None: + """从累计日志中提取最近一条值得转述的脚本仓库下载/更新进展行。 + + Serilog 每行消息在带 ``[HH:mm:ss]`` 前缀的头行之后另起一行,这里只匹配消息行。 + 按时间从后往前找,命中即返回相干文案;无进展(或不在下载/更新阶段)返回 None。 + """ + for ln in reversed(log.splitlines()): + ln = ln.strip() + if not ln or ln.startswith("["): + continue + if "浅克隆仓库" in ln: + return "正在从脚本仓库下载脚本(首次克隆/仓库冷启动,可能耗时较长,请耐心等待)..." + if "拉取对象" in ln: + return "正在向脚本仓库拉取 git 对象..." + if "开始静默更新脚本仓库" in ln: + return "正在静默更新脚本仓库..." + if "自动更新订阅脚本完成" in ln: + return f"脚本仓库更新完成: {ln}" + if "本地仓库已是最新" in ln: + return "脚本仓库已是最新,无需下载" + return None + + +def _is_switch_script_updated(log: str) -> bool: + """切号脚本是否已在本次日志中被检出。""" + return '更新脚本成功: "js/SwitchAccountMultipleMode"' in log + + +# ── 切队配置错误识别 ───────────────────────────────── +# 一条龙里的战斗队伍(PartyName)若在游戏内置找不到,BGI 切队会把整条一龙任务打崩: +# - OCR 扫描不到名单:SwitchPartyTask 打「未找到队伍: <名>,返回主界面」; +# - 直接抛异常:SwitchPartyTask.Start 第202行 Enumerable.Last() 取不到匹配项 → +# InvalidOperationException "Sequence contains no elements" → 自动地脉花等任务打 [ERR]。 +# 两者都说明配置的战斗队伍名不合法。命中时给明确报错(指明队伍名),而非笼统的 +# 「任务执行异常」/「完成任务前退出」。 +_BGI_PARTY_SWITCH_RE = re.compile( + r'尝试切换至队伍:\s*["“]?([^"”\n]+)["”]?\s*$', re.M +) +_BGI_PARTY_ERROR_HINTS = ("未找到队伍", "Sequence contains no elements") + + +def _party_config_error(log: str) -> str | None: + """检测切队配置错误(战斗队伍名在游戏内置找不到),返回出错队伍名;无则 None。""" + if not ("尝试切换至队伍" in log and any(h in log for h in _BGI_PARTY_ERROR_HINTS)): + return None + m = _BGI_PARTY_SWITCH_RE.search(log) + if not m: + return None + name = m.group(1).strip() + return name or None + + +class AutoProxyTask(TaskExecuteBase): + """BetterGI 自动代理:拼 `startOneDragon ` 启动并监控日志""" + + def __init__( + self, + script_info: ScriptItem, + script_config: BetterGIConfig, + user_config: MultipleConfig[BetterGIUserConfig], + ): + 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.cur_user_item: UserItem = 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: BetterGIUserConfig = self.user_config[self.cur_user_uid] + self.use_mas_config = bool(self.cur_user_config.get("Info", "IfUseMasConfig")) + self.cur_user_log: LogRecord | None = None + self.bettergi_process_manager: ProcessManager | None = None + self.wait_event: asyncio.Event | None = None + self.script_root_path: Path | None = None + self.script_exe_path: Path | None = None + self.script_target_process_info: ProcessInfo | None = None + self.script_log_path: Path | None = None + self.log_monitor: LogMonitor | None = None + # 切队配置错误报错只推送一次,避免每个日志回调重复刷屏 + self._party_err_pushed = False + + async def check(self) -> str: + root = Path(self.script_config.get("Info", "RootPath")) + if not root.is_dir(): + return "请设置 BetterGI 脚本路径" + if not (root / _BGI_REL_EXE).is_file(): + return "请设置 BetterGI 脚本路径" + + if ( + self.script_config.get("Run", "ProxyTimesLimit") != 0 + and self.cur_user_config.get("Data", "ProxyTimes") + >= self.script_config.get("Run", "ProxyTimesLimit") + ): + self.cur_user_item.status = "跳过" + return "今日代理次数已达上限, 跳过该用户" + if self.cur_user_config.get("Info", "RemainedDay") == 0: + self.cur_user_item.status = "跳过" + return "用户剩余天数为 0, 跳过该用户" + + return "Pass" + + async def prepare(self): + self.bettergi_process_manager = ProcessManager() + self.wait_event = asyncio.Event() + + self.user_start_time = datetime.now() + self.log_start_time = datetime.now() + + # ── 所有 Script 路径从 RootPath 实时派生,不依赖 ConfigItem 存储值 ── + self.script_root_path = Path(self.script_config.get("Info", "RootPath")) + self.script_exe_path = self.script_root_path / _BGI_REL_EXE + + self.script_target_process_info = ProcessInfo( + name=_BGI_TRACK_PROCESS_NAME, + exe=str(self.script_exe_path), + cmdline=None, + ) + + self.script_log_path = self._build_log_path() + + self.log_time_range = (_BGI_LOG_TIME_START, _BGI_LOG_TIME_END) + self.log_time_format = _BGI_LOG_TIME_FORMAT + self.log_monitor = LogMonitor( + self.log_time_range, + self.log_time_format, + self.check_log, + ) + + self.one_dragon_config = one_dragon.resolve_config_name( + str(self.cur_user_config.get("Task", "OneDragonConfigName") or "") + ) + self.one_dragon_groups = list( + self.cur_user_config.get("OneDragon", "Groups") or [] + ) + self.use_custom_groups = bool( + self.cur_user_config.get("OneDragon", "IfUseCustomGroups") + ) + self.one_dragon_custom_groups = one_dragon.parse_custom_groups( + self.cur_user_config.get("OneDragon", "CustomGroups") or "" + ) + # 「用户独立配置」开启时,per-user 配置落到 MAS 专属槽位并据此启动,BGI 同名的 + # 用户实配全程零接触。若用户恰好把配置命名为槽位名,退化为直连(防自伤)。 + self.use_mas_launch_slot = ( + self.use_mas_config + and one_dragon.launch_slot_name() != self.one_dragon_config + ) + self.launch_config_name = ( + one_dragon.launch_slot_name() + if self.use_mas_launch_slot + else self.one_dragon_config + ) + self.bettergi_args = ["startOneDragon", self.launch_config_name] + + self.run_book = False + + # 通用战斗队伍/策略落到全局 config.json 前的叶子快照,None 表示尚未接管 + self._reseed_global_config: dict | None = None + + def _build_log_path(self) -> Path: + """构造 BetterGI 当日滚动日志路径(better-genshin-impact{yyyyMMdd}.log)。""" + return ( + self.script_root_path + / _BGI_REL_LOG_DIR + / f"{_BGI_LOG_FILE_PREFIX}{datetime.now():%Y%m%d}.log" + ) + + async def _push_dispatch_log(self, line: str) -> None: + """向调度台追加流程日志(赋值 script_info.log 会触发 WebSocket 推送)。""" + + prev = self.script_info.log + self.script_info.log = f"{prev}\n{line}" if prev else line + await asyncio.sleep(0) + + def _write_one_dragon_config(self) -> None: + """用户独立配置模式下,把组开关应用到一条龙配置并写回 BetterGI。""" + if not self.use_mas_config: + return + party_name = str( + self.cur_user_config.get("OneDragon", "PartyName") or "" + ) + one_dragon.write_user_one_dragon( + self.script_root_path, + self.script_info.script_id, + self.cur_user_item.user_id, + self.one_dragon_config, + self.one_dragon_groups, + daily_reward_party_name=str( + self.cur_user_config.get("OneDragon", "DailyRewardPartyName") or "" + ), + party_name=party_name, + auto_boss_strategy_name=str( + self.cur_user_config.get("OneDragon", "AutoBossStrategyName") or "" + ), + custom_groups=self.one_dragon_custom_groups, + manage_custom_groups=self.use_custom_groups, + ) + # 通用战斗队伍/策略补写进全局 config.json(秘境/地脉花/幽境危战读取段) + one_dragon.apply_global_battle_team(self.script_root_path, party_name) + one_dragon.apply_global_battle_strategy( + self.script_root_path, + str(self.cur_user_config.get("OneDragon", "AutoBossStrategyName") or ""), + ) + logger.info( + f"已写入用户 {self.cur_user_item.name} 的一条龙配置: {self.one_dragon_config}" + ) + + def _snapshot_one_dragon_config(self) -> None: + """把 BetterGI 现有的一条龙配置回读为 per-user 副本(捕获 GUI 中改的设置)。 + + 独立模式下读取源是 MAS 槽位 ``launch_config_name``(用户在 BGI GUI 里编辑的就是这份), + 缓存 key 仍是用户所选名 ``one_dragon_config``,供下一轮 ``write_user_one_dragon`` 种子。 + """ + if not self.use_mas_config: + return + one_dragon.snapshot_user_one_dragon( + self.script_root_path, + self.script_info.script_id, + self.cur_user_item.user_id, + self.one_dragon_config, + read_name=self.launch_config_name, + ) + + def _backup_one_dragon_config(self) -> None: + """运行前快照乐观覆盖的全局 config.json 队伍/策略叶子,供结束后还原。 + + 独立配置模式下 per-user 配置落到 MAS 专属槽位,不写 BGI 同名实配;BGI 那套 + 一条龙文件无需备份。全局 config.json 的队伍/策略叶子(地脉花/幽境危战/秘境读段) + 仍需运行时临时补写、结束还原,故这里先快照。 + """ + if not self.use_mas_config: + return + self._reseed_global_config = one_dragon.snapshot_global_battle_config( + self.script_root_path + ) + + def _restore_one_dragon_config(self) -> None: + """运行/异常结束后还原全局 config.json 队伍/策略叶子,并删除 MAS 运行时槽位。 + + 仅在本次确接管过(``_reseed_global_config`` 非 None)时生效;还原一次后置 None + 保证幂等,避免 final_task 与 on_crash 相继触发时重复覆盖。槽位文件在 ``finally`` + 中无条件删除(幂等),使 BGI GUI 不残留 MAS 运行时配置。 + """ + if self._reseed_global_config is None: + return + try: + # 还原全局 config.json 队伍/策略叶子字段(秘境/地脉花/幽境危战读取段) + one_dragon.restore_global_battle_config( + self.script_root_path, self._reseed_global_config + ) + finally: + one_dragon.remove_one_dragon_slot(self.script_root_path) + self._reseed_global_config = None + + async def main_task(self): + await self.prepare() + 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.cur_user_item.status = "运行" + + # 切换账号(单独执行 --startGroups,先于一条龙) + if not await self._switch_account(): + self.cur_user_item.status = "异常" + self.script_info.log = "切换账号失败,已中止任务" + logger.error(f"用户 {self.cur_user_item.name} 切换账号失败,中止任务") + return + + # 用户独立配置:先备份现场再写入,结束后 (final_task/on_crash) 还原 + self._backup_one_dragon_config() + self._write_one_dragon_config() + + run_limit = int(self.script_config.get("Run", "RunTimesLimit")) + for i in range(run_limit): + if self.run_book: + break + logger.info( + f"用户 {self.cur_user_item.name} - 尝试次数: {i + 1}/{run_limit}" + ) + self.cur_user_item.status = "运行" + self.log_start_time = datetime.now() + self.cur_user_item.log_record[self.log_start_time] = LogRecord() + self.cur_user_log = self.cur_user_item.log_record[self.log_start_time] + self.script_info.log = "" + + if self.cur_user_config.get("Info", "IfScriptBeforeTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptBeforeTask")), + "脚本前任务", + ) + + await self._push_dispatch_log( + f"启动 BetterGI: startOneDragon {self.launch_config_name}" + ) + logger.info( + f"启动 BetterGI 进程: {self.script_exe_path} " + f"{' '.join(self.bettergi_args)}" + ) + + await self.bettergi_process_manager.open_process( + self.script_exe_path, + *self.bettergi_args, + target_process=self.script_target_process_info, + elevated=True, + ) + + # 启动日志监控(文件日志) + await asyncio.sleep(1) + await self.log_monitor.start_monitor_file( + self.script_log_path, self.log_start_time + ) + + self.wait_event.clear() + await self.wait_event.wait() + await self.log_monitor.stop() + + if self.cur_user_log.status == "Success!": + self.run_book = True + self.script_info.log = ( + "检测到 BetterGI 已完成任务\n正在等待 BetterGI 自行退出" + ) + if self.cur_user_config.get("Info", "IfScriptAfterTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) + await asyncio.sleep(3) + break + + logger.warning( + f"用户 {self.cur_user_item.name} - BetterGI 代理异常: " + f"{self.cur_user_log.status}" + ) + self.script_info.log = ( + f"{self.cur_user_log.status}\n正在中止相关程序" + ) + await self.kill_managed_process() + try: + await Notify.push_plyer( + "BetterGI 自动代理出现异常!", + f"用户 {self.cur_user_item.name} 的自动代理出现一次异常", + f"{self.cur_user_item.name}的自动代理出现异常", + 3, + ) + except Exception: + pass + if self.cur_user_config.get("Info", "IfScriptAfterTask"): + await execute_script_task( + Path(self.cur_user_config.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) + if i + 1 < run_limit: + self.script_info.log += ( + f"\n将在稍后重试 ({i + 1}/{run_limit})" + ) + await asyncio.sleep(10) + + async def _switch_account(self) -> bool: + """单独执行一次切号(--startGroups),返回是否切换成功。 + + 未配置账号时直接返回 True(无需切换);失败/超时返回 False, + 由调用方决定是否继续执行一条龙。 + """ + account = str(self.cur_user_config.get("Info", "Id") or "").strip() + if not account: + return True + + resource = str(self.cur_user_config.get("Switch", "Resource") or "官服").strip() + uid = str(self.cur_user_config.get("Switch", "Uid") or "").strip() + password = str(self.cur_user_config.get("Info", "Password") or "") + + # 切换模式不再单独配置,按密码是否填写推断: + # 填密码 → 「账号+密码+OCR」,未填 → 「下拉列表」。 + # B服 无下拉/OCR 方式,由 resolve_switch_settings 强制走「B服切换另一个账号匹配+键鼠」。 + mode = "账号+密码+OCR" if password else "下拉列表" + global_account, servers, mode = account_switch.resolve_switch_settings( + resource, mode + ) + + # 1. 订阅脚本仓库(BetterGI 自行拉取/更新切换账号脚本)+ 生成配置组 + try: + script_present = account_switch.ensure_switch_subscription( + self.script_root_path + ) + account_switch.write_switch_group( + self.script_root_path, + account, + password, + mode, + global_account, + servers, + uid, + ) + except Exception as e: + logger.opt(exception=True).warning(f"切换账号准备失败: {e}") + await self._push_dispatch_log(f"切换账号准备失败: {e}") + # write_switch_group 可能已写入明文凭据后抛异常,失败路径同样脱敏,避免明文残留磁盘 + with suppress(Exception): + account_switch.scrub_switch_group(self.script_root_path) + return False + + # 更新情况:BGI 启动时先更新仓库脚本、再执行配置组;本地已有脚本则本次是增量检查。 + # 缺失(用户误删/初次使用)时 ensure_switch_subscription 已强制重建仓库,BGI 启动即补位。 + if script_present: + logger.info("切换账号脚本已存在于本地,BGI 启动时检查仓库更新") + await self._push_dispatch_log("切换账号脚本已就绪,随 BGI 启动检查仓库更新") + else: + logger.info("切换账号脚本本地缺失,已强制 BGI 启动时从脚本仓库重新检出") + await self._push_dispatch_log( + "切换账号脚本缺失,已重新订阅并由 BGI 启动时重新下载(若网络较慢请耐心等待)" + ) + + await self._push_dispatch_log( + f"开始切换账号: --startGroups {account_switch._GROUP_NAME}" + ) + logger.info( + f"用户 {self.cur_user_item.name} 启动 BetterGI 切换账号: " + f"{self.script_exe_path} --startGroups {account_switch._GROUP_NAME}" + ) + + # 2. 杀旧进程,保证单实例下 --startGroups 由新进程执行 + await self.kill_managed_process() + + switch_success = asyncio.Event() + switch_result = {"success": False, "started": False} + # 已转述过到调度台的仓库进展(每个文案只推一次,避免刷屏) + repo_progress_reported: set[str] = set() + + # 单组 --startGroups 的成功/失败判定取自 BetterGI 配置组日志: + # 成功: 配置组 "MAS切换账号" 执行结束 + # 失败: 执行配置组任务时失败 / 任务启动失败 / 任务执行异常 / [FTL] / [ERR] + switch_group_done = f'配置组 "{account_switch._GROUP_NAME}" 执行结束' + # 单组 --startGroups 场景下 [ERR] 即该配置组执行失败(无后续组可续跑),与一条龙判定中 + # [ERR] 是「任务级可恢复、跳过继续跑」的语义不同,故此处按失败处理。 + switch_group_fail = ( + "执行配置组任务时失败", + "任务启动失败", + "任务执行异常", + "[FTL]", + "[ERR]", + ) + + async def on_switch_log( + log_content: list[str], latest_time: datetime + ) -> None: + log = "".join(log_content) + + # 转述 BGI 脚本仓库的下载/更新进展,避免下载阶段长时间无动静被误认为卡死 + if prog := _latest_repo_progress(log): + if prog not in repo_progress_reported: + repo_progress_reported.add(prog) + await self._push_dispatch_log(prog) + if _is_switch_script_updated(log): + if "切换脚本已检出" not in repo_progress_reported: + repo_progress_reported.add("切换脚本已检出") + await self._push_dispatch_log( + "切号脚本已从仓库检出: SwitchAccountMultipleMode" + ) + + if switch_group_done in log: + switch_result["success"] = True + switch_success.set() + elif any(n in log for n in switch_group_fail): + switch_result["success"] = False + switch_success.set() + elif ( + switch_result["started"] + and not await self.bettergi_process_manager.is_running() + ): + # 进程已启动(search_process 确认过)后又在任务完成前退出 + switch_success.set() + + switch_monitor = LogMonitor( + self.log_time_range, self.log_time_format, on_switch_log + ) + + try: + await self.bettergi_process_manager.open_process( + self.script_exe_path, + "--startGroups", + account_switch._GROUP_NAME, + target_process=self.script_target_process_info, + elevated=True, + ) + # open_process 内部 search_process 已确认目标进程存在,之后退出才算失败 + switch_result["started"] = True + await asyncio.sleep(1) + await switch_monitor.start_monitor_file( + self.script_log_path, datetime.now() + ) + + try: + await asyncio.wait_for( + switch_success.wait(), timeout=_BGI_SWITCH_TIMEOUT_SECONDS + ) + except asyncio.TimeoutError: + switch_result["success"] = False + logger.warning(f"用户 {self.cur_user_item.name} 切换账号超时") + except Exception as e: + logger.opt(exception=True).warning(f"切换账号执行异常: {e}") + switch_result["success"] = False + finally: + await switch_monitor.stop() + await self.kill_managed_process() + # 切号结束即脱敏配置组,避免明文账号/密码残留磁盘 + with suppress(Exception): + account_switch.scrub_switch_group(self.script_root_path) + + if switch_result["success"]: + await self._push_dispatch_log("切换账号完成") + logger.success(f"用户 {self.cur_user_item.name} 切换账号完成") + else: + await self._push_dispatch_log("切换账号失败或超时,已中止任务") + logger.warning(f"用户 {self.cur_user_item.name} 切换账号失败或超时,已中止任务") + return switch_result["success"] + + async def check_log(self, log_content: list[str], latest_time: datetime) -> None: + """按内置日志判定结果,未见成功日志便退出则视为异常。""" + log = "".join(log_content) + self.cur_user_log.content = log_content + self.script_info.log = log[-4000:] if len(log) > 4000 else log + + log_status = "BetterGI 正常运行中" + user_item_status: str | None = None + + # 切队配置错误(战斗队伍名在游戏内置找不到)优先于笼统的 [ERR] 判定, + # 并给一次指明队伍名的明确报错 + if party_err := _party_config_error(log): + log_status = ( + f"切队失败/配置错误: 战斗队伍「{party_err}」在游戏内队伍列表中未找到" + ) + user_item_status = "异常" + if not self._party_err_pushed: + self._party_err_pushed = True + await self._push_dispatch_log( + f"BetterGI 运行异常:战斗队伍「{party_err}」在游戏内未找到," + "请核对 MAS 里该用户的「战斗队伍」配置" + ) + else: + for needle, msg in _BGI_BUILTIN_FATAL: + if needle in log: + log_status = msg + user_item_status = "异常" + break + # 仅在未命中进程级致命日志时判定成功/提前退出/卡死/超时(for…else) + else: + if _one_dragon_sequence_done(log): + log_status = "Success!" + user_item_status = "完成" + elif not await self.bettergi_process_manager.is_running(): + log_status = "BetterGI 在完成任务前退出" + user_item_status = "异常" + elif ( + "[ERR]" in log + and datetime.now() - latest_time + > timedelta(minutes=_BGI_ERR_STALL_MINUTES) + ): + # [ERR] 后长时间无任何新日志行:BGI 既没走完收尾、也没继续推进也没退出, + # 判定卡死提前失败(不等 RunTimeLimit)。仍在新行推进则不触发。 + log_status = ( + f"BetterGI 出现 [ERR] 后 {_BGI_ERR_STALL_MINUTES} " + "分钟无进展(疑似卡死)" + ) + user_item_status = "异常" + elif datetime.now() - latest_time > timedelta( + minutes=self.script_config.get("Run", "RunTimeLimit") + ): + log_status = "BetterGI 运行超时" + user_item_status = "异常" + + self.cur_user_log.status = log_status + if user_item_status is not None: + self.cur_user_item.status = user_item_status + + logger.debug(f"BetterGI 日志分析结果: {self.cur_user_log.status}") + if self.cur_user_log.status != "BetterGI 正常运行中": + logger.info(f"BetterGI 任务结果: {self.cur_user_log.status}, 日志锁已释放") + self.wait_event.set() + + async def final_task(self): + # 结束时先清理进程与监控 + if self.log_monitor is not None: + with suppress(Exception): + await self.log_monitor.stop() + await self.kill_managed_process() + + # 任务结束后关闭原神游戏进程(Game.CloseOnFinish) + await self._close_game() + + # 写入历史记录(对齐 General/SRC/MaaEnd/Okww 行为) + statistic_paths: list[Path] = [] + for t, log_item in self.cur_user_item.log_record.items(): + dt = t.replace(tzinfo=datetime.now().astimezone().tzinfo).astimezone(UTC4) + log_path = Config.build_history_log_path( + script_name=self.script_info.name, + user_name=self.cur_user_item.name, + log_time=dt, + ) + + if log_item.status == "BetterGI 正常运行中": + log_item.status = "任务被用户手动中止" + + if len(log_item.content) == 0: + log_item.content = ["未捕获到任何日志内容"] + log_item.status = "未捕获到日志" + + await Config.save_general_log(log_path, log_item.content, log_item.status) + statistic_paths.append(log_path.with_suffix(".json")) + + # 一条龙分步执行报告:按执行顺序列出每步做了什么、成败与经过(供统计通知/邮件模板)。 + # 无一条龙任务(仅配置组/未捕获到日志)时自动省略该区块。 + combined_log = "".join( + ln for item in self.cur_user_item.log_record.values() for ln in item.content + ) + one_dragon_report = _parse_one_dragon_report(combined_log) + + if statistic_paths: + try: + statistics = await Config.merge_statistic_info(statistic_paths) + if one_dragon_report: + statistics["one_dragon_steps"] = one_dragon_report + statistics["user_info"] = self.cur_user_item.name + start_time = getattr(self, "user_start_time", datetime.now()) + statistics["start_time"] = 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 + ) + success_symbol = "√" if self.run_book else "X" + await push_notification( + "统计信息", + f"{datetime.now().strftime('%m-%d')} |{success_symbol}| " + f"{self.cur_user_item.name} 的 BetterGI 自动代理统计报告", + statistics, + self.cur_user_config, + ) + except Exception as e: + # 失败不再静默:既记 ERROR 日志,也推到调度台实时日志,让用户能看到推送为何失败 + await self._push_dispatch_log(f"推送用户统计通知失败: {e}") + logger.opt(exception=True).error( + f"推送 BetterGI 用户统计通知时出现异常: {e}" + ) + + await self._persist_user_run_result() + + # 用户独立配置:回读 BetterGI 现有配置,捕获运行中/GUI 里改的设置,固化到 per-user 副本 + self._snapshot_one_dragon_config() + + # 快照已完成,再把现场还原为覆盖前的副本,避免污染其它用户 + self._restore_one_dragon_config() + + async def _persist_user_run_result(self) -> None: + if self.cur_user_config is None: + return + + await self.cur_user_config.set( + "Data", "LastOneDragonConfig", getattr(self, "one_dragon_config", "") + ) + 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, + ) + await self.cur_user_config.set("Data", "LastProxyStatus", "成功") + self.cur_user_item.status = "完成" + logger.success(f"用户 {self.cur_user_uid} 的 BetterGI 自动代理任务已完成") + else: + await self.cur_user_config.set("Data", "LastProxyStatus", "失败") + if self.cur_user_item.status != "完成": + self.cur_user_item.status = "异常" + + async def on_crash(self, e: Exception): + self.cur_user_item.status = "异常" + if self.cur_user_log is not None: + self.cur_user_log.status = f"BetterGI 运行异常: {e}" + logger.opt(exception=True).warning(f"BetterGI 自动代理任务出现异常: {e}") + if self.wait_event is not None: + self.wait_event.set() + with suppress(Exception): + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"BetterGI 自动代理任务出现异常: {e}"}, + ) + with suppress(Exception): + await self.kill_managed_process() + with suppress(Exception): + await self._persist_user_run_result() + + # 异常退出也要还原 BetterGI 现场(切号失败/中途崩溃不得污染原配置) + try: + self._restore_one_dragon_config() + except Exception as e: + logger.opt(exception=True).warning( + f"异常退出后恢复 BetterGI 一条龙配置失败: {e}" + ) + + # 推送通知(复用 Notify) + try: + if ( + self.cur_user_log is not None + and self.cur_user_log.status + and self.cur_user_log.status != "Success!" + ): + await Notify.push_plyer( + "BetterGI 运行异常", + f"用户 {self.cur_user_item.name}:{self.cur_user_log.status}", + "异常", + 3, + ) + except Exception: + pass + + async def _close_game(self) -> None: + """任务结束后关闭原神游戏进程。 + + 按进程名逐一尝试:先优雅关闭(发送 WM_CLOSE),等待短暂时间后 + 再强制结束残留进程,覆盖官服/B服/国际服/云原神等客户端。 + """ + if not self.script_config.get("Game", "CloseOnFinish"): + return + + await self._push_dispatch_log("任务结束,正在关闭游戏进程") + for name in _BGI_GAME_PROCESS_NAMES: + image = f"{name}.exe" + try: + # 先优雅关闭(taskkill 不带 /F 会向 GUI 窗口发送 WM_CLOSE) + graceful = await ProcessRunner.run_process( + "taskkill", "/IM", image, "/T" + ) + if graceful.returncode == 0: + await asyncio.sleep(_BGI_GAME_CLOSE_WAIT_SECONDS) + # 再强制结束仍残留的进程(含子进程) + await ProcessRunner.run_process("taskkill", "/IM", image, "/F", "/T") + except Exception as e: + logger.warning(f"关闭游戏进程 {image} 失败: {e}") + await self._push_dispatch_log("游戏进程已关闭") + + async def kill_managed_process(self) -> None: + """中止 BetterGI 进程(游戏进程由 BetterGI 自身管理)。""" + if self.bettergi_process_manager is not None: + try: + await self.bettergi_process_manager.kill() + except Exception as e: + logger.opt(exception=True).warning( + f"通过进程管理器中止 BetterGI 进程失败: {e}" + ) + if self.script_exe_path is not None: + try: + await System.kill_process(self.script_exe_path) + except Exception as e: + logger.opt(exception=True).warning( + f"中止 BetterGI 主进程失败: {e}" + ) diff --git a/app/task/BetterGI/ScriptConfig.py b/app/task/BetterGI/ScriptConfig.py new file mode 100644 index 000000000..ae9af7387 --- /dev/null +++ b/app/task/BetterGI/ScriptConfig.py @@ -0,0 +1,159 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +import asyncio +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 BetterGIConfig, BetterGIUserConfig +from app.models.task import ScriptItem, TaskExecuteBase +from app.services import System +from app.utils import ProcessManager, get_logger + +from .AutoProxy import _BGI_REL_EXE +from .tools import one_dragon + +logger = get_logger("BetterGI 脚本设置") + + +class ScriptConfigTask(TaskExecuteBase): + """无参数启动 BetterGI 本体,供用户修改程序设置(原生 GUI 直控)。""" + + def __init__( + self, + script_info: ScriptItem, + script_config: BetterGIConfig, + user_config: MultipleConfig[BetterGIUserConfig], + ): + 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.cur_user_item = self.script_info.user_list[self.script_info.current_index] + # 脚本级配置("Default")强制独立配置;真实用户读 IfUseMasConfig + 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.process_manager = ProcessManager() + self.wait_event = asyncio.Event() + self.crashed = False + self.root_path = Path(self.script_config.get("Info", "RootPath")) + self.exe_path = self.root_path / _BGI_REL_EXE + + def _target_user_config(self) -> BetterGIUserConfig | None: + """返回当前会话对应的用户配置;脚本级("Default")返回 None。""" + if self.cur_user_item.user_id == "Default": + return None + return self.user_config[uuid.UUID(self.cur_user_item.user_id)] + + def _write_one_dragon_config(self) -> None: + """用户独立配置模式下,把该用户组开关写入一条龙配置并载入 BetterGI。""" + if not self.use_mas_config: + return + target = self._target_user_config() + if target is None: + return + one_dragon.write_user_one_dragon( + self.root_path, + self.script_info.script_id, + self.cur_user_item.user_id, + str(target.get("Task", "OneDragonConfigName") or ""), + list(target.get("OneDragon", "Groups") or []), + custom_groups=one_dragon.parse_custom_groups( + target.get("OneDragon", "CustomGroups") or "" + ), + manage_custom_groups=bool( + target.get("OneDragon", "IfUseCustomGroups") + ), + ) + + def _snapshot_one_dragon_config(self) -> None: + """把 BetterGI 现有的一条龙配置回读为 per-user 副本(捕获 GUI 中改的设置)。 + + 独立模式下 ``write_user_one_dragon`` 物化到 MAS 槽位,用户在 BGI GUI 里编辑的就是 + 槽位,故读取源改为槽位名,per-user 缓存 key 仍是用户所选名。 + """ + if not self.use_mas_config: + return + target = self._target_user_config() + if target is None: + return + config_name = str(target.get("Task", "OneDragonConfigName") or "") + read_name = ( + one_dragon.launch_slot_name() + if one_dragon.launch_slot_name() != one_dragon.resolve_config_name(config_name) + else config_name + ) + one_dragon.snapshot_user_one_dragon( + self.root_path, + self.script_info.script_id, + self.cur_user_item.user_id, + config_name, + read_name=read_name, + ) + + async def main_task(self) -> None: + await self._kill_processes() + logger.info(f"启动 BetterGI 设置: {self.exe_path}") + self.cur_user_item.status = "运行" + # 用户独立配置:先把该用户的一条龙配置载入 BetterGI,再打开 GUI 供其修改 + self._write_one_dragon_config() + await self.process_manager.open_process(self.exe_path, elevated=True) + await self.wait_event.wait() + + async def final_task(self) -> None: + self.wait_event.set() + await self._kill_processes() + if not self.crashed: + # 用户独立配置:回读 BetterGI 现有配置,保存 GUI 中修改的设置 + self._snapshot_one_dragon_config() + logger.success("BetterGI 直控配置已由脚本原生 GUI 保存") + self.cur_user_item.status = "完成" + + async def on_crash(self, e: Exception) -> None: + self.crashed = True + self.cur_user_item.status = "异常" + logger.opt(exception=True).warning(f"BetterGI 设置任务出现异常: {e}") + with suppress(Exception): + await self._kill_processes() + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"BetterGI 设置任务出现异常: {e}"}, + ) + + async def _kill_processes(self) -> None: + try: + await self.process_manager.kill() + except Exception as e: + logger.opt(exception=True).warning(f"通过进程管理器中止 BetterGI 失败: {e}") + + try: + await System.kill_process(self.exe_path) + except Exception as e: + logger.opt(exception=True).warning(f"中止 BetterGI 进程失败: {e}") diff --git a/app/task/BetterGI/__init__.py b/app/task/BetterGI/__init__.py new file mode 100644 index 000000000..bb088ad08 --- /dev/null +++ b/app/task/BetterGI/__init__.py @@ -0,0 +1,21 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +from .manager import BetterGIManager + +__all__ = ["BetterGIManager"] diff --git a/app/task/BetterGI/manager.py b/app/task/BetterGI/manager.py new file mode 100644 index 000000000..e011426bc --- /dev/null +++ b/app/task/BetterGI/manager.py @@ -0,0 +1,303 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +import uuid +from contextlib import suppress +from datetime import datetime +from pathlib import Path + +from app.core import Config +from app.models.task import TaskExecuteBase, ScriptItem, UserItem +from app.models.config import BetterGIConfig, BetterGIUserConfig +from app.models.ConfigBase import MultipleConfig +from app.services import Notify +from app.tools.game_sign_notify import ( + append_task_game_sign_summary, + mark_task_game_sign_summary_consumed, +) +from app.utils import get_logger +from app.utils.constants import TASK_MODE_ZH + +from .AutoProxy import AutoProxyTask, _BGI_REL_EXE +from .ScriptConfig import ScriptConfigTask +from .tools import push_notification + +logger = get_logger("BetterGI 调度器") + + +class BetterGIManager(TaskExecuteBase): + """BetterGI 控制器(better-genshin-impact 线)""" + + def __init__(self, script_info: ScriptItem): + 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.user_config: MultipleConfig[BetterGIUserConfig] | None = None + self.begin_time = "" + + async def check(self) -> str: + if self.task_info.mode not in ("AutoProxy", "ScriptConfig"): + return "不支持的任务模式, 请检查任务配置!" + + script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] + if not isinstance(script_config, BetterGIConfig): + return "脚本配置类型错误, 不是 BetterGI 类型" + + if self.task_info.mode == "ScriptConfig": + root_path = Path(script_config.get("Info", "RootPath")) + if not root_path.is_dir() or not (root_path / _BGI_REL_EXE).is_file(): + return "请先设置有效的 BetterGI 脚本路径" + target_user_id = self.task_info.user_id or "Default" + if target_user_id != "Default": + try: + target_user_uid = uuid.UUID(target_user_id) + except ValueError: + return "BetterGI 用户不存在,请刷新后重试" + if target_user_uid not in script_config.UserData: + return "BetterGI 用户不存在,请刷新后重试" + + # AutoProxy 模式只做用户列表可用性校验;逐用户配置文件检查放到 AutoProxyTask.check() + if self.task_info.mode == "AutoProxy": + script_uid = uuid.UUID(self.script_info.script_id) + if (not self.script_info.user_list) or ( + self.script_info.user_list + and self.script_info.user_list[0].name == "暂未加载" + ): + self.script_info.user_list = [ + UserItem(user_id=str(uid), name=config.get("Info", "Name"), status="等待") + for uid, config in Config.ScriptConfig[script_uid].UserData.items() + if config.get("Info", "Status") + and config.get("Info", "RemainedDay") != 0 + ] + if not self.script_info.user_list: + return "当前没有可执行的用户,请先添加并启用用户" + + return "Pass" + + async def prepare(self): + script_uid = uuid.UUID(self.script_info.script_id) + await Config.ScriptConfig[script_uid].lock() + self.script_config = Config.ScriptConfig[script_uid] + # 任务期使用独立副本,避免在 ScriptConfig 已锁时写 UserData(对齐 General) + self.user_config = MultipleConfig([BetterGIUserConfig]) + await self.user_config.load(await self.script_config.UserData.toDict()) + logger.success(f"{self.script_info.script_id} 已锁定,BetterGI 用户配置已提取") + + if not isinstance(self.script_config, BetterGIConfig): + raise TypeError("脚本配置类型错误") + + if self.task_info.mode == "ScriptConfig": + target_user_id = self.task_info.user_id or "Default" + target_user_name = "BetterGI 设置" + with suppress(ValueError): + target_user_uid = uuid.UUID(target_user_id) + if target_user_uid in self.user_config: + target_user_name = self.user_config[target_user_uid].get( + "Info", "Name" + ) + self.script_info.user_list = [ + UserItem( + user_id=target_user_id, + name=target_user_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 + ] + + async def main_task(self): + 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.task_info.mode == "ScriptConfig": + self.script_info.current_index = 0 + await self.spawn( + ScriptConfigTask( + self.script_info, + self.script_config, + self.user_config, + ) + ) + return + + 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] + + method = AutoProxyTask( + script_info=self.script_info, + script_config=self.script_config, + user_config=self.user_config, + ) + + sub_check = await method.check() + if sub_check != "Pass": + self.check_result = sub_check + current_user = self.script_info.user_list[self.script_info.current_index] + if current_user.status == "等待": + current_user.status = "异常" + await Config.send_websocket_message( + id=self.task_info.task_id, type="Info", data={"Error": sub_check} + ) + continue + + await self.spawn(method) + + async def final_task(self): + script_uid = uuid.UUID(self.script_info.script_id) + script_cfg = Config.ScriptConfig[script_uid] + + try: + # 先解锁,再写回 UserData(load() 在锁定状态下会抛异常) + if script_cfg.is_locked: + await script_cfg.unlock() + + if self.check_result != "Pass" and not any( + user.status in ("完成", "跳过") + for user in self.script_info.user_list + ): + if self.task_info.mode == "AutoProxy" and self.user_config is not None: + await script_cfg.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + self.script_info.status = "异常" + return + + if self.task_info.mode == "AutoProxy" and self.user_config is not None: + await script_cfg.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + + if any(user.status == "异常" for user in self.script_info.user_list): + self.script_info.status = "异常" + else: + self.script_info.status = "完成" + + if self.task_info.mode == "AutoProxy": + error_user = [ + user.name + for user in self.script_info.user_list + if user.status == "异常" + ] + over_user = [ + user.name + for user in self.script_info.user_list + if user.status == "完成" + ] + wait_user = [ + user.name + for user in self.script_info.user_list + if user.status == "等待" + ] + task_mode = TASK_MODE_ZH[self.task_info.mode] + title = ( + f"{datetime.now().strftime('%m-%d')} | " + f"{self.script_info.name or '空白'}的{task_mode}任务报告" + ) + task_result = append_task_game_sign_summary( + self.task_info, self.script_info.result + ) + has_game_sign_summary = task_result != self.script_info.result + result = { + "title": f"{task_mode}任务报告", + "script_name": self.script_info.name or "空白", + "start_time": self.begin_time, + "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "completed_count": len(over_user), + "uncompleted_count": len(error_user) + len(wait_user), + "result": task_result, + "game_sign_summary": has_game_sign_summary, + } + + await Notify.push_plyer( + title.replace("报告", "已完成!"), + ( + f"已完成用户数: {len(over_user)}, " + f"未完成用户数: {len(error_user) + len(wait_user)}" + ), + ( + f"已完成用户数: {len(over_user)}, " + f"未完成用户数: {len(error_user) + len(wait_user)}" + ), + 10, + ) + try: + await push_notification("代理结果", title, result) + if has_game_sign_summary: + mark_task_game_sign_summary_consumed(self.task_info) + except Exception as e: + logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"推送代理结果时出现异常: {e}"}, + ) + finally: + if script_cfg.is_locked: + with suppress(Exception): + await script_cfg.unlock() + + async def on_crash(self, e: Exception): + self.script_info.status = "异常" + logger.opt(exception=True).warning(f"BetterGI任务出现异常: {e}") + script_uid = uuid.UUID(self.script_info.script_id) + + try: + script_cfg = Config.ScriptConfig[script_uid] + except Exception: + script_cfg = None + + if script_cfg is not None: + if script_cfg.is_locked: + with suppress(Exception): + await script_cfg.unlock() + + try: + if self.task_info.mode == "AutoProxy" and self.user_config is not None: + await script_cfg.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + except Exception: + logger.opt(exception=True).warning( + "on_crash 写回 UserConfig 失败,放弃本次状态变更" + ) + + with suppress(Exception): + await Config.send_websocket_message( + id=self.task_info.task_id, + type="Info", + data={"Error": f"BetterGI任务出现异常: {e}"}, + ) diff --git a/app/task/BetterGI/tools/__init__.py b/app/task/BetterGI/tools/__init__.py new file mode 100644 index 000000000..04ae056ca --- /dev/null +++ b/app/task/BetterGI/tools/__init__.py @@ -0,0 +1,21 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +from .notify import push_notification + +__all__ = ["push_notification"] diff --git a/app/task/BetterGI/tools/account_switch.py b/app/task/BetterGI/tools/account_switch.py new file mode 100644 index 000000000..0d832822c --- /dev/null +++ b/app/task/BetterGI/tools/account_switch.py @@ -0,0 +1,310 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""BetterGI 切换账号专项适配。 + +通过 BetterGI 的脚本仓库(ScriptRepoUpdater)管理「切换账号多模式」脚本,不再随 +MAS 内置冻结副本:MAS 只写订阅清单,由 BetterGI 把脚本更新到 +``User/JsScript/SwitchAccountMultipleMode``。切号/一条龙的「运行前先同步更新、更新完成 +再执行」特性当前已冻结(见 ``_UPDATE_REPO_BEFORE_RUN``,仅保留后台自动更新与误删恢复)。 +MAS 按当前用户配置生成一个独立的配置组 ``MAS切换账号``,供 +``BetterGI.exe --startGroups MAS切换账号`` 单独执行。 + +- 订阅清单: ``{RootPath}/User/Subscriptions/bettergi-scripts-list.json`` = 路径数组 +- 自动更新: ``{RootPath}/User/config.json`` 的 ``ScriptConfig`` 三个开关(两个自动更新开关 + + ``selectedChannelName = "CNB"`` 固定仓库渠道为 BetterGI 官方 cnb.cool 镜像,无需境外源) +- 检出目标: ``{RootPath}/User/JsScript/SwitchAccountMultipleMode`` +- 误删恢复/初次使用: 脚本本地缺失时删除中央仓库副本 + ``{RootPath}/Repos/bettergi-scripts-list``,BGI 下次启动完整重建并重新检出已订阅脚本。 + 因为 ``OnDeleteScript`` 只删脚本目录、不取消订阅,BGI 每次更新都会对已订阅脚本无条件 + 重检出,即便仓库「已是最新」也会把误删后的目录补回来。 + +账号密码来源:MAS 用户配置 ``Info.Id`` / ``Info.Password``(密码已加密存储), +下拉列表模式下由 MAS 负责把完整手机号/邮箱转换为游戏下拉列表显示的打码形式。 +""" + +from pathlib import Path +import shutil +from typing import Any + +from app.utils import get_logger +from app.utils.io import read_file, write_file + +from .one_dragon import _GLOBAL_CONFIG_LOCK + +logger = get_logger("BetterGI 切换账号") + +# 生成并执行的配置组名称(同时作为文件名与 --startGroups 的组名) +_GROUP_NAME = "MAS切换账号" + +# 与 BetterGI 项目结构固定的相对路径(从 RootPath 派生) +_JS_SCRIPT_REL_DIR = Path("User") / "JsScript" +_SCRIPT_GROUP_REL_DIR = Path("User") / "ScriptGroup" + +# 内置资源目录(随 MAS 版本同步;含配置组模板,脚本本体不再内置) +_RES_TEMPLATE_DIR = Path.cwd() / "res" / "templates" / "BetterGI" + +# 切换账号脚本在 BetterGI 脚本仓库中的相对路径(repo 下),"js" 前缀映射到 User/JsScript +_SCRIPT_REPO_PATH = "js/SwitchAccountMultipleMode" +# 仓库检出到 User/JsScript 下的文件夹名,须与配置组 template 的 folderName 一致 +_SCRIPT_FOLDER_NAME = "SwitchAccountMultipleMode" + +# BetterGI 脚本仓库(ScriptRepoUpdater)控制相对路径 +# 仓库目录: {RootPath}/Repos/bettergi-scripts-list(真实 git 克隆) +# 订阅清单: {RootPath}/User/Subscriptions/bettergi-scripts-list.json = ["js/..."] +_REPO_FOLDER_NAME = "bettergi-scripts-list" + +# BetterGI 中央脚本仓库本地副本: {RootPath}/Repos/bettergi-scripts-list(桌面元数据缓存)。 +# 用户误删脚本 / 初次使用脚本缺失时,删掉它可在 BGI 下次启动触发完整重建,从而把 +# 检出的脚本连同缺失的切换账号脚本一起拉回来。 +_REPO_REL_DIR = Path("Repos") / _REPO_FOLDER_NAME +_SUBSCRIPTION_REL_DIR = Path("User") / "Subscriptions" +# BetterGI 主配置: {RootPath}/User/config.json,ScriptConfig 段开启自动更新 +_BGI_CONFIG_REL_PATH = Path("User") / "config.json" + +# 下拉列表模式下手机号/邮箱的打码规则(与游戏登录界面显示一致) +_PHONE_MASK_PREFIX = 3 +_PHONE_MASK_SUFFIX = 2 +_PHONE_DIGITS = 11 + + +def mask_account(account: str) -> str: + """把完整账号转换为游戏下拉列表显示的打码形式。 + + 手机号 ``13812345678`` → ``138******78``(前3 + 6个* + 后2) + 邮箱 ``11abc1@919.com`` → ``11****1@919.com``(@前 前2 + **** + 最后1位) + 第三方登录(如 ``apple``)→ 原样返回。 + """ + account = (account or "").strip() + if not account: + return "" + + if "@" in account: + local, _, domain = account.partition("@") + if len(local) <= 2: + # 本地部分过短,无法打码,原样返回 + return account + return f"{local[:2]}****{local[-1]}@{domain}" + + if account.isdigit() and len(account) == _PHONE_DIGITS: + return f"{account[:_PHONE_MASK_PREFIX]}******{account[-_PHONE_MASK_SUFFIX:]}" + + return account + + +# 游戏服务器 → (是否国际服, 国际服服务器, 强制切换模式) +# 官服/B服 走国服登录(非国际服);B服 强制走「B服切换另一个账号匹配+键鼠」模式 +# (B服 无下拉列表/OCR 切换方式),其余服务器不强制(切换模式由密码是否填写决定)。 +_RESOURCE_MAP: dict[str, tuple[bool, str, str | None]] = { + "官服": (False, "不切换服务器", None), + "B服": (False, "不切换服务器", "B服切换另一个账号匹配+键鼠"), + "亚服": (True, "Asia", None), + "欧服": (True, "Europe", None), + "美服": (True, "America", None), + "港澳台服": (True, "TW,HK,MO", None), +} + + +def resolve_switch_settings(resource: str, mode: str) -> tuple[bool, str, str]: + """把「游戏服务器」翻译为切换账号脚本所需的三元组 (是否国际服, 服务器, 切换模式)。 + + B服 强制走「B服切换另一个账号匹配+键鼠」模式;未知资源兜底为「官服」。 + """ + resource = (resource or "官服").strip() + global_account, servers, forced_mode = _RESOURCE_MAP.get( + resource, _RESOURCE_MAP["官服"] + ) + if forced_mode is not None: + mode = forced_mode + return global_account, servers, mode + + +def _build_js_settings( + account: str, + password: str, + mode: str, + global_account: bool, + servers: str, + uid: str, +) -> dict[str, Any]: + """组装配置组中 ``jsScriptSettingsObject``(即脚本 settings 注入对象)。""" + # 下拉列表模式写打码账号;账号+密码模式写完整账号,由脚本 OCR 输入 + username = mask_account(account) if mode == "下拉列表" else account.strip() + return { + "Modes": mode, + "username": username, + "password": password, + "GlobalAccount": global_account, + "Servers": servers, + "uid": uid, + } + + +def switch_script_dir(root_path: Path) -> Path: + """切换账号脚本经 BetterGI 仓库检出后的本地部署目录。""" + return root_path / _JS_SCRIPT_REL_DIR / _SCRIPT_FOLDER_NAME + + +def _ensure_script_subscription(root_path: Path) -> Path: + """合并订阅清单,返回订阅文件路径。 + + 把 ``_SCRIPT_REPO_PATH`` 追加进 ``User/Subscriptions/{仓库名}.json``(路径数组), + 保留用户已订阅的其他脚本;由 BetterGI ScriptRepoUpdater 据此拉取/更新。 + """ + sub_path = root_path / _SUBSCRIPTION_REL_DIR / f"{_REPO_FOLDER_NAME}.json" + data = read_file(sub_path) + subscribed = [str(x) for x in data] if isinstance(data, list) else [] + if _SCRIPT_REPO_PATH not in subscribed: + subscribed.append(_SCRIPT_REPO_PATH) + write_file(sub_path, subscribed) + logger.info(f"已订阅切换账号脚本: {_SCRIPT_REPO_PATH} -> {sub_path}") + return sub_path + + +# 「命令行运行前先同步更新脚本仓库、更新完成后再执行任务」特性开关。 +# 暂时冻结:当前发现该特性已无必要——每次 CLI 启动前的同步更新会拖慢启动, +# 且切号脚本已能通过后台自动更新(autoUpdateSubscribedScripts)与误删恢复补位。 +# 若日后出现脚本缺失却未被后台拉回等真实 bug,再把本常量置 True 重新启用。 +_UPDATE_REPO_BEFORE_RUN = False + + +def _ensure_auto_update_on_cli(root_path: Path) -> Path: + """配置 BetterGI 脚本仓库自动更新并把渠道固定为 CNB,返回主配置文件路径。 + + ``{RootPath}/User/config.json`` 的 ``ScriptConfig`` 置: + - ``autoUpdateBeforeCommandLineRun = _UPDATE_REPO_BEFORE_RUN``:命令行启动 + (切号/一条龙)是否先同步更新仓库脚本再执行。当前冻结停用(False), + 届时若重新启用改回 True。 + - ``autoUpdateSubscribedScripts = true``:普通启动时也后台更新已订阅脚本(兜底) + - ``selectedChannelName = "CNB"``:脚本仓库固定从 BetterGI 官方 cnb.cool 镜像 + ``https://cnb.cool/bettergi/bettergi-scripts-list`` 拉取/更新。CNB 本就是 + ScriptRepoUpdater 的默认渠道,但用户若在 BGI GUI 里选了 GitHub 会盖过默认回境外源, + 这里显式钉死,避免切号脚本又从 GitHub 下载。 + """ + config_path = root_path / _BGI_CONFIG_REL_PATH + with _GLOBAL_CONFIG_LOCK: + config = read_file(config_path) + if not isinstance(config, dict): + config = {} + # 统一写到 camelCase 键(BetterGI JsonOptions 以 CamelCase 读写,PascalCase 键读取时会被忽略) + script_cfg = config.get("scriptConfig") + if not isinstance(script_cfg, dict): + legacy = config.get("ScriptConfig") # 兼容历史 PascalCase 键,合并后弃用 + script_cfg = legacy if isinstance(legacy, dict) else {} + script_cfg["autoUpdateBeforeCommandLineRun"] = _UPDATE_REPO_BEFORE_RUN + script_cfg["autoUpdateSubscribedScripts"] = True + script_cfg["selectedChannelName"] = "CNB" + config.pop("ScriptConfig", None) + config["scriptConfig"] = script_cfg + write_file(config_path, config) + logger.info( + f"已配置 BetterGI 脚本仓库自动更新(运行时预更新={'启用' if _UPDATE_REPO_BEFORE_RUN else '已冻结'})" + f",渠道 CNB: {config_path}" + ) + return config_path + + +def ensure_switch_subscription(root_path: Path) -> bool: + """确保切换账号脚本被订阅,缺失时强制重建,返回脚本本地是否已就绪。 + + BGI 负责按订阅更新仓库脚本(运行前同步更新已冻结,改走后台 + ``autoUpdateSubscribedScripts``),故这里只需保证订阅就绪即可, + 删除/初次使用两种情况都会被 BGI 自动重新检出: + + - 覆盖式写入订阅清单(``js/SwitchAccountMultipleMode``)并开启自动更新, + 保留用户已有订阅项与其余配置。BGI 更新逻辑对每个已订阅路径无条件重检出, + 即使仓库「已是最新」也会补回被误删的脚本目录。 + - 若切换账号脚本当前本地缺失(用户误删、或初次使用从未检出),删除本地中央 + 仓库副本 ``Repos/bettergi-scripts-list``,强制 BGI 下次启动完整重建仓库并 + 重检出全部已订阅脚本,确定性地把缺失的切号脚本拉回来,避免依赖 BGI 磁盘 + 缓存快捷路径导致一直不补位。 + + Returns: + 切换账号脚本当前是否已存在于本地(帮助日志判断是已就绪还是将现拉取)。 + """ + try: + _ensure_script_subscription(root_path) + _ensure_auto_update_on_cli(root_path) + if not switch_script_dir(root_path).is_dir(): + # 脚本缺失(用户误删/初次使用):清掉本地仓库,逼 BGI 下次启动整体重建 + repo_dir = root_path / _REPO_REL_DIR + if repo_dir.is_dir(): + shutil.rmtree(repo_dir, ignore_errors=True) + logger.info(f"切换账号脚本缺失,已清理本地脚本仓库强制重建: {repo_dir}") + except Exception as e: + logger.opt(exception=True).warning(f"切换账号脚本仓库订阅设置失败: {e}") + raise + return switch_script_dir(root_path).is_dir() + + +def write_switch_group( + root_path: Path, + account: str, + password: str, + mode: str, + global_account: bool, + servers: str, + uid: str, +) -> Path: + """生成(覆盖)BetterGI 切换账号配置组 ``MAS切换账号``。 + + ``folderName`` 固定指向脚本仓库检出目录 ``SwitchAccountMultipleMode``,与 + ``ensure_switch_subscription`` 对齐;``jsScriptSettingsObject`` 按用户注入。 + Returns: + 写入的配置组 JSON 文件路径。 + """ + template_path = _RES_TEMPLATE_DIR / f"{_GROUP_NAME}.json" + template = read_file(template_path) + if not isinstance(template, dict) or not isinstance(template.get("projects"), list): + raise RuntimeError(f"切换账号配置组模板无效: {template_path}") + + project = template["projects"][0] + if not isinstance(project, dict): + raise RuntimeError(f"切换账号配置组模板缺 projects[0]: {template_path}") + project["folderName"] = _SCRIPT_FOLDER_NAME + project["jsScriptSettingsObject"] = _build_js_settings( + account, password, mode, global_account, servers, uid + ) + + out_path = root_path / _SCRIPT_GROUP_REL_DIR / f"{_GROUP_NAME}.json" + write_file(out_path, template) + logger.info(f"已生成切换账号配置组: {out_path} (账号 {mask_account(account)})") + return out_path + + +def scrub_switch_group(root_path: Path) -> None: + """运行结束后脱敏切换账号配置组,清空密码并把账号置为打码形式。 + + 切号脚本执行时必须写入明文账号/密码供 OCR/键鼠登录,但完成后不应让明文 + 凭据残留磁盘。本函数把 ``jsScriptSettingsObject`` 的 ``password`` 清空、 + ``username`` 还原为打码(下拉列表模式本已是打码,OCR 模式的完整账号被抹掉)。 + """ + out_path = root_path / _SCRIPT_GROUP_REL_DIR / f"{_GROUP_NAME}.json" + data = read_file(out_path) + if not isinstance(data, dict): + return + for proj in data.get("projects") or []: + if not isinstance(proj, dict): + continue + settings = proj.get("jsScriptSettingsObject") + if not isinstance(settings, dict): + continue + settings["password"] = "" + settings["username"] = mask_account(str(settings.get("username") or "")) + write_file(out_path, data) + logger.info(f"已脱敏切换账号配置组: {out_path}") diff --git a/app/task/BetterGI/tools/notify.py b/app/task/BetterGI/tools/notify.py new file mode 100644 index 000000000..73f433deb --- /dev/null +++ b/app/task/BetterGI/tools/notify.py @@ -0,0 +1,188 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +from datetime import datetime + +from app.core import Config +from app.models.config import BetterGIUserConfig +from app.services import Notify +from app.utils import get_logger + +logger = get_logger("BetterGI 通知工具") + +_STEP_TIME_FMT = "%H:%M:%S" + +# 各发信渠道的正文长度上限(按需在分步表之外决定用完整版还是简略版): +# 邮件(网页 HTML):无实际字数瓶颈 → 始终用完整版(含「一条龙分步执行」表)。 +# ServerChan/Server酱 desp:上限约 32KB → 完整版,超过预算安全回退简略版。 +# 自定义 Webhook(企业微信 text 2048 字节 / Discord 2000 字符 / Telegram 4096 字符):聊天机器人 +# 存在真实每消息字数瓶颈 → 始终用简略版(回退旧的 4 字段汇总),避免分步表被静默截断/丢弃。 +_SERVERCHAN_MAX_BYTES = 30 * 1024 + + +def _step_duration(step: dict) -> str: + """把一步的起止时刻换算成人类可读用时(秒/分+秒);缺时间或解析失败返回 —。""" + try: + a = datetime.strptime(step["start"].split(".")[0], _STEP_TIME_FMT) + b = datetime.strptime(step["end"].split(".")[0], _STEP_TIME_FMT) + except (KeyError, ValueError, AttributeError): + return "—" + total = (b.hour - a.hour) * 3600 + (b.minute - a.minute) * 60 + (b.second - a.second) + total = max(0, total) + if total < 60: + return f"{total}秒" + return f"{total // 60}分{total % 60}秒" + + +def _render_one_dragon_steps(steps: list[dict]) -> str: + """把「一条龙分步执行」拼成通知文本段落:每步一行,成功 ✓+时间,异常标注原因/次数+时间。""" + if not steps: + return "" + lines = ["【一条龙分步执行】"] + for s in steps: + tag = f"{s['index']}/{s['total']}" + span = f"{s['start']} → {s['end']}({_step_duration(s)})" + if s["ok"] and not s["issue_count"]: + lines.append(f"✓ {tag} {s['task']} 成功 {span}") + elif s["ok"]: + lines.append( + f"✓ {tag} {s['task']} 成功(含 {s['issue_count']} 处异常: {s['issue_text']}) {span}" + ) + else: + reason = f" · {s['issue_text']}" if s["issue_text"] else " · 未走完就结束/中断" + lines.append(f"✗ {tag} {s['task']} 失败{reason} {span}") + return "\n".join(lines) + + +async def push_notification( + mode: str, + title: str, + message: dict, + user_config: BetterGIUserConfig | None = None, +) -> None: + """通过全局或用户配置的渠道推送 BetterGI 任务报告。""" + + logger.info(f"开始推送通知, 模式: {mode}, 标题: {title}") + + if mode == "统计信息": + if user_config is None or not ( + user_config.get("Notify", "Enabled") + and user_config.get("Notify", "IfSendStatistic") + ): + return + + # 简略版(所有渠道的兜底):仅 4 字段汇总,旧版格式 + message_text = ( + f"用户: {message['user_info']}\n" + f"开始时间: {message['start_time']}\n" + f"结束时间: {message['end_time']}\n" + f"执行结果: {message['user_result']}" + ) + steps_text = ( + "\n\n" + _render_one_dragon_steps(steps) + if (steps := message.get("one_dragon_steps")) + else "" + ) + # 完整版:4 字段 + 「一条龙分步执行」 + message_text_full = f"{message_text}{steps_text}" + message_html = Config.notify_env.get_template( + "general_statistics.html" + ).render(message) + + if user_config.get("Notify", "IfSendMail"): + if user_config.get("Notify", "ToAddress"): + # 邮件无实际字数瓶颈,始终发完整版(含分步表) + await Notify.send_mail( + "网页", + title, + message_html, + user_config.get("Notify", "ToAddress"), + ) + else: + logger.warning("用户邮箱地址为空, 无法发送 BetterGI 用户通知") + + if user_config.get("Notify", "IfServerChan"): + if user_config.get("Notify", "ServerChanKey"): + # Server酱 desp 上限约 32KB:分步表很小时用完整版,超预算回退简略版 + serverchan_content = message_text_full + if len(serverchan_content.encode("utf-8")) > _SERVERCHAN_MAX_BYTES: + serverchan_content = message_text + logger.warning( + "Server酱内容超过字数上限,已回退为简略版(不含分步表)" + ) + await Notify.ServerChanPush( + title, + f"{serverchan_content.replace(chr(10), chr(10) * 2)}\n\nAUTO-MAS 敬上", + user_config.get("Notify", "ServerChanKey"), + ) + else: + logger.warning("用户ServerChan密钥为空, 无法发送 BetterGI 用户通知") + + for webhook in user_config.Notify_CustomWebhooks.values(): + # Webhook 目标多为聊天机器人(企业微信 2048 字节 / Discord 2000 字符 / Telegram + # 4096 字符),有真实字数瓶颈 → 用回简略版,避免分步表塞爆被静默丢弃。 + await Notify.WebhookPush( + title, f"{message_text}\n\nAUTO-MAS 敬上", webhook + ) + return + + if mode != "代理结果": + return + + result_time_setting = Config.get("Notify", "SendTaskResultTime") + if not message.get("game_sign_summary", False) and ( + result_time_setting != "任何时刻" + and ( + result_time_setting != "仅失败时" + or message["uncompleted_count"] == 0 + ) + ): + return + + message_text = ( + f"任务开始时间: {message['start_time']}, 结束时间: {message['end_time']}\n" + f"已完成数: {message['completed_count']}, " + f"未完成数: {message['uncompleted_count']}\n\n" + f"{message['result']}" + ) + message_html = Config.notify_env.get_template("general_result.html").render( + message + ) + serverchan_message = message_text.replace("\n", "\n\n") + + if Config.get("Notify", "IfSendMail"): + await Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + await Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO-MAS 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + for webhook in Config.Notify_CustomWebhooks.values(): + await Notify.WebhookPush( + title, f"{message_text}\n\nAUTO-MAS 敬上", webhook + ) + + if Config.get("Notify", "IfKoishiSupport"): + await Notify.send_koishi( + f"{title}\n\n{message_text}\n\nAUTO-MAS 敬上" + ) diff --git a/app/task/BetterGI/tools/one_dragon.py b/app/task/BetterGI/tools/one_dragon.py new file mode 100644 index 000000000..ba32bae2f --- /dev/null +++ b/app/task/BetterGI/tools/one_dragon.py @@ -0,0 +1,592 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""BetterGI 一条龙配置读写与配置组切换。 + +BetterGI 一条龙配置以独立 JSON 文件保存于 ``{RootPath}/User/OneDragon/{配置名}.json`` +(``Name`` 字段 == 文件名)。每个配置组以「组名」标识(``TaskDefinitions`` 的 value), +UUID 是每实例随机生成的临时标识,因此本模块按组名识别与切换,新组用 ``uuid.uuid4()`` 生成。 + +MAS 只管理 8 个内置配置组(按组名对其 enabled 置 true/false,组定义保留、可逆), +其余自定义组(用户自建 ScriptGroup)本轮不管理、原样保留,其启用与否由 BetterGI 内部 +配置决定;除三个组列表外的所有设置字段(队伍/秘境/地脉花/首领讨伐等)一律原样保留。 +""" + +import json +import threading +import uuid +from pathlib import Path +from typing import Any + +from app.models.config import _BGI_BUILTIN_ONE_DRAGON_GROUPS +from app.utils.io import read_file, write_file + +# 8 个内置一条龙配置组:单一来源为 app/models/config.py 的 _BGI_BUILTIN_ONE_DRAGON_GROUPS, +# 此处仅别名引用,避免双份硬编码随版本漂移。 +_BUILTIN_ONE_DRAGON_GROUPS = _BGI_BUILTIN_ONE_DRAGON_GROUPS + +# 全局主配置 config.json 的读-改-写串行化锁。atomic_write 只保证单次写原子, +# 读-改-写整体仍可能交错丢失更新(切号的 _ensure_auto_update_on_cli 与一条龙的 +# apply_global_battle_* / snapshot / restore 都读写同一文件),故加锁串行化。 +_GLOBAL_CONFIG_LOCK = threading.Lock() + +# 一条龙配置目录(从 RootPath 派生) +_ONE_DRAGON_REL_DIR = Path("User") / "OneDragon" + +# 内置种子模板(随 MAS 版本同步) +_RES_TEMPLATE_DIR = Path.cwd() / "res" / "templates" / "BetterGI" +_SEED_TEMPLATE = _RES_TEMPLATE_DIR / "OneDragon" / "默认配置.json" + +# 空配置名的显式兜底配置名 +_DEFAULT_CONFIG_NAME = "默认配置" +# MAS 运行时专属槽位配置名:开启「用户独立配置」时,把 per-user 配置落地到这个独立文件并据此启动, +# 绝不覆盖 BGI 同名的用户实配({RootPath}/User/OneDragon/{用户所选名}.json 全程零接触)。 +# 该槽位由 MAS 独占、运行后删除;名称避免与常见用户配置名冲突。 +_MAS_ONE_DRAGON_SLOT_NAME = "MAS独立配置" + +# BetterGI 内置自动战斗策略名(跨版本始终存在;AutoBossParam.BuildCombatStrategyPath 将其映射到 User\AutoFight\ 目录) +_AUTO_BOSS_BUILTIN_STRATEGY = "根据队伍自动选择" +# 自定义策略文件所在目录({RootPath}/User/AutoFight/*.txt) +_AUTO_FIGHT_REL_DIR = Path("User") / "AutoFight" + +# BetterGI 全局主配置(config.json)使用 camelCase 键。一条龙配置自带战斗字段的只有 +# 秘境(PartyName)与首领讨伐(AutoBossTeamName/AutoBossStrategyName);地脉花/幽境危战 +# 则由 BetterGI 在 OneDragonTaskItem 里直接从全局 AutoLeyLineOutcropConfig / +# AutoStygianOnslaughtConfig 段读取队伍与策略(无一条龙专用字段),秘境策略走全局 +# AutoFightConfig。故通用战斗队伍/策略需另补写以下 camelCase 叶子路径(tuple 表示嵌套): +# 队伍: autoLeyLineOutcropConfig.Team(地脉花), +# autoStygianOnslaughtConfig.fightTeamName(幽境危战) +# 策略: autoFightConfig.strategyName(秘境), +# autoLeyLineOutcropConfig.fightConfig.strategyName(地脉花), +# autoStygianOnslaughtConfig.strategyName(幽境危战) +_BGI_CONFIG_REL_PATH = Path("User") / "config.json" + +# 通用战斗队伍落到的叶子路径 +_GLOBAL_TEAM_LEAVES = ( + ("autoLeyLineOutcropConfig", "Team"), + ("autoStygianOnslaughtConfig", "fightTeamName"), +) + +# 通用战斗策略落到的叶子路径 +_GLOBAL_STRATEGY_LEAVES = ( + ("autoFightConfig", "strategyName"), + ("autoLeyLineOutcropConfig", "fightConfig", "strategyName"), + ("autoStygianOnslaughtConfig", "strategyName"), +) + +# 全部待补写叶子路径:apply 用分组,快照/还原用全集 +_ALL_GLOBAL_LEAVES = _GLOBAL_TEAM_LEAVES + _GLOBAL_STRATEGY_LEAVES + + +def list_auto_boss_strategies(root: Path) -> list[str]: + """列出可选自动战斗策略:内置默认 + {RootPath}/User/AutoFight/*.txt 文件名。 + + 玩家可自行往该目录放置 .txt 战斗脚本,故每次调用实时扫描以反映最新选项。 + """ + options = [_AUTO_BOSS_BUILTIN_STRATEGY] + autofight_dir = root / _AUTO_FIGHT_REL_DIR + if autofight_dir.is_dir(): + for p in sorted(autofight_dir.glob("*.txt"), key=lambda p: p.stem): + name = p.stem.strip() + if name and name not in options: + options.append(name) + return options + + +def list_one_dragon_configs(root: Path) -> list[str]: + """列出可选一条龙配置名:{RootPath}/User/OneDragon/*.json 的文件名。 + + 排除 MAS 运行时槽位「MAS独立配置」;始终把「默认配置」置顶(空名/首选的兜底)。 + 实时扫描以反映 BGI 侧手工新增/删除的配置。 + """ + names: list[str] = [] + dragon_dir = root / _ONE_DRAGON_REL_DIR + if dragon_dir.is_dir(): + for p in sorted(dragon_dir.glob("*.json"), key=lambda p: p.stem): + name = p.stem.strip() + if name and name != _MAS_ONE_DRAGON_SLOT_NAME and name not in names: + names.append(name) + names = [name for name in names if name != _DEFAULT_CONFIG_NAME] + return [_DEFAULT_CONFIG_NAME] + names + + +def resolve_config_name(name: str) -> str: + """解析一条龙配置名,空值显式兜底为「默认配置」。""" + return (name or "").strip() or _DEFAULT_CONFIG_NAME + + +def launch_slot_name() -> str: + """MAS 运行时专属槽位配置名(开启「用户独立配置」时据此启动一条龙)。""" + return _MAS_ONE_DRAGON_SLOT_NAME + + +def one_dragon_slot_path(root: Path) -> Path: + """MAS 运行时槽位配置文件的绝对路径(``{RootPath}/User/OneDragon/MAS独立配置.json``)。""" + return one_dragon_path(root, _MAS_ONE_DRAGON_SLOT_NAME) + + +def remove_one_dragon_slot(root: Path) -> bool: + """删除 MAS 运行时槽位配置(幂等)。返回是否确实存在并删除了。""" + path = one_dragon_slot_path(root) + existed = path.exists() + path.unlink(missing_ok=True) + return existed + + +def parse_custom_groups(raw: Any) -> list[dict[str, Any]]: + """解析前端保存的自定义配置组 JSON 列表(字符串或已是列表),非法时返回空列表。 + + 元素过滤为 ``{"name", "enabled"}`` 结构。 + """ + if isinstance(raw, list): + data = raw + elif isinstance(raw, str): + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(data, list): + return [] + else: + return [] + return [ + {"name": str(item.get("name", "")).strip(), "enabled": bool(item.get("enabled", True))} + for item in data + if isinstance(item, dict) and str(item.get("name", "")).strip() + ] + + +def list_custom_groups(root: Path, config_name: str) -> list[dict[str, Any]]: + """列出某一条龙配置里的自定义配置组(非内置 8 组),按 ``TaskOrder`` 相对顺序。 + + 供前端「自定义配置组」表格自动加载:读取 BetterGI 现有配置,返回 + ``[{"name": ..., "enabled": ...}, ...]``。 + """ + config = load_one_dragon(root, config_name) + defs: dict[str, str] = config.get("TaskDefinitions") or {} + enabled_map: dict[str, bool] = config.get("TaskEnabledList") or {} + order: list[str] = list(config.get("TaskOrder") or []) + name_by_uid = {uid: n for uid, n in defs.items() if n} + items: list[dict[str, Any]] = [] + seen: set[str] = set() + for uid in order: + name = name_by_uid.get(uid) + if not name or name in _BUILTIN_ONE_DRAGON_GROUPS or name in seen: + continue + seen.add(name) + items.append({"name": name, "enabled": bool(enabled_map.get(uid, True))}) + # 兜底:不在 TaskOrder 里但存在定义的自定义组 + for uid, name in defs.items(): + if ( + name + and name not in _BUILTIN_ONE_DRAGON_GROUPS + and name not in seen + ): + seen.add(name) + items.append({"name": name, "enabled": bool(enabled_map.get(uid, True))}) + return items + + +def one_dragon_path(root: Path, name: str) -> Path: + """一条龙配置文件的绝对路径。""" + return root / _ONE_DRAGON_REL_DIR / f"{resolve_config_name(name)}.json" + + +def load_one_dragon(root: Path, name: str) -> dict[str, Any]: + """读取一条龙配置;文件不存在返回空 ``{}``。""" + data = read_file(one_dragon_path(root, name)) + return data if isinstance(data, dict) else {} + + +def write_one_dragon(root: Path, name: str, config: dict[str, Any]) -> Path: + """写入一条龙配置(同步 ``Name`` 字段与文件名)。""" + name = resolve_config_name(name) + config = dict(config) + config["Name"] = name + out_path = one_dragon_path(root, name) + write_file(out_path, config) + return out_path + + +def load_seed_template() -> dict[str, Any]: + """读取内置种子模板;模板缺失时返回仅含 8 个内置组的最小合法配置。""" + data = read_file(_SEED_TEMPLATE) + if isinstance(data, dict) and data: + return data + return _minimal_config() + + +def _minimal_config() -> dict[str, Any]: + """构造最小合法的一条龙配置(仅 8 个内置组全部开启,无其它设置)。""" + defs: dict[str, str] = {} + order: list[str] = [] + for name in _BUILTIN_ONE_DRAGON_GROUPS: + uid = str(uuid.uuid4()) + defs[uid] = name + order.append(uid) + return { + "TaskEnabledList": {uid: True for uid in order}, + "TaskOrder": order, + "TaskDefinitions": defs, + "Name": _DEFAULT_CONFIG_NAME, + "NextTaskId": "", + } + + +def per_user_one_dragon_path(script_id: str, user_id: str, config_name: str) -> Path: + """某用户的一条龙配置副本路径 ``data/{script_id}/{user_id}/OneDragon/{name}.json``。""" + return ( + Path.cwd() + / "data" + / script_id + / user_id + / "OneDragon" + / f"{resolve_config_name(config_name)}.json" + ) + + +def write_user_one_dragon( + root: Path, + script_id: str, + user_id: str, + config_name: str, + groups: list[str], + daily_reward_party_name: str = "", + party_name: str = "", + auto_boss_strategy_name: str = "", + custom_groups: list[dict[str, Any]] | None = None, + manage_custom_groups: bool = False, +) -> None: + """把组开关与队伍/策略设置应用到一条龙配置,写入 BGI 运行时槽位并缓存 per-user 副本。 + + 种子优先级:per-user 副本 → BetterGI 现有配置 → 内置模板。 + 关键:物化结果写入 MAS 专属槽位 ``{RootPath}/User/OneDragon/MAS独立配置.json``(据此启动, + 运行后由 ``remove_one_dragon_slot`` 删除),而 **不写入用户所选名的 BGI 实配**——BGI 同名 + 配置全程零接触,不会被覆盖成用户独立配置的样子。per-user 缓存仍以用户所选名 key。 + + 非组字段(领取奖励队伍/战斗队伍/战斗策略)仅在非空时覆盖配置(留空不覆盖); + 其中「战斗队伍/战斗策略」会落到秘境 ``PartyName`` 与首领讨伐的 + ``AutoBossTeamName`` / ``AutoBossStrategyName``(秘境策略仍走全局 + ``autoFightConfig``);地脉花/幽境危战/以及秘境策略另外经 + ``apply_global_battle_team`` / ``apply_global_battle_strategy`` 补写全局 config.json。 + ``manage_custom_groups`` 开启时按 ``custom_groups``(name→enabled)管理自定义组, + 否则自定义组原样保留(由 BetterGI 内部决定)。 + """ + config_name = resolve_config_name(config_name) + user_path = per_user_one_dragon_path(script_id, user_id, config_name) + + config = read_file(user_path) + if not config or not isinstance(config, dict): + # 缓存缺失/为空时(read_file 对不存在返回 {})回退到 BGI 实配:否则种子退化为 + # 仅 8 个内置组的空模板,用户现有自定义配置组会整体丢失、重启后落到一条龙末尾。 + config = load_one_dragon(root, config_name) + if not config: + config = load_seed_template() + + config = apply_groups( + config, groups, custom_groups=custom_groups, manage_customs=manage_custom_groups + ) + if daily_reward_party_name: + config["DailyRewardPartyName"] = daily_reward_party_name + if party_name: + config["PartyName"] = party_name + # 一条龙自动首领讨伐从 AutoBossTeamName 取队伍(OneDragonTaskItem.cs) + config["AutoBossTeamName"] = party_name + if auto_boss_strategy_name: + config["AutoBossStrategyName"] = auto_boss_strategy_name + write_one_dragon(root, _MAS_ONE_DRAGON_SLOT_NAME, config) + write_file(user_path, config) + + +def _global_config_path(root: Path) -> Path: + """BetterGI 全局主配置 config.json 的绝对路径。""" + return root / _BGI_CONFIG_REL_PATH + + +def _set_leaf(config: dict, leaf: tuple[str, ...], value: str) -> bool: + """沿叶子路径向下补建字典并赋 ``value``;值未变返回 False(避免无谓触写)。""" + cur = config + for key in leaf[:-1]: + nxt = cur.get(key) + if not isinstance(nxt, dict): + nxt = {} + cur[key] = nxt + cur = nxt + key = leaf[-1] + if cur.get(key) == value: + return False + cur[key] = value + return True + + +def _apply_leaves(root: Path, leaves, value: str) -> None: + """把 ``value`` 补写到 config.json 的若干叶子路径,保留同段其余字段;空值不写。""" + value = (value or "").strip() + if not value: + return + with _GLOBAL_CONFIG_LOCK: + config = read_file(_global_config_path(root)) + if not isinstance(config, dict): + config = {} + changed = False + for leaf in leaves: + changed |= _set_leaf(config, leaf, value) + if changed: + write_file(_global_config_path(root), config) + + +def apply_global_battle_team(root: Path, party_name: str) -> None: + """把通用战斗队伍补写进 BetterGI 全局配置,供一条龙的地脉花/幽境危战读取。 + + 首领讨伐走一条龙 ``AutoBossTeamName``(见 ``write_user_one_dragon``);地脉花/幽境危战 + 由 BGI 直读全局段,故在此补写。保留同段其余字段;空值不覆盖。 + """ + _apply_leaves(root, _GLOBAL_TEAM_LEAVES, party_name) + + +def apply_global_battle_strategy(root: Path, strategy_name: str) -> None: + """把通用战斗策略补写进 BetterGI 全局配置,供一条龙的秘境/地脉花/幽境危战读取。 + + 首领讨伐走一条龙 ``AutoBossStrategyName``(见 ``write_user_one_dragon``);其余三项 + 由 BGI 直读全局段。保留同段其余字段;空值不覆盖。 + """ + _apply_leaves(root, _GLOBAL_STRATEGY_LEAVES, strategy_name) + + +def _restore_leaf(config: dict, leaf: tuple[str, ...], existed: bool, value) -> bool: + """还原单个叶子:原存在则回写原值,原缺失则删除;返回是否实际改写。""" + parent = config + for key in leaf[:-1]: + nxt = parent.get(key) if isinstance(parent, dict) else None + if not isinstance(nxt, dict): + return False # 父链已不存在(本次并未补写该叶子),无需还原 + parent = nxt + if not isinstance(parent, dict): + return False + key = leaf[-1] + if existed: + if parent.get(key) != value: + parent[key] = value + return True + return False + if key in parent: + del parent[key] + return True + return False + + +def _prune_empty_ancestors(config: dict, leaf: tuple[str, ...]) -> None: + """沿 leaf 前缀(不含叶子本身)从深到浅删除沿途变空的字典。""" + prefix = list(leaf[:-1]) + while prefix: + cur = config + broken = False + for key in prefix[:-1]: + nxt = cur.get(key) if isinstance(cur, dict) else None + if not isinstance(nxt, dict): + broken = True + break + cur = nxt + if broken: + return + last = prefix[-1] + grand = cur.get(last) if isinstance(cur, dict) else None + if isinstance(grand, dict) and not grand: + del cur[last] + prefix.pop() + else: + return + + +def snapshot_global_battle_config(root: Path) -> dict[tuple[str, ...], tuple[bool, Any]]: + """快照 config.json 本次可能改写的队伍/策略叶子路径,供结束还原。 + + 键为叶子路径元组,值为 ``(该叶子原本是否存在, 原值)``。 + """ + with _GLOBAL_CONFIG_LOCK: + config = read_file(_global_config_path(root)) + if not isinstance(config, dict): + config = {} + snap: dict[tuple[str, ...], tuple[bool, Any]] = {} + for leaf in _ALL_GLOBAL_LEAVES: + cur: Any = config + present = True + for key in leaf: + if not isinstance(cur, dict) or key not in cur: + present = False + break + cur = cur[key] + snap[leaf] = (present, cur if present else None) + return snap + + +def restore_global_battle_config(root: Path, snapshot: dict[tuple[str, ...], tuple[bool, Any]]) -> None: + """把 config.json 的队伍/策略叶子路径还原为快照状态,消除单次运行残留。 + + 原本缺失则删除(沿路径清理变空字典),原本存在则回写原值;只改写本次动过的键。 + """ + if not snapshot: + return + with _GLOBAL_CONFIG_LOCK: + config = read_file(_global_config_path(root)) + if not isinstance(config, dict): + return + changed = False + for leaf in _ALL_GLOBAL_LEAVES: + existed, value = snapshot.get(leaf, (False, None)) + if _restore_leaf(config, leaf, existed, value): + changed = True + if changed: + for leaf in _ALL_GLOBAL_LEAVES: + _prune_empty_ancestors(config, leaf) + write_file(_global_config_path(root), config) + + +def snapshot_user_one_dragon( + root: Path, + script_id: str, + user_id: str, + config_name: str, + read_name: str | None = None, +) -> None: + """回读 BetterGI 现有一条龙配置为 per-user 副本(捕获 GUI 中改的设置)。 + + ``read_name`` 指定实际读取的配置名:独立模式下用户编辑的是 MAS 槽位「MAS独立配置」, + 而 per-user 缓存 key 仍是用户所选名 ``config_name``,故读取源与缓存 key 解耦。 + 缺省 ``read_name=None`` 时与 ``config_name`` 相同(直控/旧行为)。 + """ + config_name = resolve_config_name(config_name) + source_name = resolve_config_name(read_name or config_name) + config = load_one_dragon(root, source_name) + if config: + write_file(per_user_one_dragon_path(script_id, user_id, config_name), config) + + +def apply_groups( + config: dict[str, Any], + enabled: list[str], + custom_groups: list[dict[str, Any]] | None = None, + manage_customs: bool = False, +) -> dict[str, Any]: + """按组名切换一条龙配置的组开关,保留其余设置。 + + 按钮是「开关」而非删减:对每个内置组在 ``TaskEnabledList`` 里置 ``true/false``, + 组定义保留(便于日后重新打开);仅在按钮 ON 而配置里缺失时才补建新组。 + + 自定义组处理分两种情况: + - ``manage_customs=False``(总开关关):自定义组一律原样保留其 UUID、启用状态与 + 相对顺序,启用与否由 BetterGI 内部配置决定。 + - ``manage_customs=True``(总开关开):按 ``custom_groups``([{"name","enabled"}]) + 覆盖自定义组启用状态:入表组按表状态、未入表(但 BetterGI 文件里存在)组保持原 + 启用状态、入表且启用但配置缺失时补建。 + + 应用到当前运行的配置(``Name`` 指向哪个就写哪个),不局限于某一份命名。 + + Args: + config: 一条龙配置 dict(可为空 ``{}``)。 + enabled: 按钮打开的 8 个内置组名列表。 + custom_groups: 自定义配置组管理列表(name→enabled),仅 ``manage_customs`` 时使用。 + manage_customs: 是否管理自定义组开关。 + + Returns: + 修改后的配置 dict(浅拷贝,原 ``config`` 不变)。 + """ + config = dict(config or {}) + selected = [n for n in enabled if n in _BUILTIN_ONE_DRAGON_GROUPS] + selected_set = set(selected) + + # 自定义组管理表:name -> enabled + custom_enabled: dict[str, bool] = {} + for cg in custom_groups or []: + name = (cg.get("name") if isinstance(cg, dict) else None) or "" + name = str(name).strip() + if name and name not in _BUILTIN_ONE_DRAGON_GROUPS: + custom_enabled[name] = bool(cg.get("enabled", True)) + + old_defs: dict[str, str] = config.get("TaskDefinitions") or {} + old_enabled: dict[str, bool] = config.get("TaskEnabledList") or {} + old_order: list[str] = list(config.get("TaskOrder") or []) + name_by_uid = {uid: n for uid, n in old_defs.items() if n} + + new_defs: dict[str, str] = {} + new_order: list[str] = [] + new_enabled: dict[str, bool] = {} + present_builtin: set[str] = set() + + # 单遍扫描旧顺序:内置组按按钮开关置 enabled,自定义组按管理表/原样保留,保持相对顺序 + for uid in old_order: + name = name_by_uid.get(uid) + if not name or uid in new_defs: + continue + if name in _BUILTIN_ONE_DRAGON_GROUPS: + present_builtin.add(name) + new_defs[uid] = name + new_order.append(uid) + new_enabled[uid] = name in selected_set + else: # 自定义组 + new_defs[uid] = name + new_order.append(uid) + if manage_customs: + # 入表按表状态;未入表保持 BetterGI 原启用状态,避免误开用户已关闭的组 + new_enabled[uid] = ( + custom_enabled[name] + if name in custom_enabled + else bool(old_enabled.get(uid, True)) + ) + else: + new_enabled[uid] = bool(old_enabled.get(uid, True)) + + # 兜底:未出现在 TaskOrder 的自定义组不丢失 + for uid, name in old_defs.items(): + if ( + name + and name not in _BUILTIN_ONE_DRAGON_GROUPS + and uid not in new_defs + ): + new_defs[uid] = name + new_order.append(uid) + if manage_customs: + new_enabled[uid] = ( + custom_enabled[name] + if name in custom_enabled + else bool(old_enabled.get(uid, True)) + ) + else: + new_enabled[uid] = bool(old_enabled.get(uid, True)) + + # 按钮 ON 但配置缺失的内置组:补建并启用 + for name in selected: + if name not in present_builtin: + uid = str(uuid.uuid4()) + new_defs[uid] = name + new_order.append(uid) + new_enabled[uid] = True + + # 管理开启时:入表且启用但配置里缺失的自定义组:补建并启用 + if manage_customs: + for name, on in custom_enabled.items(): + if on and name not in new_defs.values(): + uid = str(uuid.uuid4()) + new_defs[uid] = name + new_order.append(uid) + new_enabled[uid] = True + + config["TaskDefinitions"] = new_defs + config["TaskOrder"] = new_order + config["TaskEnabledList"] = new_enabled + return config diff --git a/app/task/BetterGI/tools/one_dragon_report.py b/app/task/BetterGI/tools/one_dragon_report.py new file mode 100644 index 000000000..8d0b05543 --- /dev/null +++ b/app/task/BetterGI/tools/one_dragon_report.py @@ -0,0 +1,152 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""BetterGI「一条龙」分步执行报告解析。 + +从 BetterGI Serilog 日志逐条还原「一条龙」每一步做了什么、成没成功,供统计通知的分步报告。 +本模块刻意只依赖 ``re``(纯解析、零业务依赖),以便测试能像 ``one_dragon.py`` 那样经 +``importlib`` 按文件路径独立加载,绕开 ``app.task`` 包急切 import 触发的循环依赖。 + +日志结构(真实样本,Serilog 头行 ``[HH:mm:ss.fff] [INF] ...LoggerName`` 下方紧随消息行): + 一条龙任务执行: 1/3 ← 步开始(头行携带时间戳) + → "任务启动!" + 邮件:"全部领取" ← 任务名/进度描述;`→ "前往合成台" 开始` 同理 + [ERR] ... ← 步内可恢复异常(BGI 任务级异常会跳过步内子任务继续跑) + → "任务结束" ← 步正常结束 + 一条龙和配置组任务结束 ← 整条收尾(成败由 AutoProxy._one_dragon_sequence_done 判定) + +整条是否完成不属于本模块职责;本模块只负责「按执行顺序列出每一步 + 经过与成败」。 +""" + +import re + +# 一条龙进度行的正则:「一条龙任务执行: X/N」(可带空格/斜杠) +_BGI_STEP_PROGRESS_RE = re.compile(r"一条龙任务执行:\s*(\d+)\s*/\s*(\d+)") +# Serilog 头行时间戳:「[HH:mm:ss(.fff)] ...」 +_BGI_STEP_TIME_RE = re.compile(r"\[(\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]") +# 步内可恢复异常的信号(BGI TaskRunner 捕获后不 rethrow,一条龙继续跑下一条)。 +# 真实 BGI Serilog 里级别写在头行的第二个括号(``[..] [ERR] [Primary:..]``), +# 消息/异常原因另起一行;``任务执行异常``/``执行失败`` 则直接出现在消息行。 +_BGI_STEP_HEADER_ERR_HINTS = ("[ERR]", "[FTL]") +_BGI_STEP_ISSUE_HINTS = ("[ERR]", "任务执行异常", "执行失败") + + +def _clean_step_task(line: str) -> str: + """把日志里的一步描述提炼成简短任务名。 + + ``→ "前往合成台" 开始`` → ``前往合成台``;``邮件:"全部领取"`` → ``邮件``; + ``▶ "领取『每日委托』奖励" 未完成`` → ``领取『每日委托』奖励``。 + """ + s = line.strip() + s = re.sub(r"^[→▶]\s*", "", s) + s = s.replace('"', "").replace("“", "").replace("”", "") + s = re.sub(r"\s*(?:开始|结束)\s*$", "", s) + if ":" in s: + s = s.split(":", 1)[0] + elif ":" in s: + s = s.split(":", 1)[0] + return s.strip() or "未知任务" + + +def _parse_one_dragon_report(log: str) -> list[dict] | None: + """解析「一条龙」分步执行报告,按执行顺序返回步骤字典列表。 + + 每步字段:``index``/``total``(第几条/共几条)、``task``(任务名)、``start``/``end`` + (起止时间 HH:MM:SS)、``ok``(是否走完 ``→ "任务结束"``)、``issue_count``/``issue_text`` + (步内可恢复异常数目与首条原因摘要,无异常为空串)。 + 本会话未跑一条龙(无 ``一条龙任务执行`` 行)时返回 None,调用方据此省略分步区块。 + """ + lines = log.splitlines() + steps: list[dict] = [] + cur: dict | None = None + last_time = "" + pending_err = False # 上一条头行级别为 [ERR]/[FTL],紧随其后的消息行即异常原因 + + def finalize() -> dict: + assert cur is not None + return { + **cur, + "issue_count": len(cur["issue"]), + "issue_text": cur["issue"][0].strip() if cur["issue"] else "", + } + + for raw in lines: + m = _BGI_STEP_TIME_RE.match(raw) + if m: + last_time = m.group(1) + # 头行级别槽带 [ERR]/[FTL] 且当前在某条一步内 → 下一条消息行即异常原因 + pending_err = cur is not None and any( + h in raw for h in _BGI_STEP_HEADER_ERR_HINTS + ) + continue + line = raw.strip() + if not line: + continue + + if pending_err: + # 该消息属于上一条 [ERR] 头行:记入问题,不参与任务名解析 + pending_err = False + if cur is not None and "任务启动" not in line and "任务结束" not in line: + cur["issue"].append(line) + continue + + pm = _BGI_STEP_PROGRESS_RE.match(line) + if pm: + if cur is not None: # 上一步异常中断(无「任务结束」)也先收尾再开新步 + cur["end"] = cur["end"] or last_time + cur["ok"] = False + steps.append(finalize()) + cur = { + "index": int(pm.group(1)), + "total": int(pm.group(2)), + "task": "", + "start": last_time, + "end": "", + "ok": True, + "issue": [], + } + continue + if cur is None: + continue + + if line == '→ "任务结束"': + # 走完「任务结束」即该步成功;步内 [ERR] 是 BGI 可恢复异常,只记为 issue, + # 不把本可完成的一条龙某步误判失败(与 _one_dragon_sequence_done 语义一致)。 + cur["end"] = last_time + cur["ok"] = True + steps.append(finalize()) + cur = None + elif "任务启动" in line: + continue + else: + if not cur["task"]: + cur["task"] = _clean_step_task(line) + if any(h in line for h in _BGI_STEP_ISSUE_HINTS): + cur["issue"].append(line) + + if cur is not None: # 日志结束仍停留在某一步(未收尾)→ 该步未完成 + cur["end"] = cur["end"] or last_time + cur["ok"] = False + steps.append(finalize()) + + if not steps: + return None + # 移除解析过程使用的内部 issue 原始行,避免携带多余细节(已汇总为 issue_count/text) + for s in steps: + s.pop("issue", None) + return steps \ No newline at end of file diff --git a/app/task/__init__.py b/app/task/__init__.py index 8e7fc2271..f7fcd6749 100644 --- a/app/task/__init__.py +++ b/app/task/__init__.py @@ -42,6 +42,7 @@ "OkwwManager": (".Okww", "OkwwManager"), "OkNteManager": (".OkNte", "OkNteManager"), "HSRManager": (".HSR", "HSRManager"), + "BetterGIManager": (".BetterGI", "BetterGIManager"), "MaaFWEmbeddedManager": (".MaaFW.embedded_manager", "MaaFWEmbeddedManager"), } @@ -58,7 +59,6 @@ def __getattr__(name: str): def __dir__() -> list[str]: return sorted(set(globals()) | set(_LAZY_EXPORTS)) - __all__ = [ "MaaManager", "SrcManager", @@ -68,5 +68,6 @@ def __dir__() -> list[str]: "OkwwManager", "OkNteManager", "HSRManager", + "BetterGIManager", "MaaFWEmbeddedManager", ] diff --git a/app/utils/constants.py b/app/utils/constants.py index c58437bed..d51dedea1 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -43,6 +43,7 @@ "M9AUserConfig": "M9A", "MaaFWConfig": "MFW", "HSRConfig": "HSR", + "BetterGIConfig": "BetterGI", } """配置类型映射表""" diff --git a/app/utils/platform/common/process.py b/app/utils/platform/common/process.py index e896c71dd..9388b8de2 100644 --- a/app/utils/platform/common/process.py +++ b/app/utils/platform/common/process.py @@ -20,6 +20,8 @@ # Contact: DLmaster_361@163.com +import os +import subprocess import time import psutil import asyncio @@ -188,6 +190,7 @@ async def open_process( stdout: int = asyncio.subprocess.DEVNULL, stderr: int = asyncio.subprocess.DEVNULL, null_stream_to_pipe: bool = False, + elevated: bool = False, ) -> None: """ 启动子进程并跟踪目标进程 @@ -201,6 +204,7 @@ async def open_process( stdout (int): 标准输出重定向选项, 默认为 asyncio.subprocess.DEVNULL stderr (int): 标准错误重定向选项, 默认为 asyncio.subprocess.DEVNULL null_stream_to_pipe (bool): 若为 True, 将设为 DEVNULL 的 stdout/stderr 替换为一条自动销毁输出的标准流管道。 + elevated (bool): 若为 True 且在 Windows 上, 以管理员权限启动进程(触发 UAC),此时不直接持有子进程句柄,依赖 target_process 追踪。 """ if await self.is_running(): @@ -217,6 +221,20 @@ async def open_process( await self.clear() + if elevated and os.name == "nt": + # 以管理员权限启动进程(触发 UAC),ShellExecute 不返回子进程句柄, + # 因此无法直接持有 process,仅支持通过 target_process 追踪。 + await asyncio.get_running_loop().run_in_executor( + None, self._open_process_elevated, program, args, cwd + ) + if target_process is not None: + await self.search_process( + target_process, + 60.0, + min_create_time=time.time(), + ) + return + # 若指定了 null_stream_to_pipe, 将 stdout/stderr 为 DEVNULL 的流替换为管道, 并在后台消费以防止阻塞 drain_streams = [] if null_stream_to_pipe: @@ -251,6 +269,34 @@ async def open_process( min_create_time=time.time(), ) + @staticmethod + def _open_process_elevated( + program: Path | str, args: tuple[str, ...], cwd: Path | None + ) -> None: + """以管理员权限启动进程(触发 UAC),ShellExecute 成功时返回码大于 32。 + + win32 仅在 Windows 路径才使用,故延迟导入,保证本平台通用模块在 + 非 Windows 上也能安全导入。 + """ + import win32api + import win32con + + parameters = subprocess.list2cmdline(list(args)) if args else None + working_directory = str(cwd) if cwd is not None else None + + ret = win32api.ShellExecute( + None, + "runas", + str(program), + parameters, + working_directory, + win32con.SW_SHOWNORMAL, + ) + if ret <= 32: + raise RuntimeError( + f"以管理员权限启动进程失败: {program} (ShellExecute 返回 {ret})" + ) + async def _drain(self, stream: asyncio.StreamReader) -> None: """ 消费子进程标准流, 丢弃写入, 防止管道背压阻塞子进程。 diff --git a/frontend/.yarnrc.yml b/frontend/.yarnrc.yml index 3186f3f07..9620c8014 100644 --- a/frontend/.yarnrc.yml +++ b/frontend/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules + +npmRegistryServer: "https://registry.npmmirror.com" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ab68b6b91..20c23d854 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,6 +10,17 @@ export type { OpenAPIConfig } from './core/OpenAPI'; export type { ADBScreenshotIn } from './models/ADBScreenshotIn'; export type { ADBScreenshotOut } from './models/ADBScreenshotOut'; export type { BackendHealthOut } from './models/BackendHealthOut'; +export type { BetterGIConfig } from './models/BetterGIConfig'; +export type { BetterGIConfig_Info } from './models/BetterGIConfig_Info'; +export type { BetterGIConfig_Run } from './models/BetterGIConfig_Run'; +export type { BetterGICustomGroupOut } from './models/BetterGICustomGroupOut'; +export type { BetterGICustomGroupsOut } from './models/BetterGICustomGroupsOut'; +export type { BetterGIUserConfig } from './models/BetterGIUserConfig'; +export type { BetterGIUserConfig_Data } from './models/BetterGIUserConfig_Data'; +export type { BetterGIUserConfig_Info } from './models/BetterGIUserConfig_Info'; +export type { BetterGIUserConfig_Notify } from './models/BetterGIUserConfig_Notify'; +export type { BetterGIUserConfig_OneDragon } from './models/BetterGIUserConfig_OneDragon'; +export type { BetterGIUserConfig_Task } from './models/BetterGIUserConfig_Task'; export type { Body_batch_update_oknte_configs_api_scripts_oknte_configs_batch_update_post } from './models/Body_batch_update_oknte_configs_api_scripts_oknte_configs_batch_update_post'; export type { Body_update_oknte_config_api_scripts_oknte_configs_update_post } from './models/Body_update_oknte_config_api_scripts_oknte_configs_update_post'; export type { CheckImageAllIn } from './models/CheckImageAllIn'; @@ -320,6 +331,7 @@ export type { WSTaskUserInfoData } from './models/WSTaskUserInfoData'; export { Service } from './services/Service'; export { ActionService } from './services/ActionService'; export { AddService } from './services/AddService'; +export { BettergiService } from './services/BettergiService'; export { DeleteService } from './services/DeleteService'; export { GameSignService } from './services/GameSignService'; export { GetService } from './services/GetService'; diff --git a/frontend/src/api/models/BetterGIConfig.ts b/frontend/src/api/models/BetterGIConfig.ts new file mode 100644 index 000000000..c9e0c1e31 --- /dev/null +++ b/frontend/src/api/models/BetterGIConfig.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BetterGIConfig_Info } from './BetterGIConfig_Info'; +import type { BetterGIConfig_Run } from './BetterGIConfig_Run'; +import type { BetterGIConfig_Game } from './BetterGIConfig_Game'; +export type BetterGIConfig = { + /** + * 脚本基础信息 + */ + Info?: (BetterGIConfig_Info | null); + /** + * 运行配置 + */ + Run?: (BetterGIConfig_Run | null); + /** + * 游戏配置 + */ + Game?: (BetterGIConfig_Game | null); +}; diff --git a/frontend/src/api/models/BetterGIConfig_Game.ts b/frontend/src/api/models/BetterGIConfig_Game.ts new file mode 100644 index 000000000..df2d1706d --- /dev/null +++ b/frontend/src/api/models/BetterGIConfig_Game.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 游戏配置 + */ +export type BetterGIConfig_Game = { + /** + * 控制器:电脑端-前台/电脑端-云原神/电脑端-桌面分身 + */ + Controller?: (string | null); + /** + * 任务结束后是否关闭游戏 + */ + CloseOnFinish?: (boolean | null); +}; diff --git a/frontend/src/api/models/BetterGIConfig_Info.ts b/frontend/src/api/models/BetterGIConfig_Info.ts new file mode 100644 index 000000000..9b45cb716 --- /dev/null +++ b/frontend/src/api/models/BetterGIConfig_Info.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 脚本基础信息(复用通用字段) + */ +export type BetterGIConfig_Info = { + /** + * 脚本名称 + */ + Name?: (string | null); + /** + * 脚本根目录 + */ + RootPath?: (string | null); +}; diff --git a/frontend/src/api/models/BetterGIConfig_Run.ts b/frontend/src/api/models/BetterGIConfig_Run.ts new file mode 100644 index 000000000..08d1edb71 --- /dev/null +++ b/frontend/src/api/models/BetterGIConfig_Run.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 运行配置(复用通用字段) + */ +export type BetterGIConfig_Run = { + /** + * 每日代理次数限制 + */ + ProxyTimesLimit?: (number | null); + /** + * 重试次数限制 + */ + RunTimesLimit?: (number | null); + /** + * 日志超时限制 + */ + RunTimeLimit?: (number | null); +}; diff --git a/frontend/src/api/models/BetterGICustomGroupOut.ts b/frontend/src/api/models/BetterGICustomGroupOut.ts new file mode 100644 index 000000000..525226bad --- /dev/null +++ b/frontend/src/api/models/BetterGICustomGroupOut.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BetterGICustomGroupOut = { + /** + * 配置组名称 + */ + name: string; + /** + * 启用状态 + */ + enabled: boolean; +}; \ No newline at end of file diff --git a/frontend/src/api/models/BetterGICustomGroupsOut.ts b/frontend/src/api/models/BetterGICustomGroupsOut.ts new file mode 100644 index 000000000..2813ecf4f --- /dev/null +++ b/frontend/src/api/models/BetterGICustomGroupsOut.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BetterGICustomGroupOut } from './BetterGICustomGroupOut'; +export type BetterGICustomGroupsOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 一条龙自定义配置组列表 + */ + data: Array; +}; \ No newline at end of file diff --git a/frontend/src/api/models/BetterGIUserConfig.ts b/frontend/src/api/models/BetterGIUserConfig.ts new file mode 100644 index 000000000..d30529302 --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig.ts @@ -0,0 +1,36 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BetterGIUserConfig_Data } from './BetterGIUserConfig_Data'; +import type { BetterGIUserConfig_Info } from './BetterGIUserConfig_Info'; +import type { BetterGIUserConfig_Notify } from './BetterGIUserConfig_Notify'; +import type { BetterGIUserConfig_OneDragon } from './BetterGIUserConfig_OneDragon'; +import type { BetterGIUserConfig_Switch } from './BetterGIUserConfig_Switch'; +import type { BetterGIUserConfig_Task } from './BetterGIUserConfig_Task'; +export type BetterGIUserConfig = { + /** + * 用户信息 + */ + Info?: (BetterGIUserConfig_Info | null); + /** + * 任务配置 + */ + Task?: (BetterGIUserConfig_Task | null); + /** + * 切换账号配置 + */ + Switch?: (BetterGIUserConfig_Switch | null); + /** + * 一条龙配置 + */ + OneDragon?: (BetterGIUserConfig_OneDragon | null); + /** + * 用户数据 + */ + Data?: (BetterGIUserConfig_Data | null); + /** + * 单独通知 + */ + Notify?: (BetterGIUserConfig_Notify | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_Data.ts b/frontend/src/api/models/BetterGIUserConfig_Data.ts new file mode 100644 index 000000000..cd4617950 --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_Data.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 用户数据(复用通用字段) + */ +export type BetterGIUserConfig_Data = { + /** + * 上次代理日期 + */ + LastProxyDate?: (string | null); + /** + * 代理次数 + */ + ProxyTimes?: (number | null); + /** + * 上次代理状态(未知/成功/失败) + */ + LastProxyStatus?: (string | null); + /** + * 上次运行的一条龙配置名 + */ + LastOneDragonConfig?: (string | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_Info.ts b/frontend/src/api/models/BetterGIUserConfig_Info.ts new file mode 100644 index 000000000..732a89c24 --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_Info.ts @@ -0,0 +1,57 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 用户信息(原生 GUI 直控,账号由 BetterGI 原生管理) + */ +export type BetterGIUserConfig_Info = { + /** + * 用户名 + */ + Name?: (string | null); + /** + * 用户状态 + */ + Status?: (boolean | null); + /** + * 账号 + */ + Id?: (string | null); + /** + * 密码 + */ + Password?: (string | null); + /** + * 剩余天数 + */ + RemainedDay?: (number | null); + /** + * 是否在任务前执行脚本 + */ + IfScriptBeforeTask?: (boolean | null); + /** + * 任务前脚本路径 + */ + ScriptBeforeTask?: (string | null); + /** + * 是否在任务后执行脚本 + */ + IfScriptAfterTask?: (boolean | null); + /** + * 任务后脚本路径 + */ + ScriptAfterTask?: (string | null); + /** + * 备注 + */ + Notes?: (string | null); + /** + * 用户标签列表(JSON字符串,TagItem的dict列表) + */ + Tag?: (string | null); + /** + * 是否使用用户独立一条龙配置 + */ + IfUseMasConfig?: (boolean | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_Notify.ts b/frontend/src/api/models/BetterGIUserConfig_Notify.ts new file mode 100644 index 000000000..aa291d0ab --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_Notify.ts @@ -0,0 +1,33 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * BetterGI 用户通知(复用通用字段) + */ +export type BetterGIUserConfig_Notify = { + /** + * 是否启用通知 + */ + Enabled?: (boolean | null); + /** + * 是否发送统计信息 + */ + IfSendStatistic?: (boolean | null); + /** + * 是否发送邮件通知 + */ + IfSendMail?: (boolean | null); + /** + * 邮件接收地址 + */ + ToAddress?: (string | null); + /** + * 是否使用Server酱推送 + */ + IfServerChan?: (boolean | null); + /** + * ServerChanKey + */ + ServerChanKey?: (string | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_OneDragon.ts b/frontend/src/api/models/BetterGIUserConfig_OneDragon.ts new file mode 100644 index 000000000..26d37a93e --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_OneDragon.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BetterGIUserConfig_OneDragon = { + /** + * 一条龙要执行的内置配置组名列表 + */ + Groups?: (Array | null); + /** + * 领取奖励队伍 + */ + DailyRewardPartyName?: (string | null); + /** + * 战斗队伍 + */ + PartyName?: (string | null); + /** + * 战斗策略 + */ + AutoBossStrategyName?: (string | null); + /** + * 是否管理自定义配置组(总开关) + */ + IfUseCustomGroups?: (boolean | null); + /** + * 自定义配置组 JSON 列表字符串,元素含 name/enabled + */ + CustomGroups?: (string | Array | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_Switch.ts b/frontend/src/api/models/BetterGIUserConfig_Switch.ts new file mode 100644 index 000000000..06b90fc94 --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_Switch.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BetterGIUserConfig_Switch = { + /** + * 游戏服务器:官服/B服/亚服/欧服/美服/港澳台服 + */ + Resource?: (string | null); + /** + * 账号 UID(可不填,切换前识别一致将不执行切换动作) + */ + Uid?: (string | null); +}; diff --git a/frontend/src/api/models/BetterGIUserConfig_Task.ts b/frontend/src/api/models/BetterGIUserConfig_Task.ts new file mode 100644 index 000000000..13a399016 --- /dev/null +++ b/frontend/src/api/models/BetterGIUserConfig_Task.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BetterGIUserConfig_Task = { + /** + * BetterGI「一条龙」配置名 + */ + OneDragonConfigName?: (string | null); +}; diff --git a/frontend/src/api/models/ScriptCreateIn.ts b/frontend/src/api/models/ScriptCreateIn.ts index b54762434..33e64b91d 100644 --- a/frontend/src/api/models/ScriptCreateIn.ts +++ b/frontend/src/api/models/ScriptCreateIn.ts @@ -4,7 +4,7 @@ /* eslint-disable */ export type ScriptCreateIn = { /** - * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本 + * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本, BetterGI脚本 */ type: ScriptCreateIn.type; /** @@ -14,7 +14,7 @@ export type ScriptCreateIn = { }; export namespace ScriptCreateIn { /** - * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本 + * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, OK-NTE脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本, HSR脚本, BetterGI脚本 */ export enum type { MAA = 'MAA', @@ -26,6 +26,7 @@ export namespace ScriptCreateIn { M9A = 'M9A', MAA_FW = 'MaaFW', HSR = 'HSR', + BETTER_GI = 'BetterGI', } } diff --git a/frontend/src/api/models/ScriptCreateOut.ts b/frontend/src/api/models/ScriptCreateOut.ts index 244ba235f..fdda372cd 100644 --- a/frontend/src/api/models/ScriptCreateOut.ts +++ b/frontend/src/api/models/ScriptCreateOut.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIConfig } from './BetterGIConfig'; import type { GeneralConfig } from './GeneralConfig'; import type { HSRConfig } from './HSRConfig'; import type { M9AConfig } from './M9AConfig'; @@ -31,6 +32,6 @@ export type ScriptCreateOut = { /** * 脚本配置数据 */ - data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | OkNteConfig | MaaEndConfig | M9AConfig | MaaFWConfig | HSRConfig); + data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | OkNteConfig | MaaEndConfig | M9AConfig | MaaFWConfig | HSRConfig | BetterGIConfig); }; diff --git a/frontend/src/api/models/ScriptGetOut.ts b/frontend/src/api/models/ScriptGetOut.ts index 4f1ed8abf..95df0cc63 100644 --- a/frontend/src/api/models/ScriptGetOut.ts +++ b/frontend/src/api/models/ScriptGetOut.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIConfig } from './BetterGIConfig'; import type { GeneralConfig } from './GeneralConfig'; import type { HSRConfig } from './HSRConfig'; import type { M9AConfig } from './M9AConfig'; @@ -32,6 +33,6 @@ export type ScriptGetOut = { /** * 脚本数据字典, key来自于index列表的uid */ - data: Record; + data: Record; }; diff --git a/frontend/src/api/models/ScriptIndexItem.ts b/frontend/src/api/models/ScriptIndexItem.ts index cbd5b84aa..ea0763828 100644 --- a/frontend/src/api/models/ScriptIndexItem.ts +++ b/frontend/src/api/models/ScriptIndexItem.ts @@ -26,6 +26,7 @@ export namespace ScriptIndexItem { M9ACONFIG = 'M9AConfig', MAA_FWCONFIG = 'MaaFWConfig', HSRCONFIG = 'HSRConfig', + BETTER_GICONFIG = 'BetterGIConfig', } } diff --git a/frontend/src/api/models/ScriptUpdateIn.ts b/frontend/src/api/models/ScriptUpdateIn.ts index 578b210b4..1d17e3c40 100644 --- a/frontend/src/api/models/ScriptUpdateIn.ts +++ b/frontend/src/api/models/ScriptUpdateIn.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIConfig } from './BetterGIConfig'; import type { GeneralConfig } from './GeneralConfig'; import type { HSRConfig } from './HSRConfig'; import type { M9AConfig } from './M9AConfig'; @@ -19,6 +20,6 @@ export type ScriptUpdateIn = { /** * 脚本更新数据 */ - data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | OkNteConfig | MaaEndConfig | M9AConfig | MaaFWConfig | HSRConfig); + data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | OkNteConfig | MaaEndConfig | M9AConfig | MaaFWConfig | HSRConfig | BetterGIConfig); }; diff --git a/frontend/src/api/models/UserCreateOut.ts b/frontend/src/api/models/UserCreateOut.ts index c9b0ebe22..a807a7f60 100644 --- a/frontend/src/api/models/UserCreateOut.ts +++ b/frontend/src/api/models/UserCreateOut.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIUserConfig } from './BetterGIUserConfig'; import type { GeneralUserConfig } from './GeneralUserConfig'; import type { HSRUserConfig } from './HSRUserConfig'; import type { M9AUserConfig } from './M9AUserConfig'; @@ -31,6 +32,6 @@ export type UserCreateOut = { /** * 用户配置数据 */ - data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | OkNteUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | HSRUserConfig); + data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | OkNteUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | HSRUserConfig | BetterGIUserConfig); }; diff --git a/frontend/src/api/models/UserGetOut.ts b/frontend/src/api/models/UserGetOut.ts index 3629108ad..2e5836493 100644 --- a/frontend/src/api/models/UserGetOut.ts +++ b/frontend/src/api/models/UserGetOut.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIUserConfig } from './BetterGIUserConfig'; import type { GeneralUserConfig } from './GeneralUserConfig'; import type { HSRUserConfig } from './HSRUserConfig'; import type { M9AUserConfig } from './M9AUserConfig'; @@ -32,6 +33,6 @@ export type UserGetOut = { /** * 用户数据字典, key来自于index列表的uid */ - data: Record; + data: Record; }; diff --git a/frontend/src/api/models/UserIndexItem.ts b/frontend/src/api/models/UserIndexItem.ts index 1a07fe897..41bb03445 100644 --- a/frontend/src/api/models/UserIndexItem.ts +++ b/frontend/src/api/models/UserIndexItem.ts @@ -26,6 +26,7 @@ export namespace UserIndexItem { M9AUSER_CONFIG = 'M9AUserConfig', MAA_FWUSER_CONFIG = 'MaaFWUserConfig', HSRUSER_CONFIG = 'HSRUserConfig', + BETTER_GIUSER_CONFIG = 'BetterGIUserConfig', } } diff --git a/frontend/src/api/models/UserUpdateIn.ts b/frontend/src/api/models/UserUpdateIn.ts index a596c8ca3..32de5e653 100644 --- a/frontend/src/api/models/UserUpdateIn.ts +++ b/frontend/src/api/models/UserUpdateIn.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { BetterGIUserConfig } from './BetterGIUserConfig'; import type { GeneralUserConfig } from './GeneralUserConfig'; import type { HSRUserConfig } from './HSRUserConfig'; import type { M9AUserConfig } from './M9AUserConfig'; @@ -23,6 +24,6 @@ export type UserUpdateIn = { /** * 用户更新数据 */ - data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | OkNteUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | HSRUserConfig); + data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | OkNteUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | HSRUserConfig | BetterGIUserConfig); }; diff --git a/frontend/src/api/services/BettergiService.ts b/frontend/src/api/services/BettergiService.ts new file mode 100644 index 000000000..202fc0634 --- /dev/null +++ b/frontend/src/api/services/BettergiService.ts @@ -0,0 +1,80 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BetterGICustomGroupsOut } from '../models/BetterGICustomGroupsOut'; +import type { ComboBoxOut } from '../models/ComboBoxOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class BettergiService { + /** + * 获取 BetterGI 自动战斗策略选项 + * 返回 BetterGI 可用自动战斗策略:内置「根据队伍自动选择」+ {RootPath}/User/AutoFight/*.txt 文件名。 + * @param scriptId + * @returns ComboBoxOut Successful Response + * @throws ApiError + */ + public static getBettergiStrategiesApiApiScriptsBettergiStrategiesGet( + scriptId?: (string | null), + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/scripts/bettergi/strategies', + query: { + 'scriptId': scriptId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 获取 BetterGI 一条龙配置名列表 + * 返回 BetterGI 可选一条龙配置名:{RootPath}/User/OneDragon/*.json 文件名(默认配置置顶)。 + * @param scriptId + * @returns ComboBoxOut Successful Response + * @throws ApiError + */ + public static getBettergiOneDragonConfigsApiApiScriptsBettergiOneDragonConfigsGet( + scriptId?: (string | null), + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/scripts/bettergi/one-dragon/configs', + query: { + 'scriptId': scriptId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 获取 BetterGI 一条龙自定义配置组 + * 返回指定一条龙配置里的自定义配置组(非内置 8 组)及其启用状态,供前端表格自动加载。 + * @param scriptId + * @param configName + * @param useMasConfig 用户独立配置开启时改读 MAS 运行时槽位「MAS独立配置」 + * @returns BetterGICustomGroupsOut Successful Response + * @throws ApiError + */ + public static getBettergiOneDragonCustomGroupsApiApiScriptsBettergiOneDragonCustomGroupsGet( + scriptId: string, + configName?: (string | null), + useMasConfig?: boolean, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/scripts/bettergi/one-dragon/custom-groups', + query: { + 'scriptId': scriptId, + 'configName': configName, + 'useMasConfig': useMasConfig, + }, + errors: { + 422: `Validation Error`, + }, + }); + } +} \ No newline at end of file diff --git a/frontend/src/assets/bettergi.ico b/frontend/src/assets/bettergi.ico new file mode 100644 index 000000000..35439042e Binary files /dev/null and b/frontend/src/assets/bettergi.ico differ diff --git a/frontend/src/assets/satellite-icons/bettergi.png b/frontend/src/assets/satellite-icons/bettergi.png new file mode 100644 index 000000000..b8f3957fa Binary files /dev/null and b/frontend/src/assets/satellite-icons/bettergi.png differ diff --git a/frontend/src/components/ScriptTable.vue b/frontend/src/components/ScriptTable.vue index 3d65874ad..d1721c36d 100644 --- a/frontend/src/components/ScriptTable.vue +++ b/frontend/src/components/ScriptTable.vue @@ -75,6 +75,12 @@ alt="MFW" class="script-logo" /> +
@@ -372,7 +378,8 @@ v-if=" script.type === 'General' || script.type === 'Okww' || - script.type === 'OkNte' + script.type === 'OkNte' || + script.type === 'BetterGI' " class="user-info-tags" > @@ -753,6 +760,7 @@ const SCRIPT_TYPE_TAG_COLORS: Record = { Okww: 'blue', OkNte: 'blue', HSR: 'purple', + BetterGI: 'gold', General: 'green', } diff --git a/frontend/src/composables/satellite-config.ts b/frontend/src/composables/satellite-config.ts index 94a13486e..3f532147a 100644 --- a/frontend/src/composables/satellite-config.ts +++ b/frontend/src/composables/satellite-config.ts @@ -27,9 +27,10 @@ const filenameToScriptType: Record = { 'ok-nte.ico': 'OkNte', 'hsr.png': 'HSR', 'maafw.png': 'MaaFW', + 'bettergi.ico': 'BetterGI', } -const iconFilenames: ScriptType[] = ['MAA', 'SRC', 'M9A', 'MaaEnd', 'Okww', 'OkNte', 'HSR', 'MaaFW'] +const iconFilenames: ScriptType[] = ['MAA', 'SRC', 'M9A', 'MaaEnd', 'Okww', 'OkNte', 'HSR', 'MaaFW', 'BetterGI'] export const satelliteModules: SatelliteModule[] = iconFilenames .map(type => { diff --git a/frontend/src/composables/useBettergiCustomGroups.ts b/frontend/src/composables/useBettergiCustomGroups.ts new file mode 100644 index 000000000..2f76ccee3 --- /dev/null +++ b/frontend/src/composables/useBettergiCustomGroups.ts @@ -0,0 +1,189 @@ +// BetterGI 自定义配置组管理(名称 + 启用开关,表格化管理) +import { computed, reactive, ref } from 'vue' +import { message } from 'ant-design-vue' +import type { TableColumnsType } from 'ant-design-vue' +import { BettergiService } from '@/api' + +const logger = window.electronAPI.getLogger('BetterGI自定义配置组') + +export interface BettergiCustomGroupRow { + name: string + enabled: boolean +} + +export interface BettergiCustomGroupOptions { + /** 所在脚本,用于从 BetterGI 现有配置读取自定义组 */ + scriptId: string + /** 父组件表单 OneDragon 区块的读取器——须是 getter,父组件在 loadUser 时会整体替换 + * formData.OneDragon,若传静态引用则本 composable 一直读写失效的旧对象,开关不再生效 */ + oneDragon: () => { CustomGroups: string | any[]; IfUseCustomGroups: boolean } + /** 一条龙配置名(Task.OneDragonConfigName),决定从 BetterGI 哪份配置读取自定义组 */ + configName: () => string + /** 用户独立配置(Info.IfUseMasConfig):为 true 时改读 MAS 槽位「MAS独立配置」而非同名实配 */ + masConfig: () => boolean + /** 是否处于「脚本直控配置」之外(可编辑)。为 true 时允许交互 */ + editable: () => boolean + /** 保存某字段到后端(形如 'OneDragon.CustomGroups'),返回是否保存成功 */ + saveField: (key: string, value: unknown) => Promise +} + +/** + * BetterGI 自定义配置组管理:总开关、表格(名称 + 启用)、批量删除与添加。 + * + * 状态与 `oneDragon.CustomGroups`(JSON 字符串)保持同步:每次 `persist` 既写回表单 + * 又经 `saveField` 落库;首次开启总开关且表格为空时,从 BetterGI 现有配置自动加载。 + */ +export function useBettergiCustomGroups(options: BettergiCustomGroupOptions) { + const { scriptId, oneDragon: getOneDragon, configName, masConfig, editable, saveField } = + options + const oneDragon = () => getOneDragon() + + const table = ref([]) + const selectedKeys = ref([]) + const modal = reactive({ + open: false, + name: '', + saving: false, + // 「添加配置组」弹窗的下拉候选项:BGI 现有的自定义配置组名(已入表的排除) + addOptions: [] as Array<{ value: string; label: string }>, + }) + + const columns: TableColumnsType = [ + { title: '配置组名称', dataIndex: 'name', key: 'name' }, + { title: '是否启用', dataIndex: 'enabled', key: 'enabled', width: 120 }, + ] + + const rowSelection = computed(() => ({ + selectedRowKeys: selectedKeys.value, + onChange: (keys: (string | number)[]) => { + selectedKeys.value = keys.map(String) + }, + })) + + const parseList = (raw: unknown): BettergiCustomGroupRow[] => { + let arr: unknown = raw + if (typeof raw === 'string') { + try { + arr = JSON.parse(raw) + } catch { + return [] + } + } + if (!Array.isArray(arr)) return [] + return arr + .filter((x): x is Record => !!x && typeof x.name === 'string') + .map(x => ({ name: x.name as string, enabled: Boolean(x.enabled) })) + } + + const syncFromForm = () => { + table.value = parseList(oneDragon().CustomGroups) + } + + const mergeRows = (rows: BettergiCustomGroupRow[]) => { + const existing = new Map(table.value.map(r => [r.name, r])) + for (const r of rows) { + if (!existing.has(r.name)) existing.set(r.name, r) + } + table.value = Array.from(existing.values()) + } + + const fetchBettergiGroups = async (): Promise => { + try { + const resp = + await BettergiService.getBettergiOneDragonCustomGroupsApiApiScriptsBettergiOneDragonCustomGroupsGet( + scriptId, + configName(), + masConfig() + ) + return resp.code === 200 && Array.isArray(resp.data) ? resp.data : [] + } catch (e) { + logger.error(e instanceof Error ? e.message : String(e)) + return [] + } + } + + const loadFromBettergi = async () => { + mergeRows(await fetchBettergiGroups()) + } + + /** 拉取「添加配置组」下拉候选:BGI 现有自定义组名,剔除已入表的 */ + const refreshAddOptions = async () => { + const existing = new Set(table.value.map(r => r.name)) + const groups = await fetchBettergiGroups() + modal.addOptions = groups + .filter(g => !existing.has(g.name)) + .map(g => ({ value: g.name, label: g.name })) + } + + const persist = () => { + const str = JSON.stringify(table.value) + oneDragon().CustomGroups = str + void saveField('OneDragon.CustomGroups', str) + } + + const toggleMaster = () => { + if (!editable()) return + const cur = oneDragon() + const next = !cur.IfUseCustomGroups + cur.IfUseCustomGroups = next + void saveField('OneDragon.IfUseCustomGroups', next) + // 首次开启且表格为空时,从 BetterGI 自动加载现有自定义组 + if (next && table.value.length === 0) { + void loadFromBettergi() + } + } + + const openAdd = async () => { + modal.name = '' + // 每次打开都刷新候选,保证已新增/删除的组名在下拉里即时反映 + await refreshAddOptions() + modal.open = true + } + + const confirmAdd = async () => { + const name = modal.name.trim() + if (!name) { + message.warning('请输入配置组名称') + return + } + if (table.value.some(r => r.name === name)) { + message.warning('该配置组已存在') + return + } + modal.saving = true + try { + table.value.push({ name, enabled: true }) + persist() + modal.open = false + } finally { + modal.saving = false + } + } + + const deleteSelected = () => { + const removed = new Set(selectedKeys.value) + table.value = table.value.filter(r => !removed.has(r.name)) + selectedKeys.value = [] + persist() + } + + const toggleEnabled = (record: BettergiCustomGroupRow) => { + record.enabled = !record.enabled + persist() + } + + return { + table, + selectedKeys, + modal, + columns, + rowSelection, + syncFromForm, + loadFromBettergi, + toggleMaster, + openAdd, + confirmAdd, + deleteSelected, + toggleEnabled, + } +} diff --git a/frontend/src/composables/useBettergiGuiSession.ts b/frontend/src/composables/useBettergiGuiSession.ts new file mode 100644 index 000000000..bff240641 --- /dev/null +++ b/frontend/src/composables/useBettergiGuiSession.ts @@ -0,0 +1,140 @@ +// BetterGI 原生设置会话(原生 GUI 直控) +import { ref } from 'vue' +import { message } from 'ant-design-vue' +import { Service } from '@/api' +import { TaskCreateIn } from '@/api/models/TaskCreateIn' +import { useWebSocket } from '@/composables/useWebSocket' + +const logger = window.electronAPI.getLogger('BetterGI配置会话') + +/** + * BetterGI 原生设置会话:打开 BetterGI 原生界面并遮罩等待,保存快照后结束会话。 + * + * 抽自此前的日志与进度:`BetterGIUserEdit.vue` 只负责组合各区域,会话生命周期 + * (WebSocket 订阅、遮罩、30 分钟超时自动保存、卸载清理)全部收敛于此。 + */ +export function useBettergiGuiSession() { + const { subscribe, unsubscribe } = useWebSocket() + + const bettergiConfigLoading = ref(false) + const bettergiSubscriptionId = ref(null) + const bettergiWebsocketId = ref(null) + const showBettergiConfigMask = ref(false) + const stoppingBettergiConfig = ref(false) + + // 原生设置会话超时自动保存的时长与提前提醒的提前量(避免无预告直接中断会话) + const SESSION_TIMEOUT_MS = 30 * 60 * 1000 + const SESSION_WARNING_ADVANCE_MS = 30 * 1000 + + let bettergiConfigTimeout: number | null = null + let bettergiConfigWarningTimeout: number | null = null + + const clearSession = () => { + if (bettergiSubscriptionId.value) { + unsubscribe(bettergiSubscriptionId.value) + bettergiSubscriptionId.value = null + } + bettergiWebsocketId.value = null + showBettergiConfigMask.value = false + if (bettergiConfigTimeout) { + window.clearTimeout(bettergiConfigTimeout) + bettergiConfigTimeout = null + } + if (bettergiConfigWarningTimeout) { + window.clearTimeout(bettergiConfigWarningTimeout) + bettergiConfigWarningTimeout = null + } + } + + const stopSession = async (keepOnFailure = false): Promise => { + const taskId = bettergiWebsocketId.value + if (!taskId) { + clearSession() + return true + } + if (stoppingBettergiConfig.value) return false + + stoppingBettergiConfig.value = true + try { + const response = await Service.stopTaskApiDispatchStopPost({ taskId }) + if (response.code !== 200) { + throw new Error(response.message || '停止 BetterGI 设置失败') + } + clearSession() + return true + } catch (e) { + logger.error(e instanceof Error ? e.message : String(e)) + if (keepOnFailure) return false + clearSession() + return false + } finally { + stoppingBettergiConfig.value = false + } + } + + const startSession = async (userId: string): Promise => { + try { + bettergiConfigLoading.value = true + const response = await Service.addTaskApiDispatchStartPost({ + taskId: userId, + mode: TaskCreateIn.mode.SCRIPT_CONFIG, + }) + if (response.code !== 200 || !response.taskId) { + throw new Error(response.message || '启动 BetterGI 设置失败') + } + + showBettergiConfigMask.value = true + bettergiWebsocketId.value = response.taskId + const subscriptionId = subscribe({ id: response.taskId }, (wsMessage: any) => { + if (wsMessage.type === 'error') { + message.error(`BetterGI 设置连接失败: ${String(wsMessage.data)}`) + void stopSession() + return + } + if (wsMessage.type === 'Info' && wsMessage.data?.Error) { + message.error(`BetterGI 设置失败: ${String(wsMessage.data.Error)}`) + void stopSession() + return + } + if (wsMessage.type === 'Signal' && wsMessage.data?.Accomplish !== undefined) { + clearSession() + } + }) + bettergiSubscriptionId.value = subscriptionId + message.success('已打开 BetterGI 设置') + bettergiConfigWarningTimeout = window.setTimeout(() => { + message.warning('BetterGI 设置会话即将超时,30 秒后自动保存') + }, SESSION_TIMEOUT_MS - SESSION_WARNING_ADVANCE_MS) + bettergiConfigTimeout = window.setTimeout(saveSession, SESSION_TIMEOUT_MS) + } catch (e) { + logger.error(e instanceof Error ? e.message : String(e)) + message.error(e instanceof Error ? e.message : '启动 BetterGI 设置失败') + clearSession() + } finally { + bettergiConfigLoading.value = false + } + } + + const saveSession = async () => { + if (!bettergiWebsocketId.value) return + if (await stopSession(true)) { + message.success('BetterGI 设置已保存') + } else { + message.error('保存 BetterGI 设置失败') + } + } + + const dispose = () => { + void stopSession() + } + + return { + bettergiConfigLoading, + bettergiWebsocketId, + showBettergiConfigMask, + startSession, + saveSession, + stopSession, + dispose, + } +} diff --git a/frontend/src/composables/useScriptApi.ts b/frontend/src/composables/useScriptApi.ts index 80f2620df..6e7fae1a2 100644 --- a/frontend/src/composables/useScriptApi.ts +++ b/frontend/src/composables/useScriptApi.ts @@ -11,6 +11,7 @@ import { type OkNteConfig, type SrcConfig, type HSRConfig, + type BetterGIConfig, type HSRStageOptionsData, type MaaEndOptionsOut, type MaaFWInterfacePreviewOut, @@ -36,6 +37,7 @@ type ScriptListConfig = | M9AConfig | MaaFWConfig | HSRConfig + | BetterGIConfig type HSRStageEngine = 'M7A' | 'SRA' @@ -48,6 +50,7 @@ const SCRIPT_CREATE_TYPE_BY_SCRIPT_TYPE: Record Okww: ScriptCreateIn.type.OKWW, OkNte: ScriptCreateIn.type.OK_NTE, HSR: ScriptCreateIn.type.HSR, + BetterGI: ScriptCreateIn.type.BETTER_GI, General: ScriptCreateIn.type.GENERAL, } @@ -60,6 +63,7 @@ const SCRIPT_TYPE_BY_CONFIG_TYPE: Record = { M9AConfig: 'M9A', MaaFWConfig: 'MaaFW', HSRConfig: 'HSR', + BetterGIConfig: 'BetterGI', } const resolveScriptType = (configType: string): ScriptType => { @@ -1118,6 +1122,212 @@ export function useScriptApi() { : 0, }, } + } else if (userIndex.type === 'BetterGIUserConfig' && userData) { + const bettergiUserData = userData as any + return { + id: userIndex.uid, + name: bettergiUserData.Info?.Name || `用户${userIndex.uid}`, + Info: { + Name: + bettergiUserData.Info?.Name !== undefined + ? bettergiUserData.Info.Name + : `用户${userIndex.uid}`, + Status: + bettergiUserData.Info?.Status !== undefined + ? bettergiUserData.Info.Status + : true, + Id: bettergiUserData.Info?.Id !== undefined ? bettergiUserData.Info.Id : '', + Password: + bettergiUserData.Info?.Password !== undefined + ? bettergiUserData.Info.Password + : '', + RemainedDay: + bettergiUserData.Info?.RemainedDay !== undefined + ? bettergiUserData.Info.RemainedDay + : -1, + IfScriptBeforeTask: + bettergiUserData.Info?.IfScriptBeforeTask !== undefined + ? bettergiUserData.Info.IfScriptBeforeTask + : false, + ScriptBeforeTask: + bettergiUserData.Info?.ScriptBeforeTask !== undefined + ? bettergiUserData.Info.ScriptBeforeTask + : '', + IfScriptAfterTask: + bettergiUserData.Info?.IfScriptAfterTask !== undefined + ? bettergiUserData.Info.IfScriptAfterTask + : false, + ScriptAfterTask: + bettergiUserData.Info?.ScriptAfterTask !== undefined + ? bettergiUserData.Info.ScriptAfterTask + : '', + Notes: + bettergiUserData.Info?.Notes !== undefined + ? bettergiUserData.Info.Notes + : '', + Tag: + bettergiUserData.Info?.Tag !== undefined + ? bettergiUserData.Info.Tag + : null, + }, + Task: { + OneDragonConfigName: + bettergiUserData.Task?.OneDragonConfigName !== undefined + ? bettergiUserData.Task.OneDragonConfigName + : '', + }, + Notify: { + Enabled: + bettergiUserData.Notify?.Enabled !== undefined + ? bettergiUserData.Notify.Enabled + : false, + IfSendStatistic: + bettergiUserData.Notify?.IfSendStatistic !== undefined + ? bettergiUserData.Notify.IfSendStatistic + : false, + IfSendMail: + bettergiUserData.Notify?.IfSendMail !== undefined + ? bettergiUserData.Notify.IfSendMail + : false, + ToAddress: + bettergiUserData.Notify?.ToAddress !== undefined + ? bettergiUserData.Notify.ToAddress + : '', + IfServerChan: + bettergiUserData.Notify?.IfServerChan !== undefined + ? bettergiUserData.Notify.IfServerChan + : false, + ServerChanKey: + bettergiUserData.Notify?.ServerChanKey !== undefined + ? bettergiUserData.Notify.ServerChanKey + : '', + CustomWebhooks: + bettergiUserData.Notify?.CustomWebhooks !== undefined + ? bettergiUserData.Notify.CustomWebhooks + : [], + }, + Data: { + LastProxyDate: + bettergiUserData.Data?.LastProxyDate !== undefined + ? bettergiUserData.Data.LastProxyDate + : '', + ProxyTimes: + bettergiUserData.Data?.ProxyTimes !== undefined + ? bettergiUserData.Data.ProxyTimes + : 0, + LastProxyStatus: + bettergiUserData.Data?.LastProxyStatus !== undefined + ? bettergiUserData.Data.LastProxyStatus + : '未知', + LastOneDragonConfig: + bettergiUserData.Data?.LastOneDragonConfig !== undefined + ? bettergiUserData.Data.LastOneDragonConfig + : '', + }, + } + } else if (userIndex.type === 'BetterGIUserConfig' && userData) { + const bettergiUserData = userData as any + return { + id: userIndex.uid, + name: bettergiUserData.Info?.Name || `用户${userIndex.uid}`, + Info: { + Name: + bettergiUserData.Info?.Name !== undefined + ? bettergiUserData.Info.Name + : `用户${userIndex.uid}`, + Status: + bettergiUserData.Info?.Status !== undefined + ? bettergiUserData.Info.Status + : true, + Id: bettergiUserData.Info?.Id !== undefined ? bettergiUserData.Info.Id : '', + Password: + bettergiUserData.Info?.Password !== undefined + ? bettergiUserData.Info.Password + : '', + RemainedDay: + bettergiUserData.Info?.RemainedDay !== undefined + ? bettergiUserData.Info.RemainedDay + : -1, + IfScriptBeforeTask: + bettergiUserData.Info?.IfScriptBeforeTask !== undefined + ? bettergiUserData.Info.IfScriptBeforeTask + : false, + ScriptBeforeTask: + bettergiUserData.Info?.ScriptBeforeTask !== undefined + ? bettergiUserData.Info.ScriptBeforeTask + : '', + IfScriptAfterTask: + bettergiUserData.Info?.IfScriptAfterTask !== undefined + ? bettergiUserData.Info.IfScriptAfterTask + : false, + ScriptAfterTask: + bettergiUserData.Info?.ScriptAfterTask !== undefined + ? bettergiUserData.Info.ScriptAfterTask + : '', + Notes: + bettergiUserData.Info?.Notes !== undefined + ? bettergiUserData.Info.Notes + : '', + Tag: + bettergiUserData.Info?.Tag !== undefined + ? bettergiUserData.Info.Tag + : null, + }, + Task: { + OneDragonConfigName: + bettergiUserData.Task?.OneDragonConfigName !== undefined + ? bettergiUserData.Task.OneDragonConfigName + : '', + }, + Notify: { + Enabled: + bettergiUserData.Notify?.Enabled !== undefined + ? bettergiUserData.Notify.Enabled + : false, + IfSendStatistic: + bettergiUserData.Notify?.IfSendStatistic !== undefined + ? bettergiUserData.Notify.IfSendStatistic + : false, + IfSendMail: + bettergiUserData.Notify?.IfSendMail !== undefined + ? bettergiUserData.Notify.IfSendMail + : false, + ToAddress: + bettergiUserData.Notify?.ToAddress !== undefined + ? bettergiUserData.Notify.ToAddress + : '', + IfServerChan: + bettergiUserData.Notify?.IfServerChan !== undefined + ? bettergiUserData.Notify.IfServerChan + : false, + ServerChanKey: + bettergiUserData.Notify?.ServerChanKey !== undefined + ? bettergiUserData.Notify.ServerChanKey + : '', + CustomWebhooks: + bettergiUserData.Notify?.CustomWebhooks !== undefined + ? bettergiUserData.Notify.CustomWebhooks + : [], + }, + Data: { + LastProxyDate: + bettergiUserData.Data?.LastProxyDate !== undefined + ? bettergiUserData.Data.LastProxyDate + : '', + ProxyTimes: + bettergiUserData.Data?.ProxyTimes !== undefined + ? bettergiUserData.Data.ProxyTimes + : 0, + LastProxyStatus: + bettergiUserData.Data?.LastProxyStatus !== undefined + ? bettergiUserData.Data.LastProxyStatus + : '未知', + LastOneDragonConfig: + bettergiUserData.Data?.LastOneDragonConfig !== undefined + ? bettergiUserData.Data.LastOneDragonConfig + : '', + }, + } } return null diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 6ecedd495..7d89688eb 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2237,6 +2237,7 @@ export default { Okww: 'ok-ww', OkNte: 'ok-nte', HSR: 'HSR', + BetterGI: 'BetterGI', General: 'General', }, typeDesc: { @@ -2247,6 +2248,7 @@ export default { Okww: 'ok-script line: runs tasks through the -t/-e launch arguments', OkNte: 'Neverness to Everness OK-NTE automation, -t/-e task launch', HSR: 'Honkai: Star Rail — March7th / SRA dual-script support', + BetterGI: 'BetterGI · auto-pickup/story/fishing and more for Genshin', General: 'Generic automation for any script that writes a log file', }, mask: { @@ -2345,6 +2347,7 @@ export default { Okww: 'Dedicated ok-script task runner', OkNte: 'Neverness to Everness OK-NTE automation', HSR: 'March7th / SRA dual-script support', + BetterGI: 'BetterGI · auto-pickup/story/fishing and more for Genshin', }, }, toast: { diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 5edaec82a..7c14764e1 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -2261,6 +2261,7 @@ export default { Okww: 'ok-ww', OkNte: 'ok-nte', HSR: 'HSR', + BetterGI: 'BetterGI', General: '汎用', }, typeDesc: { @@ -2272,6 +2273,7 @@ export default { Okww: 'ok-script 系列専用:-t/-e 起動引数でタスクを実行します', OkNte: 'Neverness to Everness(OK-NTE)の自動化。-t/-e でタスクを起動', HSR: '崩壊:スターレイル — 三月なのか / SRA の 2 種類に対応', + BetterGI: 'より良い原神 · 自動拾取/ストーリー/釣りなどの完全自動化', General: 'ログファイルを出力するあらゆるスクリプトに使える汎用の自動化', }, mask: { @@ -2379,6 +2381,7 @@ export default { Okww: 'ok-script 専用のタスクランナー', OkNte: 'Neverness to Everness(OK-NTE)の自動化', HSR: '三月なのか / SRA の 2 種類に対応', + BetterGI: 'より良い原神 · 自動拾取/ストーリー/釣りなどの完全自動化', }, }, toast: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index d02ace434..35ff6bb10 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2151,6 +2151,7 @@ export default { Okww: 'ok-ww脚本', OkNte: 'ok-nte脚本', HSR: 'HSR脚本', + BetterGI: 'BetterGI脚本', General: '通用脚本', }, typeDesc: { @@ -2161,6 +2162,7 @@ export default { Okww: 'ok-script 线专项:通过 -t/-e 启动参数运行任务', OkNte: '异环 OK-NTE 自动化脚本,支持 -t/-e 任务启动', HSR: '崩坏:星穹铁道 三月七 / SRA 双脚本适配', + BetterGI: '更好的原神 · 自动拾取/剧情/钓鱼等全自动化', General: '通用自动化脚本,适用于所有具备日志文件的脚本', }, mask: { @@ -2258,6 +2260,7 @@ export default { Okww: 'ok-script 专项任务脚本', OkNte: '异环 OK-NTE 自动化脚本', HSR: '三月七 / SRA 双脚本适配', + BetterGI: '更好的原神 · 自动拾取/剧情/钓鱼等全自动化', }, }, toast: { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dbe3bc6a6..f0d1be9df 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -118,6 +118,12 @@ const routes = [ component: () => import('../views/EditView/Script/OkNteScriptEdit.vue'), meta: { title: '编辑ok-nte脚本' }, }, + { + path: '/scripts/:id/edit/bettergi', + name: 'BetterGIScriptEdit', + component: () => import('../views/EditView/Script/BetterGIScriptEdit.vue'), + meta: { title: '编辑BetterGI脚本' }, + }, { path: '/scripts/:scriptId/users/add/maa', name: 'MAAUserAdd', @@ -226,6 +232,18 @@ const routes = [ component: () => import('../views/EditView/User/OkNteUserEdit.vue'), meta: { title: '编辑ok-nte用户' }, }, + { + path: '/scripts/:scriptId/users/add/bettergi', + name: 'BetterGIUserAdd', + component: () => import('../views/EditView/User/BetterGIUserEdit.vue'), + meta: { title: '添加BetterGI用户' }, + }, + { + path: '/scripts/:scriptId/users/:userId/edit/bettergi', + name: 'BetterGIUserEdit', + component: () => import('../views/EditView/User/BetterGIUserEdit.vue'), + meta: { title: '编辑BetterGI用户' }, + }, { path: '/plans', name: 'Plans', diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index e5f145a60..378d4e6c8 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -9,6 +9,7 @@ import type { SrcConfig, MaaEndConfig, M9AConfig, + BetterGIConfig, } from '@/api' import type { AutoEssenceLocation, @@ -28,9 +29,11 @@ export type ScriptType = | 'M9A' | 'MaaFW' | 'HSR' + | 'BetterGI' export type OkwwScriptConfig = OkwwConfig export type OkNteScriptConfig = OkNteConfig +export type BetterGIScriptConfig = BetterGIConfig // MAA脚本配置 export interface MAAScriptConfig { Info: { @@ -507,6 +510,7 @@ export interface Script { | M9AConfig | MaaFWScriptConfig | HSRConfig + | BetterGIConfig users: User[] } @@ -600,6 +604,7 @@ export interface AddScriptResponse { | M9AScriptConfig | MaaFWScriptConfig | HSRScriptConfig + | BetterGIScriptConfig } // 脚本索引项 @@ -615,6 +620,7 @@ export interface ScriptIndexItem { | 'M9AConfig' | 'MaaFWConfig' | 'HSRConfig' + | 'BetterGIConfig' } // 获取脚本API响应 @@ -634,6 +640,7 @@ export interface GetScriptsResponse { | M9AScriptConfig | MaaFWScriptConfig | HSRScriptConfig + | BetterGIScriptConfig > } @@ -652,6 +659,7 @@ export interface ScriptDetail { | M9AConfig | MaaFWScriptConfig | HSRConfig + | BetterGIConfig users?: User[] createTime?: string } diff --git a/frontend/src/utils/scriptIcon.ts b/frontend/src/utils/scriptIcon.ts index 99049c237..06dcf40d6 100644 --- a/frontend/src/utils/scriptIcon.ts +++ b/frontend/src/utils/scriptIcon.ts @@ -1,5 +1,6 @@ import type { ScriptType } from '@/types/script' import generalIcon from '@/assets/AUTO-MAS.ico' +import bettergiIcon from '@/assets/bettergi.ico' import maafwIcon from '@/assets/maafw.png' import hsrIcon from '@/assets/hsr.png' import maaIcon from '@/assets/MAA.png' @@ -19,6 +20,7 @@ const SCRIPT_ICON_BY_TYPE: Record = { M9A: m9aIcon, MaaFW: maafwIcon, HSR: hsrIcon, + BetterGI: bettergiIcon, } /** Return the host-owned icon for current and legacy persisted script types. */ diff --git a/frontend/src/utils/scriptLogos.ts b/frontend/src/utils/scriptLogos.ts index 64737c4c9..c1d0d6285 100644 --- a/frontend/src/utils/scriptLogos.ts +++ b/frontend/src/utils/scriptLogos.ts @@ -1,5 +1,6 @@ import type { ScriptType } from '@/types/script' import generalIcon from '@/assets/AUTO-MAS.ico' +import bettergiIcon from '@/assets/bettergi.ico' import hsrIcon from '@/assets/hsr.png' import maaIcon from '@/assets/MAA.png' import maaEndIcon from '@/assets/MaaEnd.png' @@ -11,6 +12,7 @@ import maafwIcon from '@/assets/maafw.png' /** 脚本类型 → 图标资源,Vite 处理后的 URL */ export const SCRIPT_LOGOS: Record = { + BetterGI: bettergiIcon, General: generalIcon, HSR: hsrIcon, M9A: m9aIcon, @@ -24,6 +26,7 @@ export const SCRIPT_LOGOS: Record = { /** 脚本类型 → 展示名,用于图片 alt 与标签文案 */ export const SCRIPT_LABELS: Record = { + BetterGI: 'BetterGI', General: 'AUTO-MAS', HSR: 'HSR', M9A: 'M9A', diff --git a/frontend/src/views/EditView/Script/BetterGIScriptEdit.vue b/frontend/src/views/EditView/Script/BetterGIScriptEdit.vue new file mode 100644 index 000000000..d9fc9997c --- /dev/null +++ b/frontend/src/views/EditView/Script/BetterGIScriptEdit.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/frontend/src/views/EditView/User/BetterGIUserEdit.vue b/frontend/src/views/EditView/User/BetterGIUserEdit.vue new file mode 100644 index 000000000..bf0a7705f --- /dev/null +++ b/frontend/src/views/EditView/User/BetterGIUserEdit.vue @@ -0,0 +1,1352 @@ + + + + + diff --git a/frontend/src/views/Scripts.vue b/frontend/src/views/Scripts.vue index 74cd1ba04..b3f54a41f 100644 --- a/frontend/src/views/Scripts.vue +++ b/frontend/src/views/Scripts.vue @@ -327,6 +327,12 @@ alt="MFW" class="type-icon" /> + BetterGI General
@@ -450,6 +456,17 @@
+ +
+
+ +
+
+
{{ t('scripts.type.BetterGI') }}
+
{{ t('scripts.typeDesc.BetterGI') }}
+
+
+
@@ -705,6 +722,7 @@ const scriptEditPathMap: Record = { M9A: 'm9a', MaaFW: 'maafw', HSR: 'hsr', + BetterGI: 'bettergi', } const getScriptEditPath = (type: ScriptType) => scriptEditPathMap[type] @@ -1130,6 +1148,8 @@ const handleAddUser = (script: Script) => { router.push(`/scripts/${script.id}/users/add/oknte`) } else if (script.type === 'HSR') { router.push(`/scripts/${script.id}/users/add/hsr`) + } else if (script.type === 'BetterGI') { + router.push(`/scripts/${script.id}/users/add/bettergi`) } else { router.push(`/scripts/${script.id}/users/add/general`) } @@ -1156,6 +1176,8 @@ const handleEditUser = (user: User) => { router.push(`/scripts/${script.id}/users/${user.id}/edit/oknte`) } else if (script.type === 'HSR') { router.push(`/scripts/${script.id}/users/${user.id}/edit/hsr`) + } else if (script.type === 'BetterGI') { + router.push(`/scripts/${script.id}/users/${user.id}/edit/bettergi`) } else { router.push(`/scripts/${script.id}/users/${user.id}/edit/general`) } diff --git a/frontend/src/views/scripts/components/scriptCreateFlow.test.ts b/frontend/src/views/scripts/components/scriptCreateFlow.test.ts index 9689b84ed..c8aba18cc 100644 --- a/frontend/src/views/scripts/components/scriptCreateFlow.test.ts +++ b/frontend/src/views/scripts/components/scriptCreateFlow.test.ts @@ -25,6 +25,7 @@ describe('scriptCreateFlow', () => { 'Okww', 'OkNte', 'HSR', + 'BetterGI', ]) }) @@ -49,6 +50,7 @@ describe('scriptCreateFlow', () => { expect(getScriptEditSegment('Okww')).toBe('okww') expect(getScriptEditSegment('OkNte')).toBe('oknte') expect(getScriptEditSegment('HSR')).toBe('hsr') + expect(getScriptEditSegment('BetterGI')).toBe('bettergi') expect(getScriptEditSegment('General')).toBe('general') }) diff --git a/frontend/src/views/scripts/components/scriptCreateFlow.ts b/frontend/src/views/scripts/components/scriptCreateFlow.ts index 4c78d2c77..dd1aeae99 100644 --- a/frontend/src/views/scripts/components/scriptCreateFlow.ts +++ b/frontend/src/views/scripts/components/scriptCreateFlow.ts @@ -105,6 +105,14 @@ export const SCRIPT_TYPE_OPTIONS: ScriptTypeOption[] = [ group: 'specialized', icon: SCRIPT_LOGOS.HSR, }, + { + value: 'BetterGI', + titleKey: 'scripts.type.BetterGI', + descriptionKey: 'scripts.create.typeDesc.BetterGI', + keywords: ['bettergi', 'better-gi', '原神', 'genshin'], + group: 'specialized', + icon: SCRIPT_LOGOS.BetterGI, + }, ] export const buildCreateSteps = ({ type }: Pick): CreateStep[] => { @@ -152,6 +160,7 @@ const EDIT_SEGMENT_BY_TYPE: Record = { Okww: 'okww', OkNte: 'oknte', HSR: 'hsr', + BetterGI: 'bettergi', General: 'general', } diff --git a/res/html/general_statistics.html b/res/html/general_statistics.html index fb37460f6..6b4c07893 100644 --- a/res/html/general_statistics.html +++ b/res/html/general_statistics.html @@ -197,6 +197,37 @@

自动代理统计报告

+ {% if one_dragon_steps %} +
+

一条龙分步执行

+ + + + + + + + {% for s in one_dragon_steps %} + + + + + + + {% endfor %} +
步骤任务结果经过
{{ s.index }}/{{ s.total }}{{ s.task }} + {% if s.ok and not s.issue_count %} + ✓ 成功 + {% elif s.ok %} + ✓ 成功(含{{ s.issue_count }}处异常) + {% else %} + ✗ 未完成 + {% endif %} + {% if s.issue_text %}
{{ s.issue_text }}{% endif %} +
{{ s.start }} → {{ s.end }}
+
+ {% endif %} +

AUTO-MAS 敬上