Compare commits

..

1 Commits

Author SHA1 Message Date
DLmaster361
4cbd921ab6 Merge branch 'dev' 2025-07-15 18:16:23 +08:00
475 changed files with 17246 additions and 59534 deletions

277
.github/workflows/build-app.yml vendored Normal file
View File

@@ -0,0 +1,277 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
name: Build AUTO_MAA
on:
workflow_dispatch:
permissions:
contents: write
actions: write
jobs:
pre_check:
name: Pre Checks
runs-on: ubuntu-latest
steps:
- name: Repo Check
id: repo_check
run: |
if [[ "$GITHUB_REPOSITORY" != "DLmaster361/AUTO_MAA" ]]; then
echo "When forking this repository to make your own builds, you have to adjust this check."
exit 1
fi
exit 0
build_AUTO_MAA:
runs-on: windows-latest
needs: pre_check
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
pip install -r requirements.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Get version
id: get_version
run: |
$version = (Get-Content resources/version.json | ConvertFrom-Json).main_version
echo "main_version=$version" >> $env:GITHUB_OUTPUT
- name: Nuitka build main program
uses: Nuitka/Nuitka-Action@main
with:
script-name: main.py
mode: app
enable-plugins: pyside6
onefile-tempdir-spec: "{TEMP}/AUTO_MAA"
windows-console-mode: attach
windows-icon-from-ico: resources/icons/AUTO_MAA.ico
windows-uac-admin: true
company-name: AUTO_MAA Team
product-name: AUTO_MAA
file-version: ${{ steps.get_version.outputs.main_version }}
product-version: ${{ steps.get_version.outputs.main_version }}
file-description: AUTO_MAA Component
copyright: Copyright © 2024-2025 DLmaster361
assume-yes-for-downloads: true
output-file: AUTO_MAA
output-dir: AUTO_MAA
- name: Upload unsigned main program
id: upload-unsigned-main-program
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA
path: AUTO_MAA/AUTO_MAA.exe
- name: Sign main program
id: sign_main_program
uses: signpath/github-action-submit-signing-request@v1.2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '787a1d5f-6177-4f30-9559-d2646473584a'
project-slug: 'AUTO_MAA'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: "AUTO_MAA"
github-artifact-id: '${{ steps.upload-unsigned-main-program.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'AUTO_MAA'
- name: Add other resources
shell: pwsh
run: |
$root = "${{ github.workspace }}"
$ver = "${{ steps.get_version.outputs.main_version }}"
Copy-Item "$root/app" "$root/AUTO_MAA/app" -Recurse
Copy-Item "$root/resources" "$root/AUTO_MAA/resources" -Recurse
Copy-Item "$root/main.py" "$root/AUTO_MAA/"
Copy-Item "$root/requirements.txt" "$root/AUTO_MAA/"
Copy-Item "$root/README.md" "$root/AUTO_MAA/"
Copy-Item "$root/LICENSE" "$root/AUTO_MAA/"
- name: Create Inno Setup script
shell: pwsh
run: |
$root = "${{ github.workspace }}"
$ver = "${{ steps.get_version.outputs.main_version }}"
$iss = Get-Content "$root/app/utils/AUTO_MAA.iss" -Raw
$iss = $iss -replace '#define MyAppVersion ""', "#define MyAppVersion `"$ver`""
$iss = $iss -replace '#define MyAppPath ""', "#define MyAppPath `"$root/AUTO_MAA`""
$iss = $iss -replace '#define OutputDir ""', "#define OutputDir `"$root`""
Set-Content -Path "$root/AUTO_MAA.iss" -Value $iss
- name: Build setup program
uses: Minionguyjpro/Inno-Setup-Action@v1.2.5
with:
path: AUTO_MAA.iss
- name: Upload unsigned setup program
id: upload-unsigned-setup-program
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA-Setup
path: AUTO_MAA-Setup.exe
- name: Sign setup program
id: sign_setup_program
uses: signpath/github-action-submit-signing-request@v1.2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '787a1d5f-6177-4f30-9559-d2646473584a'
project-slug: 'AUTO_MAA'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: "AUTO_MAA-Setup"
github-artifact-id: '${{ steps.upload-unsigned-setup-program.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'AUTO_MAA_Setup'
- name: Compress setup exe
shell: pwsh
run: Compress-Archive -Path AUTO_MAA_Setup/* -DestinationPath AUTO_MAA_${{ steps.get_version.outputs.main_version }}.zip
- name: Generate version info
shell: python
run: |
import json
from pathlib import Path
def version_text(version_numb):
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
return f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
return f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
def version_info_markdown(info):
version_info = ""
for key, value in info.items():
version_info += f"## {key}\n"
for v in value:
version_info += f"- {v}\n"
return version_info
root_path = Path(".")
version = json.loads((root_path / "resources/version.json").read_text(encoding="utf-8"))
main_version_numb = list(map(int, version["main_version"].split(".")))
all_version_info = {}
for v_i in version["version_info"].values():
for key, value in v_i.items():
if key in all_version_info:
all_version_info[key] += value.copy()
else:
all_version_info[key] = value.copy()
(root_path / "version_info.txt").write_text(
f"{version_text(main_version_numb)}\n\n<!--{json.dumps(version['version_info'], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
encoding="utf-8",
)
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA_${{ steps.get_version.outputs.main_version }}
path: AUTO_MAA_${{ steps.get_version.outputs.main_version }}.zip
- name: Upload Version_Info Artifact
uses: actions/upload-artifact@v4
with:
name: version_info
path: version_info.txt
publish_release:
name: Publish release
needs: build_AUTO_MAA
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: AUTO_MAA_*
merge-multiple: true
path: artifacts
- name: Download Version_Info
uses: actions/download-artifact@v4
with:
name: version_info
path: ./
- name: Create release
id: create_release
run: |
set -xe
shopt -s nullglob
NAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
TAGNAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
NOTES_MAIN="$(sed 's/\r$//g' <(tail -n +3 version_info.txt))"
NOTES="$NOTES_MAIN
## 代码签名策略Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
- 审批人Approvers: [DLmaster (@DLmaster361)](https://github.com/DLmaster361)
## 隐私政策Privacy policy
除非用户、安装者或使用者特别要求,否则本程序不会将任何信息传输到其他网络系统。
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
[已有 Mirror酱 CDK ?前往 Mirror酱 高速下载](https://mirrorchyan.com/zh/projects?rid=AUTO_MAA&source=auto_maa-release)
\`\`\`本release通过GitHub Actions自动构建\`\`\`"
if [ "${{ github.ref_name }}" == "main" ]; then
PRERELEASE_FLAG=""
else
PRERELEASE_FLAG="--prerelease"
fi
gh release create "$TAGNAME" --target "main" --title "$NAME" --notes "$NOTES" $PRERELEASE_FLAG artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}
- name: Trigger MirrorChyanUploading
run: |
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan_release_note
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

21
.github/workflows/mirrorchyan.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: mirrorchyan
on:
workflow_dispatch:
jobs:
mirrorchyan:
runs-on: macos-latest
steps:
- id: uploading
uses: MirrorChyan/uploading-action@v1
with:
filetype: latest-release
filename: "AUTO_MAA*.zip"
mirrorchyan_rid: AUTO_MAA
owner: DLmaster361
repo: AUTO_MAA
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_token: ${{ secrets.MirrorChyanUploadToken }}

View File

@@ -0,0 +1,19 @@
name: mirrorchyan_release_note
on:
workflow_dispatch:
release:
types: [edited]
jobs:
mirrorchyan:
runs-on: macos-latest
steps:
- id: uploading
uses: MirrorChyan/release-note-action@v1
with:
mirrorchyan_rid: AUTO_MAA
upload_token: ${{ secrets.MirrorChyanUploadToken }}
github_token: ${{ secrets.GITHUB_TOKEN }}

28
.gitignore vendored
View File

@@ -1,31 +1,9 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
logs/
*.egg-info
# Virtual environments
.venv
.python-version
list/
uv.lock
# IDE and editors
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# User files
config/
data/
debug/
history/
script/
res/notice.json
res/theme_image.json
res/images/Home/BannerTheme.jpg
resources/notice.json
resources/theme_image.json
resources/images/Home/BannerTheme.jpg

121
README.md
View File

@@ -1,2 +1,119 @@
TEST
TEST
<h1 align="center">AUTO_MAA</h1>
<p align="center">
MAA多账号管理与自动化软件<br><br>
<img alt="软件图标" src="https://github.com/DLmaster361/AUTO_MAA/blob/main/resources/images/AUTO_MAA.png">
</p>
---
<p align="center">
<a href="https://github.com/DLmaster361/AUTO_MAA/stargazers"><img alt="GitHub Stars" src="https://img.shields.io/github/stars/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/network"><img alt="GitHub Forks" src="https://img.shields.io/github/forks/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/releases/latest"><img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/DLmaster361/AUTO_MAA/total?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/issues"><img alt="GitHub Issues" src="https://img.shields.io/github/issues/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors"><img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/blob/main/LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://deepwiki.com/DLmaster361/AUTO_MAA"><img alt="DeepWiki" src="https://deepwiki.com/badge.svg"></a>
<a href="https://mirrorchyan.com/zh/projects?rid=AUTO_MAA&source=auto_maa-readme"><img alt="mirrorc" src="https://img.shields.io/badge/Mirror%E9%85%B1-%239af3f6?logo=countingworkspro&logoColor=4f46e5"></a>
</p>
## 软件介绍
### 性质
本软件是明日方舟第三方软件`MAA`的第三方工具即第3<sup>3</sup>方软件。旨在优化MAA多账号功能体验并通过一些方法解决MAA项目未能解决的部分问题提高代理的稳定性。
- **集中管理**一站式管理多个MAA脚本与多个用户配置和凌乱的散装脚本窗口说再见
- **无人值守**自动处理MAA相关报错再也不用为代理任务卡死时自己不在电脑旁烦恼啦
- **配置灵活**:通过调度队列与脚本的组合,自由实现您能想到的所有调度需求!
- **信息统计**:自动统计用户的公招与关卡掉落物,看看这个月您的收益是多少!
### 原理
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
1. **配置:** 根据对应用户的配置信息生成配置文件并将其导入MAA。
2. **监测:** 在MAA开始代理后持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时通过重启MAA使之仍能继续完成任务。
3. **循环:** 重复上述步骤使MAA依次完成各个用户的自动代理任务。
### 优势
- **高效稳定**:通过日志监测、异常处理等机制,保障代理任务顺利完成。
- **简洁易用**:无需手动修改配置文件,实现自动化调度与多开管理。
- **兼容扩展**:支持 MAA 几乎所有的配置选项,满足不同用户需求。
## 重要声明
本开发团队承诺不会修改明日方舟游戏本体与相关配置文件。本项目使用GPL开源相关细则如下
- **作者:** AUTO_MAA软件作者为DLmaster、DLmaster361或DLmaster_361以上均指代同一人。
- **使用:** AUTO_MAA使用者可以按自己的意愿自由使用本软件。依据GPL对于由此可能产生的损失AUTO_MAA项目组不负任何责任。
- **分发:** AUTO_MAA允许任何人自由分发本软件包括进行商业活动牟利。若为直接分发本软件必须遵循GPL向接收者提供本软件项目地址、完整的软件源码与GPL协议原文若为修改软件后进行分发必须遵循GPL向接收者提供本软件项目地址、修改前的完整软件源码副本与GPL协议原文违反者可能会被追究法律责任。
- **传播:** AUTO_MAA原则上允许传播者自由传播本软件但无论在何种传播过程中不得删除项目作者与开发者所留版权声明不得隐瞒项目作者与相关开发者的存在。由于软件性质项目组不希望发现任何人在明日方舟官方媒体包括官方媒体账号与森空岛社区等或明日方舟游戏相关内容包括同好群、线下活动与游戏内容讨论等下提及AUTO_MAA或MAA希望各位理解。
- **衍生:** AUTO_MAA允许任何人对软件本体或软件部分代码进行二次开发或利用。但依据GPL相关成果再次分发时也必须使用GPL或兼容的协议开源。
- **贡献:** 不论是直接参与软件的维护编写或是撰写文档、测试、反馈BUG、给出建议、参与讨论都为AUTO_MAA项目的发展完善做出了不可忽视的贡献。项目组提倡各位贡献者遵照GitHub开源社区惯例发布Issues参与项目。避免私信或私发邮件安全性漏洞或敏感问题除外以帮助更多用户。
- **图像:** `AUTO_MAA主页默认图像` 并不适用开源协议,著作权归 [NARINpopo](https://space.bilibili.com/1877154) 画师所有,商业使用权归 [DLmaster (@DLmaster361)](https://github.com/DLmaster361) 所有,软件用户仅拥有非商业使用权。不得以开源协议已授权为由在未经授权的情况下使用 `AUTO_MAA主页默认图像`,不得在未经授权的情况下将 `AUTO_MAA主页默认图像` 用于任何商业用途。
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准发生冲突的以本细则为准。如有不清楚的部分请发Issues询问。若发生纠纷相关内容也没有在Issues上提及的项目组拥有最终解释权。
---
# 使用方法
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
---
# 关于
## 项目开发情况
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
## 代码签名策略Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
- 审批人Approvers: [DLmaster (@DLmaster361)](https://github.com/DLmaster361)
## 隐私政策Privacy policy
除非用户、安装者或使用者特别要求,否则本程序不会将任何信息传输到其他网络系统。
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
## 特别鸣谢
- 下载服务器:由[AoXuan (@ClozyA)](https://github.com/ClozyA) 个人为项目赞助。
- EXE签名: 由 [SignPath.io](https://signpath.io/)提供免费代码签名,签名来自[SignPath Foundation](https://signpath.org/)。
## 贡献者
感谢以下贡献者对本项目做出的贡献
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors">
<img src="https://contrib.rocks/image?repo=DLmaster361/AUTO_MAA" />
</a>
![Alt](https://repobeats.axiom.co/api/embed/6c2f834141eff1ac297db70d12bd11c6236a58a5.svg "Repobeats analytics image")
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DLmaster361/AUTO_MAA&type=Date)](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
## 交流与赞助
欢迎加入AUTO_MAA项目组欢迎反馈bug
- QQ交流群[957750551](https://qm.qq.com/q/bd9fISNoME)
---
如果喜欢这个项目的话,给作者来杯咖啡吧!
![payid](resources/images/README/payid.png "payid")

View File

@@ -1,34 +1,49 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA主程序包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .core import QueueConfig, MaaConfig, MaaUserConfig, Task, TaskManager, MainTimer
from .models import MaaManager
from .services import Notify, Crypto, System
from .ui import AUTO_MAA
from .api import *
from .core import *
from .models import *
from .services import *
from .utils import *
__all__ = ["api", "core", "models", "services", "utils"]
__all__ = [
"QueueConfig",
"MaaConfig",
"MaaUserConfig",
"Task",
"TaskManager",
"MainTimer",
"MaaManager",
"Notify",
"Crypto",
"System",
"AUTO_MAA",
]

View File

@@ -1,47 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .core import router as core_router
from .info import router as info_router
from .scripts import router as scripts_router
from .plan import router as plan_router
from .queue import router as queue_router
from .dispatch import router as dispatch_router
from .history import router as history_router
from .setting import router as setting_router
from .update import router as update_router
__all__ = [
"core_router",
"info_router",
"scripts_router",
"plan_router",
"queue_router",
"dispatch_router",
"history_router",
"setting_router",
"update_router",
]

View File

@@ -1,95 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import time
import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core import Config, Broadcast, TaskManager
from app.services import System
from app.models.schema import *
router = APIRouter(prefix="/api/core", tags=["核心信息"])
@router.websocket("/ws")
async def connect_websocket(websocket: WebSocket):
if Config.websocket is not None:
await websocket.close(code=1000, reason="已有连接")
return
await websocket.accept()
Config.websocket = websocket
last_pong = time.monotonic()
last_ping = time.monotonic()
data = {}
await TaskManager.start_startup_queue()
while True:
try:
data = await asyncio.wait_for(websocket.receive_json(), timeout=1000005.0)
if data.get("type") == "Signal" and "Pong" in data.get("data", {}):
last_pong = time.monotonic()
elif data.get("type") == "Signal" and "Ping" in data.get("data", {}):
await websocket.send_json(
WebSocketMessage(
id="Main", type="Signal", data={"Pong": "无描述"}
).model_dump()
)
else:
await Broadcast.put(data)
except asyncio.TimeoutError:
if last_pong < last_ping:
await websocket.close(code=1000, reason="Ping超时")
break
await websocket.send_json(
WebSocketMessage(
id="Main", type="Signal", data={"Ping": "无描述"}
).model_dump()
)
last_ping = time.monotonic()
except WebSocketDisconnect:
break
Config.websocket = None
await System.set_power("KillSelf")
@router.post("/close")
async def close():
"""关闭后端程序"""
try:
await System.set_power("KillSelf")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,87 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from fastapi import APIRouter, Body
from app.core import Config, TaskManager
from app.services import System
from app.models.schema import *
router = APIRouter(prefix="/api/dispatch", tags=["任务调度"])
@router.post(
"/start", summary="添加任务", response_model=TaskCreateOut, status_code=200
)
async def add_task(task: TaskCreateIn = Body(...)) -> TaskCreateOut:
try:
task_id = await TaskManager.add_task(task.mode, task.taskId)
except Exception as e:
return TaskCreateOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
websocketId="",
)
return TaskCreateOut(websocketId=str(task_id))
@router.post("/stop", summary="中止任务", response_model=OutBase, status_code=200)
async def stop_task(task: DispatchIn = Body(...)) -> OutBase:
try:
await TaskManager.stop_task(task.taskId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/set/power", summary="设置电源标志", response_model=OutBase, status_code=200
)
async def set_power(task: PowerIn = Body(...)) -> OutBase:
try:
Config.power_sign = task.signal
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/cancel/power", summary="取消电源任务", response_model=OutBase, status_code=200
)
async def cancel_power_task() -> OutBase:
try:
await System.cancel_power_task()
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,88 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Body
from app.core import Config
from app.models.schema import *
router = APIRouter(prefix="/api/history", tags=["历史记录"])
@router.post(
"/search",
summary="搜索历史记录总览信息",
response_model=HistorySearchOut,
status_code=200,
)
async def search_history(history: HistorySearchIn) -> HistorySearchOut:
try:
data = await Config.search_history(
history.mode,
datetime.strptime(history.start_date, "%Y-%m-%d").date(),
datetime.strptime(history.end_date, "%Y-%m-%d").date(),
)
for date, users in data.items():
for user, records in users.items():
record = await Config.merge_statistic_info(records)
# 安全检查:确保 index 字段存在
if "index" not in record:
record["index"] = []
record["index"] = [HistoryIndexItem(**_) for _ in record["index"]]
record = HistoryData(**record)
data[date][user] = record
except Exception as e:
return HistorySearchOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
data={},
)
return HistorySearchOut(data=data)
@router.post(
"/data",
summary="从指定文件内获取历史记录数据",
response_model=HistoryDataGetOut,
status_code=200,
)
async def get_history_data(history: HistoryDataGetIn = Body(...)) -> HistoryDataGetOut:
try:
path = Path(history.jsonPath)
data = await Config.merge_statistic_info([path])
data.pop("index", None)
data["log_content"] = path.with_suffix(".log").read_text(encoding="utf-8")
data = HistoryData(**data)
except Exception as e:
return HistoryDataGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
data=HistoryData(**{}),
)
return HistoryDataGetOut(data=data)

View File

@@ -1,215 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from fastapi import APIRouter, Body
from app.core import Config
from app.models.schema import *
router = APIRouter(prefix="/api/info", tags=["信息获取"])
@router.post(
"/version",
summary="获取后端git版本信息",
response_model=VersionOut,
status_code=200,
)
async def get_git_version() -> VersionOut:
try:
is_latest, commit_hash, commit_time = await Config.get_git_version()
except Exception as e:
return VersionOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
if_need_update=False,
current_hash="unknown",
current_time="unknown",
current_version=Config.version(),
)
return VersionOut(
if_need_update=not is_latest,
current_hash=commit_hash,
current_time=commit_time,
current_version=Config.version(),
)
@router.post(
"/combox/stage",
summary="获取关卡号下拉框信息",
response_model=ComboBoxOut,
status_code=200,
)
async def get_stage_combox(
stage: GetStageIn = Body(..., description="关卡号类型")
) -> ComboBoxOut:
try:
raw_data = await Config.get_stage_info(stage.type)
data = (
[ComboBoxItem(**item) for item in raw_data if isinstance(item, dict)]
if raw_data
else []
)
except Exception as e:
return ComboBoxOut(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
)
return ComboBoxOut(data=data)
@router.post(
"/combox/script",
summary="获取脚本下拉框信息",
response_model=ComboBoxOut,
status_code=200,
)
async def get_script_combox() -> ComboBoxOut:
try:
raw_data = await Config.get_script_combox()
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
except Exception as e:
return ComboBoxOut(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
)
return ComboBoxOut(data=data)
@router.post(
"/combox/task",
summary="获取可选任务下拉框信息",
response_model=ComboBoxOut,
status_code=200,
)
async def get_task_combox() -> ComboBoxOut:
try:
raw_data = await Config.get_task_combox()
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
except Exception as e:
return ComboBoxOut(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
)
return ComboBoxOut(data=data)
@router.post(
"/combox/plan",
summary="获取可选计划下拉框信息",
response_model=ComboBoxOut,
status_code=200,
)
async def get_plan_combox() -> ComboBoxOut:
try:
raw_data = await Config.get_plan_combox()
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
except Exception as e:
return ComboBoxOut(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
)
return ComboBoxOut(data=data)
@router.post(
"/notice/get", summary="获取通知信息", response_model=NoticeOut, status_code=200
)
async def get_notice_info() -> NoticeOut:
try:
if_need_show, data = await Config.get_notice()
except Exception as e:
return NoticeOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
if_need_show=False,
data={},
)
return NoticeOut(if_need_show=if_need_show, data=data)
@router.post(
"/notice/confirm", summary="确认通知", response_model=OutBase, status_code=200
)
async def confirm_notice() -> OutBase:
try:
await Config.set("Data", "IfShowNotice", False)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
# @router.post(
# "/apps_info", summary="获取可下载应用信息", response_model=InfoOut, status_code=200
# )
# async def get_apps_info() -> InfoOut:
# try:
# data = await Config.get_server_info("apps_info")
# except Exception as e:
# return InfoOut(
# code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
# )
# return InfoOut(data=data)
@router.post(
"/webconfig",
summary="获取配置分享中心的配置信息",
response_model=InfoOut,
status_code=200,
)
async def get_web_config() -> InfoOut:
try:
data = await Config.get_web_config()
except Exception as e:
return InfoOut(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
)
return InfoOut(data={"WebConfig": data})
@router.post(
"/get/overview", summary="信息总览", response_model=InfoOut, status_code=200
)
async def get_overview() -> InfoOut:
try:
stage = await Config.get_stage_info("Info")
proxy = await Config.get_proxy_overview()
except Exception as e:
return InfoOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
data={"Stage": [], "Proxy": []},
)
return InfoOut(data={"Stage": stage, "Proxy": proxy})

View File

@@ -1,106 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from fastapi import APIRouter, Body
from app.core import Config
from app.models.schema import *
router = APIRouter(prefix="/api/plan", tags=["计划管理"])
@router.post(
"/add", summary="添加计划表", response_model=PlanCreateOut, status_code=200
)
async def add_plan(plan: PlanCreateIn = Body(...)) -> PlanCreateOut:
try:
uid, config = await Config.add_plan(plan.type)
data = MaaPlanConfig(**(await config.toDict()))
except Exception as e:
return PlanCreateOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
planId="",
data=MaaPlanConfig(**{}),
)
return PlanCreateOut(planId=str(uid), data=data)
@router.post("/get", summary="查询计划表", response_model=PlanGetOut, status_code=200)
async def get_plan(plan: PlanGetIn = Body(...)) -> PlanGetOut:
try:
index, data = await Config.get_plan(plan.planId)
index = [PlanIndexItem(**_) for _ in index]
data = {uid: MaaPlanConfig(**cfg) for uid, cfg in data.items()}
except Exception as e:
return PlanGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return PlanGetOut(index=index, data=data)
@router.post(
"/update", summary="更新计划表配置信息", response_model=OutBase, status_code=200
)
async def update_plan(plan: PlanUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_plan(plan.planId, plan.data.model_dump(exclude_unset=True))
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/delete", summary="删除计划表", response_model=OutBase, status_code=200)
async def delete_plan(plan: PlanDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_plan(plan.planId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/order", summary="重新排序计划表", response_model=OutBase, status_code=200
)
async def reorder_plan(plan: PlanReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_plan(plan.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,259 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from fastapi import APIRouter, Body
from app.core import Config
from app.models.schema import *
router = APIRouter(prefix="/api/queue", tags=["调度队列管理"])
@router.post(
"/add", summary="添加调度队列", response_model=QueueCreateOut, status_code=200
)
async def add_queue() -> QueueCreateOut:
try:
uid, config = await Config.add_queue()
data = QueueConfig(**(await config.toDict()))
except Exception as e:
return QueueCreateOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
queueId="",
data=QueueConfig(**{}),
)
return QueueCreateOut(queueId=str(uid), data=data)
@router.post(
"/get", summary="查询调度队列配置信息", response_model=QueueGetOut, status_code=200
)
async def get_queues(queue: QueueGetIn = Body(...)) -> QueueGetOut:
try:
index, config = await Config.get_queue(queue.queueId)
index = [QueueIndexItem(**_) for _ in index]
data = {uid: QueueConfig(**cfg) for uid, cfg in config.items()}
except Exception as e:
return QueueGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return QueueGetOut(index=index, data=data)
@router.post(
"/update", summary="更新调度队列配置信息", response_model=OutBase, status_code=200
)
async def update_queue(queue: QueueUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_queue(
queue.queueId, queue.data.model_dump(exclude_unset=True)
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/delete", summary="删除调度队列", response_model=OutBase, status_code=200)
async def delete_queue(queue: QueueDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_queue(queue.queueId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/order", summary="重新排序", response_model=OutBase, status_code=200)
async def reorder_queue(script: QueueReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_queue(script.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/time/get", summary="查询定时项", response_model=TimeSetGetOut, status_code=200
)
async def get_time_set(time: TimeSetGetIn = Body(...)) -> TimeSetGetOut:
try:
index, data = await Config.get_time_set(time.queueId, time.timeSetId)
index = [TimeSetIndexItem(**_) for _ in index]
data = {uid: TimeSet(**cfg) for uid, cfg in data.items()}
except Exception as e:
return TimeSetGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return TimeSetGetOut(index=index, data=data)
@router.post(
"/time/add", summary="添加定时项", response_model=TimeSetCreateOut, status_code=200
)
async def add_time_set(time: QueueSetInBase = Body(...)) -> TimeSetCreateOut:
uid, config = await Config.add_time_set(time.queueId)
data = TimeSet(**(await config.toDict()))
return TimeSetCreateOut(timeSetId=str(uid), data=data)
@router.post(
"/time/update", summary="更新定时项", response_model=OutBase, status_code=200
)
async def update_time_set(time: TimeSetUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_time_set(
time.queueId, time.timeSetId, time.data.model_dump(exclude_unset=True)
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/time/delete", summary="删除定时项", response_model=OutBase, status_code=200
)
async def delete_time_set(time: TimeSetDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_time_set(time.queueId, time.timeSetId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/time/order", summary="重新排序定时项", response_model=OutBase, status_code=200
)
async def reorder_time_set(time: TimeSetReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_time_set(time.queueId, time.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/item/get", summary="查询队列项", response_model=QueueItemGetOut, status_code=200
)
async def get_item(item: QueueItemGetIn = Body(...)) -> QueueItemGetOut:
try:
index, data = await Config.get_queue_item(item.queueId, item.queueItemId)
index = [QueueItemIndexItem(**_) for _ in index]
data = {uid: QueueItem(**cfg) for uid, cfg in data.items()}
except Exception as e:
return QueueItemGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return QueueItemGetOut(index=index, data=data)
@router.post(
"/item/add",
summary="添加队列项",
response_model=QueueItemCreateOut,
status_code=200,
)
async def add_item(item: QueueSetInBase = Body(...)) -> QueueItemCreateOut:
uid, config = await Config.add_queue_item(item.queueId)
data = QueueItem(**(await config.toDict()))
return QueueItemCreateOut(queueItemId=str(uid), data=data)
@router.post(
"/item/update", summary="更新队列项", response_model=OutBase, status_code=200
)
async def update_item(item: QueueItemUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_queue_item(
item.queueId, item.queueItemId, item.data.model_dump(exclude_unset=True)
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/item/delete", summary="删除队列项", response_model=OutBase, status_code=200
)
async def delete_item(item: QueueItemDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_queue_item(item.queueId, item.queueItemId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/item/order", summary="重新排序队列项", response_model=OutBase, status_code=200
)
async def reorder_item(item: QueueItemReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_queue_item(item.queueId, item.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,283 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import uuid
from fastapi import APIRouter, Body
from app.core import Config
from app.models.schema import *
router = APIRouter(prefix="/api/scripts", tags=["脚本管理"])
SCRIPT_BOOK = {"MaaConfig": MaaConfig, "GeneralConfig": GeneralConfig}
USER_BOOK = {"MaaConfig": MaaUserConfig, "GeneralConfig": GeneralUserConfig}
@router.post(
"/add", summary="添加脚本", response_model=ScriptCreateOut, status_code=200
)
async def add_script(script: ScriptCreateIn = Body(...)) -> ScriptCreateOut:
try:
uid, config = await Config.add_script(script.type)
data = SCRIPT_BOOK[type(config).__name__](**(await config.toDict()))
except Exception as e:
return ScriptCreateOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
scriptId="",
data=GeneralConfig(**{}),
)
return ScriptCreateOut(scriptId=str(uid), data=data)
@router.post(
"/get", summary="查询脚本配置信息", response_model=ScriptGetOut, status_code=200
)
async def get_script(script: ScriptGetIn = Body(...)) -> ScriptGetOut:
try:
index, data = await Config.get_script(script.scriptId)
index = [ScriptIndexItem(**_) for _ in index]
data = {
uid: SCRIPT_BOOK[next((_.type for _ in index if _.uid == uid), "General")](
**cfg
)
for uid, cfg in data.items()
}
except Exception as e:
return ScriptGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return ScriptGetOut(index=index, data=data)
@router.post(
"/update", summary="更新脚本配置信息", response_model=OutBase, status_code=200
)
async def update_script(script: ScriptUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_script(
script.scriptId, script.data.model_dump(exclude_unset=True)
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/delete", summary="删除脚本", response_model=OutBase, status_code=200)
async def delete_script(script: ScriptDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_script(script.scriptId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/order", summary="重新排序脚本", response_model=OutBase, status_code=200)
async def reorder_script(script: ScriptReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_script(script.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/import/file", summary="从文件加载脚本", response_model=OutBase, status_code=200
)
async def import_script_from_file(script: ScriptFileIn = Body(...)) -> OutBase:
try:
await Config.import_script_from_file(script.scriptId, script.jsonFile)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/export/file", summary="导出脚本到文件", response_model=OutBase, status_code=200
)
async def export_script_to_file(script: ScriptFileIn = Body(...)) -> OutBase:
try:
await Config.export_script_to_file(script.scriptId, script.jsonFile)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/import/web", summary="从网络加载脚本", response_model=OutBase, status_code=200
)
async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase:
try:
await Config.import_script_from_web(script.scriptId, script.url)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/Upload/web", summary="上传脚本配置到网络", response_model=OutBase, status_code=200
)
async def upload_script_to_web(script: ScriptUploadIn = Body(...)) -> OutBase:
try:
await Config.upload_script_to_web(
script.scriptId, script.config_name, script.author, script.description
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/user/get", summary="查询用户", response_model=UserGetOut, status_code=200
)
async def get_user(user: UserGetIn = Body(...)) -> UserGetOut:
try:
index, data = await Config.get_user(user.scriptId, user.userId)
index = [UserIndexItem(**_) for _ in index]
data = {
uid: USER_BOOK[
type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__
](**cfg)
for uid, cfg in data.items()
}
except Exception as e:
return UserGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return UserGetOut(index=index, data=data)
@router.post(
"/user/add", summary="添加用户", response_model=UserCreateOut, status_code=200
)
async def add_user(user: UserInBase = Body(...)) -> UserCreateOut:
try:
uid, config = await Config.add_user(user.scriptId)
data = USER_BOOK[type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__](
**(await config.toDict())
)
except Exception as e:
return UserCreateOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
userId="",
data=GeneralUserConfig(**{}),
)
return UserCreateOut(userId=str(uid), data=data)
@router.post(
"/user/update", summary="更新用户配置信息", response_model=OutBase, status_code=200
)
async def update_user(user: UserUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_user(
user.scriptId, user.userId, user.data.model_dump(exclude_unset=True)
)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/user/delete", summary="删除用户", response_model=OutBase, status_code=200
)
async def delete_user(user: UserDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_user(user.scriptId, user.userId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/user/order", summary="重新排序用户", response_model=OutBase, status_code=200
)
async def reorder_user(user: UserReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_user(user.scriptId, user.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/user/infrastructure",
summary="导入基建配置文件",
response_model=OutBase,
status_code=200,
)
async def import_infrastructure(user: UserSetIn = Body(...)) -> OutBase:
try:
await Config.set_infrastructure(user.scriptId, user.userId, user.jsonFile)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,268 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import os
from pathlib import Path
import shutil
from fastapi import APIRouter, Body
from app.core import Config
from app.services import System, Notify
from app.models.schema import *
import uuid
router = APIRouter(prefix="/api/setting", tags=["全局设置"])
@router.post("/get", summary="查询配置", response_model=SettingGetOut, status_code=200)
async def get_scripts() -> SettingGetOut:
"""查询配置"""
try:
data = await Config.get_setting()
except Exception as e:
return SettingGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
data=GlobalConfig(**{}),
)
return SettingGetOut(data=GlobalConfig(**data))
@router.post("/update", summary="更新配置", response_model=OutBase, status_code=200)
async def update_script(script: SettingUpdateIn = Body(...)) -> OutBase:
"""更新配置"""
try:
data = script.data.model_dump(exclude_unset=True)
await Config.update_setting(data)
if data.get("Start", {}).get("IfSelfStart", None) is not None:
await System.set_SelfStart()
if data.get("Function", None) is not None:
function = data["Function"]
if function.get("IfAllowSleep", None) is not None:
await System.set_Sleep()
if function.get("IfSkipMumuSplashAds", None) is not None:
MuMu_splash_ads_path = (
Path(os.getenv("APPDATA") or "")
/ "Netease/MuMuPlayer-12.0/data/startupImage"
)
if Config.get("Function", "IfSkipMumuSplashAds"):
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_dir():
shutil.rmtree(MuMu_splash_ads_path)
MuMu_splash_ads_path.touch()
else:
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_file():
MuMu_splash_ads_path.unlink()
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/test_notify", summary="测试通知", response_model=OutBase, status_code=200
)
async def test_notify() -> OutBase:
"""测试通知"""
try:
await Notify.send_test_notification()
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/create",
summary="创建自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def create_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""创建自定义Webhook"""
try:
# 生成唯一ID
webhook_id = str(uuid.uuid4())
# 创建webhook配置
webhook_config = {
"id": webhook_id,
"name": webhook_data.get("name", ""),
"url": webhook_data.get("url", ""),
"template": webhook_data.get("template", ""),
"enabled": webhook_data.get("enabled", True),
"headers": webhook_data.get("headers", {}),
"method": webhook_data.get("method", "POST"),
}
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 添加新webhook
custom_webhooks.append(webhook_config)
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message=f"Webhook '{webhook_config['name']}' 创建成功")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
@router.post(
"/webhook/update",
summary="更新自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def update_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""更新自定义Webhook"""
try:
webhook_id = webhook_data.get("id")
if not webhook_id:
return OutBase(code=400, status="error", message="缺少Webhook ID")
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 查找并更新webhook
updated = False
for i, webhook in enumerate(custom_webhooks):
if webhook.get("id") == webhook_id:
custom_webhooks[i].update(
{
"name": webhook_data.get("name", webhook.get("name", "")),
"url": webhook_data.get("url", webhook.get("url", "")),
"template": webhook_data.get(
"template", webhook.get("template", "")
),
"enabled": webhook_data.get(
"enabled", webhook.get("enabled", True)
),
"headers": webhook_data.get(
"headers", webhook.get("headers", {})
),
"method": webhook_data.get(
"method", webhook.get("method", "POST")
),
}
)
updated = True
break
if not updated:
return OutBase(code=404, status="error", message="Webhook不存在")
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message="Webhook更新成功")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
@router.post(
"/webhook/delete",
summary="删除自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def delete_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""删除自定义Webhook"""
try:
webhook_id = webhook_data.get("id")
if not webhook_id:
return OutBase(code=400, status="error", message="缺少Webhook ID")
# 获取当前配置
current_config = await Config.get_setting()
custom_webhooks = current_config.get("Notify", {}).get("CustomWebhooks", [])
# 查找并删除webhook
original_length = len(custom_webhooks)
custom_webhooks = [w for w in custom_webhooks if w.get("id") != webhook_id]
if len(custom_webhooks) == original_length:
return OutBase(code=404, status="error", message="Webhook不存在")
# 更新配置
update_data = {"Notify": {"CustomWebhooks": custom_webhooks}}
await Config.update_setting(update_data)
return OutBase(message="Webhook删除成功")
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
@router.post(
"/webhook/test",
summary="测试自定义Webhook",
response_model=OutBase,
status_code=200,
)
async def test_webhook(webhook_data: dict = Body(...)) -> OutBase:
"""测试自定义Webhook"""
try:
webhook_config = {
"name": webhook_data.get("name", "测试Webhook"),
"url": webhook_data.get("url", ""),
"template": webhook_data.get("template", ""),
"enabled": True,
"headers": webhook_data.get("headers", {}),
"method": webhook_data.get("method", "POST"),
}
await Notify.CustomWebhookPush(
"AUTO-MAS Webhook测试",
"这是一条测试消息如果您收到此消息说明Webhook配置正确",
webhook_config,
)
return OutBase(message="Webhook测试成功")
except Exception as e:
return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")

View File

@@ -1,82 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import asyncio
from fastapi import APIRouter, Body
from app.core import Config
from app.services import Updater
from app.models.schema import *
router = APIRouter(prefix="/api/update", tags=["软件更新"])
@router.post(
"/check", summary="检查更新", response_model=UpdateCheckOut, status_code=200
)
async def check_update(version: UpdateCheckIn = Body(...)) -> UpdateCheckOut:
try:
if_need, latest_version, update_info = await Updater.check_update(
current_version=version.current_version, if_force=version.if_force
)
except Exception as e:
return UpdateCheckOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
if_need_update=False,
latest_version="",
update_info={},
)
return UpdateCheckOut(
if_need_update=if_need, latest_version=latest_version, update_info=update_info
)
@router.post("/download", summary="下载更新", response_model=OutBase, status_code=200)
async def download_update() -> OutBase:
try:
task = asyncio.create_task(Updater.download_update())
Config.temp_task.append(task)
task.add_done_callback(lambda t: Config.temp_task.remove(t))
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post("/install", summary="安装更新", response_model=OutBase, status_code=200)
async def install_update() -> OutBase:
try:
task = asyncio.create_task(Updater.install_update())
Config.temp_task.append(task)
task.add_done_callback(lambda t: Config.temp_task.remove(t))
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()

View File

@@ -1,41 +1,61 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA核心组件包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .broadcast import Broadcast
from .config import Config, MaaConfig, GeneralConfig, MaaUserConfig, GeneralUserConfig
from .config import (
QueueConfig,
MaaConfig,
MaaUserConfig,
MaaPlanConfig,
GeneralConfig,
GeneralSubConfig,
Config,
)
from .main_info_bar import MainInfoBar
from .network import Network
from .sound_player import SoundPlayer
from .task_manager import Task, TaskManager
from .timer import MainTimer
from .task_manager import TaskManager
__all__ = [
"Broadcast",
"Config",
"QueueConfig",
"MaaConfig",
"GeneralConfig",
"MainTimer",
"TaskManager",
"MaaUserConfig",
"GeneralUserConfig",
"MaaPlanConfig",
"GeneralConfig",
"GeneralSubConfig",
"MainInfoBar",
"Network",
"SoundPlayer",
"Task",
"TaskManager",
"MainTimer",
]

View File

@@ -1,53 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import asyncio
from copy import deepcopy
from typing import Set
from app.utils import get_logger
logger = get_logger("消息广播")
class _Broadcast:
def __init__(self):
self.__subscribers: Set[asyncio.Queue] = set()
async def subscribe(self, queue: asyncio.Queue):
"""订阅者注册"""
self.__subscribers.add(queue)
async def unsubscribe(self, queue: asyncio.Queue):
"""取消订阅"""
self.__subscribers.remove(queue)
async def put(self, item):
"""向所有订阅者广播消息"""
logger.debug(f"向所有订阅者广播消息: {item}")
for subscriber in self.__subscribers:
await subscriber.put(deepcopy(item))
Broadcast = _Broadcast()

File diff suppressed because it is too large Load Diff

89
app/core/main_info_bar.py Normal file
View File

@@ -0,0 +1,89 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA信息通知栏
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import Qt
from qfluentwidgets import InfoBar, InfoBarPosition
from .config import Config
from .sound_player import SoundPlayer
class _MainInfoBar:
"""信息通知栏"""
# 模式到 InfoBar 方法的映射
mode_mapping = {
"success": InfoBar.success,
"warning": InfoBar.warning,
"error": InfoBar.error,
"info": InfoBar.info,
}
def push_info_bar(
self, mode: str, title: str, content: str, time: int, if_force: bool = False
):
"""推送到信息通知栏"""
if Config.main_window is None:
logger.error("信息通知栏未设置父窗口")
return None
# 根据 mode 获取对应的 InfoBar 方法
info_bar_method = self.mode_mapping.get(mode)
if not info_bar_method:
logger.error(f"未知的通知栏模式: {mode}")
return None
if Config.main_window.isVisible():
info_bar_method(
title=title,
content=content,
orient=Qt.Horizontal,
isClosable=True,
position=InfoBarPosition.TOP_RIGHT,
duration=time,
parent=Config.main_window,
)
elif if_force:
# 如果主窗口不可见且强制推送,则录入消息队列等待窗口显示后推送
info_bar_item = {
"mode": mode,
"title": title,
"content": content,
"time": time,
}
if info_bar_item not in Config.info_bar_list:
Config.info_bar_list.append(info_bar_item)
if mode == "warning":
SoundPlayer.play("发生异常")
if mode == "error":
SoundPlayer.play("发生错误")
MainInfoBar = _MainInfoBar()

161
app/core/network.py Normal file
View File

@@ -0,0 +1,161 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA网络请求线程
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import QObject, QThread, QEventLoop
import re
import time
import requests
import truststore
from pathlib import Path
class NetworkThread(QThread):
"""网络请求线程类"""
max_retries = 3
timeout = 10
backoff_factor = 0.1
def __init__(self, mode: str, url: str, path: Path = None) -> None:
super().__init__()
self.setObjectName(
f"NetworkThread-{mode}-{re.sub(r'(&cdk=)[^&]+(&)', r'\1******\2', url)}"
)
self.mode = mode
self.url = url
self.path = path
from .config import Config
self.proxies = {
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
}
self.status_code = None
self.response_json = None
self.error_message = None
self.loop = QEventLoop()
truststore.inject_into_ssl()
@logger.catch
def run(self) -> None:
"""运行网络请求线程"""
if self.mode == "get":
self.get_json(self.url)
elif self.mode == "get_file":
self.get_file(self.url, self.path)
def get_json(self, url: str) -> None:
"""通过get方法获取json数据"""
response = None
for _ in range(self.max_retries):
try:
response = requests.get(url, timeout=self.timeout, proxies=self.proxies)
self.status_code = response.status_code
self.response_json = response.json()
self.error_message = None
break
except Exception as e:
self.status_code = response.status_code if response else None
self.response_json = None
self.error_message = str(e)
time.sleep(self.backoff_factor)
self.loop.quit()
def get_file(self, url: str, path: Path) -> None:
"""通过get方法下载文件"""
response = None
try:
response = requests.get(url, timeout=10, proxies=self.proxies)
if response.status_code == 200:
with open(path, "wb") as file:
file.write(response.content)
self.status_code = response.status_code
else:
self.status_code = response.status_code
self.error_message = "下载失败"
except Exception as e:
self.status_code = response.status_code if response else None
self.error_message = str(e)
self.loop.quit()
class _Network(QObject):
"""网络请求线程类"""
def __init__(self) -> None:
super().__init__()
self.task_queue = []
def add_task(self, mode: str, url: str, path: Path = None) -> NetworkThread:
"""添加网络请求任务"""
network_thread = NetworkThread(mode, url, path)
self.task_queue.append(network_thread)
network_thread.start()
return network_thread
def get_result(self, network_thread: NetworkThread) -> dict:
"""获取网络请求结果"""
result = {
"status_code": network_thread.status_code,
"response_json": network_thread.response_json,
"error_message": (
re.sub(r"(&cdk=)[^&]+(&)", r"\1******\2", network_thread.error_message)
if network_thread.error_message
else None
),
}
network_thread.quit()
network_thread.wait()
self.task_queue.remove(network_thread)
network_thread.deleteLater()
return result
Network = _Network()

69
app/core/sound_player.py Normal file
View File

@@ -0,0 +1,69 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA音效播放器
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import QObject, QUrl
from PySide6.QtMultimedia import QSoundEffect
from pathlib import Path
from .config import Config
class _SoundPlayer(QObject):
def __init__(self):
super().__init__()
self.sounds_path = Config.app_path / "resources/sounds"
def play(self, sound_name: str):
if not Config.get(Config.voice_Enabled):
return
if (self.sounds_path / f"both/{sound_name}.wav").exists():
self.play_voice(self.sounds_path / f"both/{sound_name}.wav")
elif (
self.sounds_path / Config.get(Config.voice_Type) / f"{sound_name}.wav"
).exists():
self.play_voice(
self.sounds_path / Config.get(Config.voice_Type) / f"{sound_name}.wav"
)
def play_voice(self, sound_path: Path):
effect = QSoundEffect(self)
effect.setVolume(1)
effect.setSource(QUrl.fromLocalFile(sound_path))
effect.play()
SoundPlayer = _SoundPlayer()

View File

@@ -1,395 +1,393 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA业务调度器
v4.4
作者DLmaster_361
"""
import uuid
import asyncio
from functools import partial
from typing import Dict, Optional, Literal
from loguru import logger
from PySide6.QtCore import QThread, QObject, Signal
from qfluentwidgets import MessageBox
from datetime import datetime
from packaging import version
from typing import Dict, Union
from .config import Config, MaaConfig, GeneralConfig, QueueConfig
from .config import Config
from .main_info_bar import MainInfoBar
from .network import Network
from .sound_player import SoundPlayer
from app.models import MaaManager, GeneralManager
from app.services import System
from app.models.schema import WebSocketMessage
from app.utils import get_logger
from app.task import *
from app.utils.constants import POWER_SIGN_MAP
logger = get_logger("业务调度")
class Task(QThread):
"""业务线程"""
check_maa_version = Signal(str)
push_info_bar = Signal(str, str, str, int)
play_sound = Signal(str)
question = Signal(str, str)
question_response = Signal(bool)
update_maa_user_info = Signal(str, dict)
update_general_sub_info = Signal(str, dict)
create_task_list = Signal(list)
create_user_list = Signal(list)
update_task_list = Signal(list)
update_user_list = Signal(list)
update_log_text = Signal(str)
accomplish = Signal(list)
class _TaskManager:
"""业务调度器"""
def __init__(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
super(Task, self).__init__()
def __init__(self):
super().__init__()
self.setObjectName(f"Task-{mode}-{name}")
self.task_dict: Dict[uuid.UUID, asyncio.Task] = {}
self.mode = mode
self.name = name
self.info = info
async def add_task(
self, mode: Literal["自动代理", "人工排查", "设置脚本"], uid: str
) -> uuid.UUID:
"""
添加任务
self.logs = []
:param mode: 任务模式
:param uid: 任务UID
"""
actual_id = uuid.UUID(uid)
if mode == "设置脚本":
if actual_id in Config.ScriptConfig:
task_id = actual_id
actual_id = None
else:
for script_id, script in Config.ScriptConfig.items():
if (
isinstance(script, (MaaConfig | GeneralConfig))
and actual_id in script.UserData
):
task_id = script_id
break
else:
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
elif actual_id in Config.QueueConfig:
task_id = actual_id
actual_id = None
elif actual_id in Config.ScriptConfig:
task_id = uuid.uuid4()
else:
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
if task_id in self.task_dict or (
actual_id is not None and actual_id in self.task_dict
):
raise RuntimeError(f"任务 {task_id} 已在运行")
logger.info(f"创建任务: {task_id}, 模式: {mode}")
self.task_dict[task_id] = asyncio.create_task(
self.run_task(mode, task_id, actual_id)
)
self.task_dict[task_id].add_done_callback(
lambda t: asyncio.create_task(self.remove_task(t, mode, task_id))
)
return task_id
self.question_response.connect(lambda: print("response"))
@logger.catch
async def run_task(
self, mode: str, task_id: uuid.UUID, actual_id: Optional[uuid.UUID]
):
def run(self):
logger.info(f"开始运行任务: {task_id}, 模式: {mode}")
if "设置MAA" in self.mode:
if mode == "设置脚本":
logger.info(f"任务开始:设置{self.name}")
self.push_info_bar.emit("info", "设置MAA", self.name, 3000)
if isinstance(Config.ScriptConfig[task_id], MaaConfig):
task_item = MaaManager(mode, task_id, actual_id, str(task_id))
elif isinstance(Config.ScriptConfig[task_id], GeneralConfig):
task_item = GeneralManager(mode, task_id, actual_id, str(task_id))
else:
logger.error(
f"不支持的脚本类型: {type(Config.ScriptConfig[task_id]).__name__}"
)
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Info",
data={"Error": "脚本类型不支持"},
).model_dump()
)
return
uid = actual_id or uuid.uuid4()
self.task_dict[uid] = asyncio.create_task(task_item.run())
self.task_dict[uid].add_done_callback(
lambda t: asyncio.create_task(task_item.final_task(t))
self.task = MaaManager(
self.mode,
Config.member_dict[self.name],
(None if "全局" in self.mode else self.info["SetMaaInfo"]["Path"]),
)
self.task_dict[uid].add_done_callback(partial(self.task_dict.pop, uid))
self.task.check_maa_version.connect(self.check_maa_version.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
try:
await self.task_dict[uid]
self.task.run()
except Exception as e:
logger.error(f"任务 {task_id} 运行出错: {type(e).__name__}: {str(e)}")
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Info",
data={"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"},
).model_dump()
)
logger.exception(f"任务异常:{self.name},错误信息:{e}")
self.push_info_bar.emit("error", "任务异常", self.name, -1)
else:
elif self.mode == "设置通用脚本":
# 初始化任务列表
if task_id in Config.QueueConfig:
logger.info(f"任务开始:设置{self.name}")
self.push_info_bar.emit("info", "设置通用脚本", self.name, 3000)
queue = Config.QueueConfig[task_id]
if not isinstance(queue, QueueConfig):
return
task_list = []
for queue_item in queue.QueueItem.values():
if queue_item.get("Info", "ScriptId") == "-":
continue
script_id = uuid.UUID(queue_item.get("Info", "ScriptId"))
script = Config.ScriptConfig[script_id]
if not isinstance(script, (MaaConfig | GeneralConfig)):
logger.error(f"不支持的脚本类型: {type(script).__name__}")
continue
task_list.append(
{
"script_id": str(script_id),
"status": "等待",
"name": script.get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in script.UserData.items()
if config.get("Info", "Status")
and config.get("Info", "RemainedDay") != 0
],
}
)
elif actual_id is not None and actual_id in Config.ScriptConfig:
script = Config.ScriptConfig[actual_id]
if not isinstance(script, (MaaConfig | GeneralConfig)):
logger.error(f"不支持的脚本类型: {type(script).__name__}")
return
task_list = [
{
"script_id": str(actual_id),
"status": "等待",
"name": script.get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in script.UserData.items()
if config.get("Info", "Status")
and config.get("Info", "RemainedDay") != 0
],
}
]
await Config.send_json(
WebSocketMessage(
id=str(task_id), type="Update", data={"task_dict": task_list}
).model_dump()
self.task = GeneralManager(
self.mode,
Config.member_dict[self.name],
self.info["SetSubInfo"]["Path"],
)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
# 清理用户列表初值
for task in task_list:
task.pop("user_list", None)
try:
self.task.run()
except Exception as e:
logger.exception(f"任务异常:{self.name},错误信息:{e}")
self.push_info_bar.emit("error", "任务异常", self.name, -1)
for task in task_list:
script_id = uuid.UUID(task["script_id"])
# 检查任务是否在运行列表中
if script_id in self.task_dict:
task["status"] = "跳过"
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Update",
data={"task_list": task_list},
).model_dump()
)
logger.info(f"跳过任务: {script_id}, 该任务已在运行列表中")
continue
# 检查任务对应脚本是否仍存在
if script_id in self.task_dict:
task["status"] = "异常"
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Update",
data={"task_list": task_list},
).model_dump()
)
logger.info(f"跳过任务: {script_id}, 该任务对应脚本已被删除")
continue
# 标记为运行中
task["status"] = "运行"
await Config.send_json(
WebSocketMessage(
id=str(task_id), type="Update", data={"task_list": task_list}
).model_dump()
)
logger.info(f"任务开始: {script_id}")
if isinstance(Config.ScriptConfig[script_id], MaaConfig):
task_item = MaaManager(mode, script_id, None, str(task_id))
elif isinstance(Config.ScriptConfig[script_id], GeneralConfig):
task_item = GeneralManager(mode, script_id, actual_id, str(task_id))
else:
logger.error(
f"不支持的脚本类型: {type(Config.ScriptConfig[script_id]).__name__}"
)
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Info",
data={"Error": "脚本类型不支持"},
).model_dump()
)
continue
self.task_dict[script_id] = asyncio.create_task(task_item.run())
self.task_dict[script_id].add_done_callback(
lambda t: asyncio.create_task(task_item.final_task(t))
)
self.task_dict[script_id].add_done_callback(
partial(self.task_dict.pop, script_id)
)
try:
await self.task_dict[script_id]
task["status"] = "完成"
except Exception as e:
logger.error(
f"任务 {script_id} 运行出错: {type(e).__name__}: {str(e)}"
)
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Info",
data={
"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"
},
).model_dump()
)
task["status"] = "异常"
await Config.send_json(
WebSocketMessage(
id=str(task_id),
type="Update",
data={"task_list": task_list},
).model_dump()
)
async def stop_task(self, task_id: str) -> None:
"""
中止任务
:param task_id: 任务ID
"""
logger.info(f"中止任务: {task_id}")
if task_id == "ALL":
for task in self.task_dict.values():
task.cancel()
else:
uid = uuid.UUID(task_id)
if uid not in self.task_dict:
raise ValueError("任务未在运行")
self.task_dict[uid].cancel()
async def remove_task(
self, task: asyncio.Task, mode: str, task_id: uuid.UUID
) -> None:
"""
处理任务结束后的收尾工作
self.task_list = [
[
(
value
if Config.member_dict[value]["Config"].get_name() == ""
else f"{value} - {Config.member_dict[value]["Config"].get_name()}"
),
"等待",
value,
]
for _, value in sorted(
self.info["Queue"].items(), key=lambda x: int(x[0][7:])
)
if value != "禁用"
]
Parameters
----------
task : asyncio.Task
任务对象
mode : str
任务模式
task_id : uuid.UUID
任务ID
"""
self.create_task_list.emit(self.task_list)
logger.info(f"任务结束: {task_id}")
for task in self.task_list:
# 从任务字典中移除任务
try:
await task
except asyncio.CancelledError:
logger.info(f"任务 {task_id} 已结束")
self.task_dict.pop(task_id)
if self.isInterruptionRequested():
break
await Config.send_json(
WebSocketMessage(
id=str(task_id), type="Signal", data={"Accomplish": "无描述"}
).model_dump()
task[1] = "运行"
self.update_task_list.emit(self.task_list)
if task[2] in Config.running_list:
task[1] = "跳过"
self.update_task_list.emit(self.task_list)
logger.info(f"跳过任务:{task[0]}")
self.push_info_bar.emit("info", "跳过任务", task[0], 3000)
continue
Config.running_list.append(task[2])
logger.info(f"任务开始:{task[0]}")
self.push_info_bar.emit("info", "任务开始", task[0], 3000)
if Config.member_dict[task[2]]["Type"] == "Maa":
self.task = MaaManager(
self.mode[0:4],
Config.member_dict[task[2]],
)
self.task.check_maa_version.connect(self.check_maa_version.emit)
self.task.question.connect(self.question.emit)
self.question_response.disconnect()
self.question_response.connect(self.task.question_response.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.create_user_list.connect(self.create_user_list.emit)
self.task.update_user_list.connect(self.update_user_list.emit)
self.task.update_log_text.connect(self.update_log_text.emit)
self.task.update_user_info.connect(self.update_maa_user_info.emit)
self.task.accomplish.connect(
lambda log: self.task_accomplish(task[2], log)
)
elif Config.member_dict[task[2]]["Type"] == "General":
self.task = GeneralManager(
self.mode[0:4],
Config.member_dict[task[2]],
)
self.task.question.connect(self.question.emit)
self.question_response.disconnect()
self.question_response.connect(self.task.question_response.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.create_user_list.connect(self.create_user_list.emit)
self.task.update_user_list.connect(self.update_user_list.emit)
self.task.update_log_text.connect(self.update_log_text.emit)
self.task.update_sub_info.connect(self.update_general_sub_info.emit)
self.task.accomplish.connect(
lambda log: self.task_accomplish(task[2], log)
)
try:
self.task.run()
task[1] = "完成"
self.update_task_list.emit(self.task_list)
logger.info(f"任务完成:{task[0]}")
self.push_info_bar.emit("info", "任务完成", task[0], 3000)
except Exception as e:
self.task_accomplish(
task[2],
{
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"History": f"任务异常,异常简报:{e}",
},
)
task[1] = "异常"
self.update_task_list.emit(self.task_list)
logger.exception(f"任务异常:{task[0]},错误信息:{e}")
self.push_info_bar.emit("error", "任务异常", task[0], -1)
Config.running_list.remove(task[2])
self.accomplish.emit(self.logs)
def task_accomplish(self, name: str, log: dict):
"""保存任务结果"""
self.logs.append([name, log])
self.task.deleteLater()
class _TaskManager(QObject):
"""业务调度器"""
create_gui = Signal(Task)
connect_gui = Signal(Task)
def __init__(self):
super(_TaskManager, self).__init__()
self.task_dict: Dict[str, Task] = {}
def add_task(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
"""添加任务"""
if name in Config.running_list or name in self.task_dict:
logger.warning(f"任务已存在:{name}")
MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000)
return None
logger.info(f"任务开始:{name}")
MainInfoBar.push_info_bar("info", "任务开始", name, 3000)
SoundPlayer.play("任务开始")
Config.running_list.append(name)
self.task_dict[name] = Task(mode, name, info)
self.task_dict[name].check_maa_version.connect(self.check_maa_version)
self.task_dict[name].question.connect(
lambda title, content: self.push_dialog(name, title, content)
)
self.task_dict[name].push_info_bar.connect(MainInfoBar.push_info_bar)
self.task_dict[name].play_sound.connect(SoundPlayer.play)
self.task_dict[name].update_maa_user_info.connect(Config.change_maa_user_info)
self.task_dict[name].update_general_sub_info.connect(
Config.change_general_sub_info
)
self.task_dict[name].accomplish.connect(
lambda logs: self.remove_task(mode, name, logs)
)
if mode == "自动代理" and task_id in Config.QueueConfig:
if "新调度台" in mode:
self.create_gui.emit(self.task_dict[name])
if Config.power_sign == "NoAction":
Config.power_sign = Config.QueueConfig[task_id].get(
"Info", "AfterAccomplish"
)
await Config.send_json(
WebSocketMessage(
id="Main", type="Update", data={"PowerSign": Config.power_sign}
).model_dump()
elif "主调度台" in mode:
self.connect_gui.emit(self.task_dict[name])
self.task_dict[name].start()
def stop_task(self, name: str):
"""中止任务"""
logger.info(f"中止任务:{name}")
MainInfoBar.push_info_bar("info", "中止任务", name, 3000)
if name == "ALL":
for name in self.task_dict:
self.task_dict[name].task.requestInterruption()
self.task_dict[name].requestInterruption()
self.task_dict[name].quit()
self.task_dict[name].wait()
elif name in self.task_dict:
self.task_dict[name].task.requestInterruption()
self.task_dict[name].requestInterruption()
self.task_dict[name].quit()
self.task_dict[name].wait()
def remove_task(self, mode: str, name: str, logs: list):
"""任务结束后的处理"""
logger.info(f"任务结束:{name}")
MainInfoBar.push_info_bar("info", "任务结束", name, 3000)
SoundPlayer.play("任务结束")
self.task_dict[name].deleteLater()
self.task_dict.pop(name)
Config.running_list.remove(name)
if "调度队列" in name and "人工排查" not in mode:
if len(logs) > 0:
time = logs[0][1]["Time"]
history = ""
for log in logs:
history += f"任务名称:{log[0]}{log[1]["History"].replace("\n","\n ")}\n"
Config.save_history(name, {"Time": time, "History": history})
else:
Config.save_history(
name,
{
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"History": "没有任务被执行",
},
)
if len(self.task_dict) == 0 and Config.power_sign != "NoAction":
logger.info(f"所有任务已结束,准备执行电源操作: {Config.power_sign}")
await Config.send_json(
WebSocketMessage(
id="Main",
type="Message",
data={
"type": "Countdown",
"title": f"{POWER_SIGN_MAP[Config.power_sign]}倒计时",
"message": f"程序将在倒计时结束后执行 {POWER_SIGN_MAP[Config.power_sign]} 操作",
},
).model_dump()
if (
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
)
await System.start_power_task()
async def start_startup_queue(self):
"""开始运行启动时运行的调度队列"""
logger.info("开始运行启动时任务")
for uid, queue in Config.QueueConfig.items():
if queue.get("Info", "StartUpEnabled") and uid not in self.task_dict:
logger.info(f"启动时需要运行的队列:{uid}")
task_id = await TaskManager.add_task("自动代理", str(uid))
await Config.send_json(
WebSocketMessage(
id="TaskManager", type="Signal", data={"newTask": str(task_id)}
).model_dump()
!= "NoAction"
and Config.power_sign == "NoAction"
):
Config.set_power_sign(
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
)
)
logger.success("启动时任务开始运行")
if Config.args.mode == "cli" and Config.power_sign == "NoAction":
Config.set_power_sign("KillSelf")
def check_maa_version(self, v: str):
"""检查MAA版本"""
network = Network.add_task(
mode="get",
url="https://mirrorchyan.com/api/resources/MAA/latest?user_agent=AutoMaaGui&os=win&arch=x64&channel=stable",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
maa_info = network_result["response_json"]
else:
logger.warning(f"获取MAA版本信息时出错{network_result['error_message']}")
MainInfoBar.push_info_bar(
"warning",
"获取MAA版本信息时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
return None
if version.parse(maa_info["data"]["version_name"]) > version.parse(v):
logger.info(
f"检测到MAA版本过低{v},最新版本:{maa_info['data']['version_name']}"
)
MainInfoBar.push_info_bar(
"info",
"MAA版本过低",
f"当前版本:{v},最新稳定版:{maa_info['data']['version_name']}",
-1,
)
def push_dialog(self, name: str, title: str, content: str):
"""推送对话框"""
choice = MessageBox(title, content, Config.main_window)
choice.yesButton.setText("")
choice.cancelButton.setText("")
self.task_dict[name].question_response.emit(bool(choice.exec()))
TaskManager = _TaskManager()

View File

@@ -1,149 +1,138 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import asyncio
import keyboard
"""
AUTO_MAA
AUTO_MAA主业务定时器
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import QObject, QTimer
from datetime import datetime
from pathlib import Path
import keyboard
from app.services import Matomo, System
from app.utils import get_logger
from app.models.schema import WebSocketMessage
from .config import Config, QueueConfig
from .config import Config
from .task_manager import TaskManager
from app.services import System
logger = get_logger("主业务定时器")
class _MainTimer(QObject):
def __init__(self, parent=None):
super().__init__(parent)
class _MainTimer:
self.Timer = QTimer()
self.Timer.timeout.connect(self.timed_start)
self.Timer.timeout.connect(self.set_silence)
self.Timer.timeout.connect(self.check_power)
self.Timer.start(1000)
self.LongTimer = QTimer()
self.LongTimer.timeout.connect(self.long_timed_task)
self.LongTimer.start(3600000)
async def second_task(self):
"""每秒定期任务"""
logger.info("每秒定期任务启动")
def long_timed_task(self):
"""长时间定期检定任务"""
while True:
Config.get_stage()
Config.main_window.setting.show_notice()
if Config.get(Config.update_IfAutoUpdate):
Config.main_window.setting.check_update()
await self.set_silence()
await self.timed_start()
await asyncio.sleep(1)
async def hour_task(self):
"""每小时定期任务"""
logger.info("每小时定期任务启动")
while True:
if (
datetime.strptime(
Config.get("Data", "LastStatisticsUpload"), "%Y-%m-%d %H:%M:%S"
).date()
!= datetime.now().date()
):
await Matomo.send_event(
"App",
"Version",
Config.version(),
1 if "beta" in Config.version() else 0,
)
await Config.set(
"Data",
"LastStatisticsUpload",
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
await asyncio.sleep(3600)
@logger.catch()
async def timed_start(self):
def timed_start(self):
"""定时启动代理任务"""
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
for name, info in Config.queue_dict.items():
for uid, queue in Config.QueueConfig.items():
if not info["Config"].get(info["Config"].queueSet_Enabled):
continue
if not isinstance(queue, QueueConfig) or not queue.get(
"Info", "TimeEnabled"
data = info["Config"].toDict()
time_set = [
data["Time"][f"TimeSet_{_}"]
for _ in range(10)
if data["Time"][f"TimeEnabled_{_}"]
]
# 按时间调起代理任务
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
if (
curtime[11:16] in time_set
and curtime
!= info["Config"].get(info["Config"].Data_LastProxyTime)[:16]
and name not in Config.running_list
):
continue
# 避免重复调起任务
if curtime == queue.get("Data", "LastTimedStart"):
continue
logger.info(f"定时任务:{name}")
TaskManager.add_task("自动代理_新调度台", name, data)
for time_set in queue.TimeSet.values():
if (
time_set.get("Info", "Enabled")
and curtime[11:16] == time_set.get("Info", "Time")
and uid not in Config.task_dict
):
logger.info(f"定时唤起任务:{uid}")
task_id = await TaskManager.add_task("自动代理", str(uid))
await queue.set("Data", "LastTimedStart", curtime)
await Config.QueueConfig.save()
await Config.send_json(
WebSocketMessage(
id="TaskManager",
type="Signal",
data={"newTask": str(task_id)},
).model_dump()
)
@logger.catch()
async def set_silence(self):
"""静默模式通过模拟老板键来隐藏模拟器窗口"""
def set_silence(self):
"""设置静默模式"""
if (
len(Config.if_ignore_silence) == 0
and Config.get("Function", "IfSilence")
and Config.get("Function", "BossKey") != ""
not Config.if_ignore_silence
and Config.get(Config.function_IfSilence)
and Config.get(Config.function_BossKey) != ""
):
windows = await System.get_window_info()
windows = System.get_window_info()
emulator_windows = []
for window in windows:
for emulator_path, endtime in Config.silence_dict.items():
if (
datetime.now() < endtime
and str(emulator_path) in window
and window[0] != "新通知" # 此处排除雷电名为新通知的窗口
):
emulator_windows.append(window)
if emulator_windows:
logger.info(f"检测到模拟器窗口: {emulator_windows}")
# 此处排除雷电名为新通知的窗口
if any(
str(emulator_path) in window and window[0] != "新通知"
for window in windows
for emulator_path in Config.silence_list
):
try:
keyboard.press_and_release(
"+".join(
_.strip().lower()
for _ in Config.get("Function", "BossKey").split("+")
for _ in Config.get(Config.function_BossKey).split("+")
)
)
logger.info(f"模拟按键: {Config.get('Function', 'BossKey')}")
except Exception as e:
logger.exception(f"模拟按键时出错: {e}")
logger.error(f"模拟按键时出错{e}")
def check_power(self):
if Config.power_sign != "NoAction" and not Config.running_list:
from app.ui import ProgressRingMessageBox
mode_book = {
"KillSelf": "退出软件",
"Sleep": "睡眠",
"Hibernate": "休眠",
"Shutdown": "关机",
}
choice = ProgressRingMessageBox(
Config.main_window, f"{mode_book[Config.power_sign]}倒计时"
)
if choice.exec():
System.set_power(Config.power_sign)
Config.set_power_sign("NoAction")
else:
Config.set_power_sign("NoAction")
MainTimer = _MainTimer()

View File

@@ -1,859 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import json
import uuid
import win32com.client
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import List, Any, Dict, Union, Optional
from app.utils import dpapi_encrypt, dpapi_decrypt
from app.utils.constants import RESERVED_NAMES, ILLEGAL_CHARS, DEFAULT_DATETIME
class ConfigValidator:
"""基础配置验证器"""
def validate(self, value: Any) -> bool:
"""验证值是否合法"""
return True
def correct(self, value: Any) -> Any:
"""修正非法值"""
return value
class RangeValidator(ConfigValidator):
"""范围验证器"""
def __init__(self, min: int | float, max: int | float):
self.min = min
self.max = max
self.range = (min, max)
def validate(self, value: Any) -> bool:
if not isinstance(value, (int | float)):
return False
return self.min <= value <= self.max
def correct(self, value: Any) -> int | float:
if not isinstance(value, (int, float)):
try:
value = float(value)
except TypeError:
return self.min
return min(max(self.min, value), self.max)
class OptionsValidator(ConfigValidator):
"""选项验证器"""
def __init__(self, options: list):
if not options:
raise ValueError("可选项不能为空")
self.options = options
def validate(self, value: Any) -> bool:
return value in self.options
def correct(self, value: Any) -> Any:
return value if self.validate(value) else self.options[0]
class UUIDValidator(ConfigValidator):
"""UUID验证器"""
def validate(self, value: Any) -> bool:
try:
uuid.UUID(value)
return True
except (TypeError, ValueError):
return False
def correct(self, value: Any) -> Any:
return value if self.validate(value) else str(uuid.uuid4())
class DateTimeValidator(ConfigValidator):
"""日期时间验证器"""
def __init__(self, date_format: str) -> None:
if not date_format:
raise ValueError("日期时间格式不能为空")
self.date_format = date_format
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
try:
datetime.strptime(value, self.date_format)
return True
except ValueError:
return False
def correct(self, value: Any) -> str:
if not isinstance(value, str):
return DEFAULT_DATETIME.strftime(self.date_format)
try:
datetime.strptime(value, self.date_format)
return value
except ValueError:
return DEFAULT_DATETIME.strftime(self.date_format)
class JSONValidator(ConfigValidator):
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
try:
json.loads(value)
return True
except json.JSONDecodeError:
return False
def correct(self, value: Any) -> str:
return value if self.validate(value) else "{ }"
class EncryptValidator(ConfigValidator):
"""加密数据验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
try:
dpapi_decrypt(value)
return True
except:
return False
def correct(self, value: Any) -> Any:
return value if self.validate(value) else dpapi_encrypt("数据损坏, 请重新设置")
class BoolValidator(OptionsValidator):
"""布尔值验证器"""
def __init__(self):
super().__init__([True, False])
class FileValidator(ConfigValidator):
"""文件路径验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
if not Path(value).is_absolute():
return False
if Path(value).suffix == ".lnk":
return False
return True
def correct(self, value: Any) -> str:
if not isinstance(value, str):
value = str(Path.cwd())
if not Path(value).is_absolute():
value = Path(value).resolve().as_posix()
if Path(value).suffix == ".lnk":
try:
shell = win32com.client.Dispatch("WScript.Shell")
shortcut = shell.CreateShortcut(value)
value = shortcut.TargetPath
except:
pass
return Path(value).resolve().as_posix()
class FolderValidator(ConfigValidator):
"""文件夹路径验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
if not Path(value).is_absolute():
return False
return True
def correct(self, value: Any) -> str:
if not isinstance(value, str):
value = str(Path.cwd())
return Path(value).resolve().as_posix()
class UserNameValidator(ConfigValidator):
"""用户名验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
if not value or not value.strip():
return False
if value != value.strip() or value != value.strip("."):
return False
if any(char in ILLEGAL_CHARS for char in value):
return False
if value.upper() in RESERVED_NAMES:
return False
if len(value) > 255:
return False
return True
def correct(self, value: Any) -> str:
if not isinstance(value, str):
value = "默认用户名"
value = value.strip().strip(".")
value = "".join(char for char in value if char not in ILLEGAL_CHARS)
if value.upper() in RESERVED_NAMES or not value:
value = "默认用户名"
if len(value) > 255:
value = value[:255]
return value
class ConfigItem:
"""配置项"""
def __init__(
self,
group: str,
name: str,
default: Any,
validator: Optional[ConfigValidator] = None,
):
"""
Parameters
----------
group: str
配置项分组名称
name: str
配置项字段名称
default: Any
配置项默认值
validator: ConfigValidator
配置项验证器, 默认为 None, 表示不进行验证
"""
super().__init__()
self.group = group
self.name = name
self.value: Any = default
self.validator = validator or ConfigValidator()
self.is_locked = False
if not self.validator.validate(self.value):
raise ValueError(
f"配置项 '{self.group}.{self.name}' 的默认值 '{self.value}' 不合法"
)
def setValue(self, value: Any):
"""
设置配置项值, 将自动进行验证和修正
Parameters
----------
value: Any
要设置的值, 可以是任何合法类型
"""
if (
dpapi_decrypt(self.value)
if isinstance(self.validator, EncryptValidator)
else self.value
) == value:
return
if self.is_locked:
raise ValueError(f"配置项 '{self.group}.{self.name}' 已锁定, 无法修改")
# deepcopy new value
try:
self.value = deepcopy(value)
except:
self.value = value
if isinstance(self.validator, EncryptValidator):
if self.validator.validate(self.value):
self.value = self.value
else:
self.value = dpapi_encrypt(self.value)
if not self.validator.validate(self.value):
self.value = self.validator.correct(self.value)
def getValue(self, if_decrypt: bool = True) -> Any:
"""
获取配置项值
"""
v = (
self.value
if self.validator.validate(self.value)
else self.validator.correct(self.value)
)
if isinstance(self.validator, EncryptValidator) and if_decrypt:
return dpapi_decrypt(v)
return v
def lock(self):
"""
锁定配置项, 锁定后无法修改配置项值
"""
self.is_locked = True
def unlock(self):
"""
解锁配置项, 解锁后可以修改配置项值
"""
self.is_locked = False
class ConfigBase:
"""
配置基类
这个类提供了基本的配置项管理功能, 包括连接配置文件、加载配置数据、获取和设置配置项值等。
此类不支持直接实例化, 必须通过子类来实现具体的配置项, 请继承此类并在子类中定义具体的配置项。
若将配置项设为类属性, 则所有实例都会共享同一份配置项数据。
若将配置项设为实例属性, 则每个实例都会有独立的配置项数据。
子配置项可以是 `MultipleConfig` 的实例。
"""
def __init__(self, if_save_multi_config: bool = True):
self.file: Optional[Path] = None
self.if_save_multi_config = if_save_multi_config
self.is_locked = False
async def connect(self, path: Path):
"""
将配置文件连接到指定配置文件
Parameters
----------
path: Path
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
"""
if path.suffix != ".json":
raise ValueError("配置文件必须是扩展名为 '.json' 的 JSON 文件")
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
self.file = path
if not self.file.exists():
self.file.parent.mkdir(parents=True, exist_ok=True)
self.file.touch()
try:
data = json.loads(self.file.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
await self.load(data)
async def load(self, data: dict):
"""
从字典加载配置数据
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigItem 实例中。
如果字典中包含 "SubConfigsInfo" 键, 则会加载子配置项, 这些子配置项应该是 MultipleConfig 的实例。
Parameters
----------
data: dict
配置数据字典
"""
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
# update the value of config item
if data.get("SubConfigsInfo"):
for k, v in data["SubConfigsInfo"].items():
if hasattr(self, k):
sub_config = getattr(self, k)
if isinstance(sub_config, MultipleConfig):
await sub_config.load(v)
data.pop("SubConfigsInfo")
for group, info in data.items():
for name, value in info.items():
if hasattr(self, f"{group}_{name}"):
configItem = getattr(self, f"{group}_{name}")
if isinstance(configItem, ConfigItem):
configItem.setValue(value)
if self.file:
await self.save()
async def toDict(
self, ignore_multi_config: bool = False, if_decrypt: bool = True
) -> Dict[str, Any]:
"""将配置项转换为字典"""
data = {}
for name in dir(self):
item = getattr(self, name)
if isinstance(item, ConfigItem):
if not data.get(item.group):
data[item.group] = {}
if item.name:
data[item.group][item.name] = item.getValue(if_decrypt)
elif not ignore_multi_config and isinstance(item, MultipleConfig):
if not data.get("SubConfigsInfo"):
data["SubConfigsInfo"] = {}
data["SubConfigsInfo"][name] = await item.toDict()
return data
def get(self, group: str, name: str) -> Any:
"""获取配置项的值"""
if not hasattr(self, f"{group}_{name}"):
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
configItem = getattr(self, f"{group}_{name}")
if isinstance(configItem, ConfigItem):
return configItem.getValue()
else:
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
async def set(self, group: str, name: str, value: Any):
"""
设置配置项的值
Parameters
----------
group: str
配置项分组名称
name: str
配置项名称
value: Any
配置项新值
"""
if not hasattr(self, f"{group}_{name}"):
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
configItem = getattr(self, f"{group}_{name}")
if isinstance(configItem, ConfigItem):
configItem.setValue(value)
if self.file:
await self.save()
else:
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
async def save(self):
"""保存配置"""
if not self.file:
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
self.file.parent.mkdir(parents=True, exist_ok=True)
self.file.write_text(
json.dumps(
await self.toDict(not self.if_save_multi_config, if_decrypt=False),
ensure_ascii=False,
indent=4,
),
encoding="utf-8",
)
async def lock(self):
"""
锁定配置项, 锁定后无法修改配置项值
"""
self.is_locked = True
for name in dir(self):
item = getattr(self, name)
if isinstance(item, ConfigItem):
item.lock()
elif isinstance(item, MultipleConfig):
await item.lock()
async def unlock(self):
"""
解锁配置项, 解锁后可以修改配置项值
"""
self.is_locked = False
for name in dir(self):
item = getattr(self, name)
if isinstance(item, ConfigItem):
item.unlock()
elif isinstance(item, MultipleConfig):
await item.unlock()
class MultipleConfig:
"""
多配置项管理类
这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。
允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。
Parameters
----------
sub_config_type: List[type]
子配置项的类型列表, 必须是 ConfigBase 的子类
"""
def __init__(self, sub_config_type: List[type]):
if not sub_config_type:
raise ValueError("子配置项类型列表不能为空")
for config_type in sub_config_type:
if not issubclass(config_type, ConfigBase):
raise TypeError(
f"配置类型 {config_type.__name__} 必须是 ConfigBase 的子类"
)
self.sub_config_type = sub_config_type
self.file: None | Path = None
self.order: List[uuid.UUID] = []
self.data: Dict[uuid.UUID, ConfigBase] = {}
self.is_locked = False
def __getitem__(self, key: uuid.UUID) -> ConfigBase:
"""允许通过 config[uuid] 访问配置项"""
if key not in self.data:
raise KeyError(f"配置项 '{key}' 不存在")
return self.data[key]
def __contains__(self, key: uuid.UUID) -> bool:
"""允许使用 uuid in config 检查是否存在"""
return key in self.data
def __len__(self) -> int:
"""允许使用 len(config) 获取配置项数量"""
return len(self.data)
def __repr__(self) -> str:
"""更好的字符串表示"""
return f"MultipleConfig(items={len(self.data)}, types={[t.__name__ for t in self.sub_config_type]})"
def __str__(self) -> str:
"""用户友好的字符串表示"""
return f"MultipleConfig with {len(self.data)} items"
async def connect(self, path: Path):
"""
将配置文件连接到指定配置文件
Parameters
----------
path: Path
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
"""
if path.suffix != ".json":
raise ValueError("配置文件必须是带有 '.json' 扩展名的 JSON 文件。")
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
self.file = path
if not self.file.exists():
self.file.parent.mkdir(parents=True, exist_ok=True)
self.file.touch()
try:
data = json.loads(self.file.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
await self.load(data)
async def load(self, data: dict):
"""
从字典加载配置数据
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigBase 实例中。
如果字典中包含 "instances" 键, 则会加载子配置项, 这些子配置项应该是 ConfigBase 子类的实例。
如果字典中没有 "instances" 键, 则清空当前配置项。
Parameters
----------
data: dict
配置数据字典
"""
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
if not data.get("instances"):
self.order = []
self.data = {}
return
self.order = []
self.data = {}
for instance in data["instances"]:
if not isinstance(instance, dict) or not data.get(instance.get("uid")):
continue
type_name = instance.get("type", self.sub_config_type[0].__name__)
for class_type in self.sub_config_type:
if class_type.__name__ == type_name:
self.order.append(uuid.UUID(instance["uid"]))
self.data[self.order[-1]] = class_type()
await self.data[self.order[-1]].load(data[instance["uid"]])
break
else:
raise ValueError(f"未知的子配置类型: {type_name}")
if self.file:
await self.save()
async def toDict(self) -> Dict[str, Union[list, dict]]:
"""
将配置项转换为字典
返回一个字典, 包含所有配置项的 UID 和类型, 以及每个配置项的具体数据。
"""
data: Dict[str, Union[list, dict]] = {
"instances": [
{"uid": str(_), "type": type(self.data[_]).__name__} for _ in self.order
]
}
for uid, config in self.items():
data[str(uid)] = await config.toDict()
return data
async def get(self, uid: uuid.UUID) -> Dict[str, Union[list, dict]]:
"""
获取指定 UID 的配置项
Parameters
----------
uid: uuid.UUID
要获取的配置项的唯一标识符
Returns
-------
Dict[str, Union[list, dict]]
对应的配置项数据字典
"""
if uid not in self.data:
raise ValueError(f"配置项 '{uid}' 不存在。")
data: Dict[str, Union[list, dict]] = {
"instances": [
{"uid": str(_), "type": type(self.data[_]).__name__}
for _ in self.order
if _ == uid
]
}
data[str(uid)] = await self.data[uid].toDict()
return data
async def save(self):
"""保存配置"""
if not self.file:
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
self.file.parent.mkdir(parents=True, exist_ok=True)
self.file.write_text(
json.dumps(await self.toDict(), ensure_ascii=False, indent=4),
encoding="utf-8",
)
async def add(self, config_type: type) -> tuple[uuid.UUID, ConfigBase]:
"""
添加一个新的配置项
Parameters
----------
config_type: type
配置项的类型, 必须是初始化时已声明的 ConfigBase 子类
Returns
-------
tuple[uuid.UUID, ConfigBase]
新创建的配置项的唯一标识符和实例
"""
if config_type not in self.sub_config_type:
raise ValueError(f"配置类型 {config_type.__name__} 不被允许")
uid = uuid.uuid4()
self.order.append(uid)
self.data[uid] = config_type()
if self.file:
await self.save()
return uid, self.data[uid]
async def remove(self, uid: uuid.UUID):
"""
移除配置项
Parameters
----------
uid: uuid.UUID
要移除的配置项的唯一标识符
"""
if self.is_locked:
raise ValueError("配置已锁定, 无法修改")
if uid not in self.data:
raise ValueError(f"配置项 '{uid}' 不存在")
if self.data[uid].is_locked:
raise ValueError(f"配置项 '{uid}' 已锁定, 无法移除")
self.data.pop(uid)
self.order.remove(uid)
if self.file:
await self.save()
async def setOrder(self, order: List[uuid.UUID]):
"""
设置配置项的顺序
Parameters
----------
order: List[uuid.UUID]
新的配置项顺序
"""
if set(order) != set(self.data.keys()):
raise ValueError("顺序与当前配置项不匹配")
self.order = order
if self.file:
await self.save()
async def lock(self):
"""
锁定配置项, 锁定后无法修改配置项值
"""
self.is_locked = True
for item in self.values():
await item.lock()
async def unlock(self):
"""
解锁配置项, 解锁后可以修改配置项值
"""
self.is_locked = False
for item in self.values():
await item.unlock()
def keys(self):
"""返回配置项的所有唯一标识符"""
return iter(self.order)
def values(self):
"""返回配置项的所有实例"""
if not self.data:
return iter([])
return iter([self.data[_] for _ in self.order])
def items(self):
"""返回配置项的所有唯一标识符和实例的元组"""
return zip(self.keys(), self.values())
class MultipleUIDValidator(ConfigValidator):
"""多配置管理类UID验证器"""
def __init__(
self, default: Any, related_config: Dict[str, MultipleConfig], config_name: str
):
self.default = default
self.related_config = related_config
self.config_name = config_name
def validate(self, value: Any) -> bool:
if value == self.default:
return True
if not isinstance(value, str):
return False
try:
uid = uuid.UUID(value)
except (TypeError, ValueError):
return False
if uid in self.related_config.get(self.config_name, {}):
return True
return False
def correct(self, value: Any) -> Any:
if self.validate(value):
return value
return self.default

2092
app/models/MAA.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,35 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA模组包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .ConfigBase import *
from .schema import *
from .general import GeneralManager
from .MAA import MaaManager
__all__ = ["ConfigBase", "schema"]
__all__ = ["GeneralManager", "MaaManager"]

937
app/models/general.py Normal file
View File

@@ -0,0 +1,937 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
通用功能组件
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import QObject, Signal, QEventLoop, QFileSystemWatcher, QTimer
import os
import sys
import shutil
import subprocess
from functools import partial
from datetime import datetime, timedelta
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from typing import Union, List, Dict
from app.core import Config, GeneralConfig, GeneralSubConfig
from app.services import Notify, System
from app.utils import ProcessManager
class GeneralManager(QObject):
"""通用脚本通用控制器"""
question = Signal(str, str)
question_response = Signal(bool)
update_sub_info = Signal(str, dict)
push_info_bar = Signal(str, str, str, int)
play_sound = Signal(str)
create_user_list = Signal(list)
update_user_list = Signal(list)
update_log_text = Signal(str)
interrupt = Signal()
accomplish = Signal(dict)
def __init__(
self,
mode: str,
config: Dict[
str,
Union[
str,
Path,
GeneralConfig,
Dict[str, Dict[str, Union[Path, GeneralSubConfig]]],
],
],
sub_config_path: Path = None,
):
super(GeneralManager, self).__init__()
self.sub_list = []
self.mode = mode
self.config_path = config["Path"]
self.sub_config_path = sub_config_path
self.game_process_manager = ProcessManager()
self.script_process_manager = ProcessManager()
self.log_monitor = QFileSystemWatcher()
self.log_monitor_timer = QTimer()
self.log_monitor_timer.timeout.connect(self.refresh_log)
self.monitor_loop = QEventLoop()
self.script_process_manager.processClosed.connect(
lambda: self.log_monitor.fileChanged.emit("进程结束检查")
)
self.question_loop = QEventLoop()
self.question_response.connect(self.__capture_response)
self.question_response.connect(self.question_loop.quit)
self.wait_loop = QEventLoop()
self.isInterruptionRequested = False
self.interrupt.connect(self.quit_monitor)
self.task_dict = {}
self.set = config["Config"].toDict()
self.data: Dict[str, Dict[str, Union[Path, dict]]] = {}
if self.mode != "设置通用脚本":
for name, info in config["SubData"].items():
self.data[name] = {
"Path": info["Path"],
"Config": info["Config"].toDict(),
}
self.data = dict(sorted(self.data.items(), key=lambda x: int(x[0][3:])))
def check_config_info(self) -> bool:
"""检查配置完整性"""
if not (
Path(self.set["Script"]["RootPath"]).exists()
and Path(self.set["Script"]["ScriptPath"]).exists()
and Path(self.set["Script"]["ConfigPath"]).exists()
and Path(self.set["Script"]["LogPath"]).parent.exists()
and self.set["Script"]["LogTimeFormat"]
and self.set["Script"]["ErrorLog"]
) or (
self.set["Game"]["Enabled"] and not Path(self.set["Game"]["Path"]).exists()
):
logger.error("脚本配置缺失")
self.push_info_bar.emit("error", "脚本配置缺失", "请检查脚本配置!", -1)
return False
return True
def configure(self):
"""提取配置信息"""
self.name = self.set["Script"]["Name"]
self.script_root_path = Path(self.set["Script"]["RootPath"])
self.script_exe_path = Path(self.set["Script"]["ScriptPath"])
self.script_config_path = Path(self.set["Script"]["ConfigPath"])
self.script_log_path = (
Path(self.set["Script"]["LogPath"]).with_stem(
datetime.now().strftime(self.set["Script"]["LogPathFormat"])
)
if self.set["Script"]["LogPathFormat"]
else Path(self.set["Script"]["LogPath"])
)
if not self.script_log_path.exists():
self.script_log_path.parent.mkdir(parents=True, exist_ok=True)
self.script_log_path.touch(exist_ok=True)
self.game_path = Path(self.set["Game"]["Path"])
self.log_time_range = [
self.set["Script"]["LogTimeStart"] - 1,
self.set["Script"]["LogTimeEnd"],
]
self.success_log = (
[_.strip() for _ in self.set["Script"]["SuccessLog"].split("|")]
if self.set["Script"]["SuccessLog"]
else []
)
self.error_log = [_.strip() for _ in self.set["Script"]["ErrorLog"].split("|")]
def run(self):
"""主进程,运行通用脚本代理进程"""
current_date = datetime.now().strftime("%m-%d")
curdate = Config.server_date().strftime("%Y-%m-%d")
begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 检查配置完整性
if not self.check_config_info():
self.accomplish.emit(
{"Time": begin_time, "History": "由于配置不完整,通用代理进程中止"}
)
return None
self.configure()
# 整理用户数据,筛选需代理的用户
if self.mode != "设置通用脚本":
self.data = dict(sorted(self.data.items(), key=lambda x: int(x[0][3:])))
self.sub_list: List[List[str, str, str]] = [
[_["Config"]["Info"]["Name"], "等待", index]
for index, _ in self.data.items()
if (
_["Config"]["Info"]["RemainedDay"] != 0
and _["Config"]["Info"]["Status"]
)
]
self.create_user_list.emit(self.sub_list)
# 自动代理模式
if self.mode == "自动代理":
# 执行情况预处理
for _ in self.sub_list:
if self.data[_[2]]["Config"]["Data"]["LastProxyDate"] != curdate:
self.data[_[2]]["Config"]["Data"]["LastProxyDate"] = curdate
self.data[_[2]]["Config"]["Data"]["ProxyTimes"] = 0
_[
0
] += f" - 第{self.data[_[2]]['Config']['Data']['ProxyTimes'] + 1}次代理"
# 开始代理
for sub in self.sub_list:
sub_data = self.data[sub[2]]["Config"]
if self.isInterruptionRequested:
break
if (
self.set["Run"]["ProxyTimesLimit"] == 0
or sub_data["Data"]["ProxyTimes"]
< self.set["Run"]["ProxyTimesLimit"]
):
sub[1] = "运行"
self.update_user_list.emit(self.sub_list)
else:
sub[1] = "跳过"
self.update_user_list.emit(self.sub_list)
continue
logger.info(f"{self.name} | 开始代理配置: {sub[0]}")
sub_start_time = datetime.now()
run_book = False
if not (self.data[sub[2]]["Path"] / "ConfigFiles").exists():
logger.error(f"{self.name} | 配置: {sub[0]} - 未找到配置文件")
self.push_info_bar.emit(
"error",
"启动通用代理进程失败",
f"未找到{sub[0]}的配置文件!",
-1,
)
run_book = False
continue
# 尝试次数循环
for i in range(self.set["Run"]["RunTimesLimit"]):
if self.isInterruptionRequested or run_book:
break
logger.info(
f"{self.name} | 用户: {sub[0]} - 尝试次数: {i + 1}/{self.set['Run']['RunTimesLimit']}"
)
# 记录当前时间
start_time = datetime.now()
# 配置脚本
self.set_sub(sub[2])
# 执行任务前脚本
if (
sub_data["Info"]["IfScriptBeforeTask"]
and Path(sub_data["Info"]["ScriptBeforeTask"]).exists()
):
self.execute_script_task(
Path(sub_data["Info"]["ScriptBeforeTask"]), "脚本前任务"
)
# 启动游戏/模拟器
if self.set["Game"]["Enabled"]:
try:
logger.info(
f"{self.name} | 启动游戏/模拟器:{self.game_path},参数:{self.set['Game']['Arguments']}"
)
self.game_process_manager.open_process(
self.game_path,
str(self.set["Game"]["Arguments"]).split(" "),
0,
)
except Exception as e:
logger.error(
f"{self.name} | 启动游戏/模拟器时出现异常:{e}"
)
self.push_info_bar.emit(
"error",
"启动游戏/模拟器时出现异常",
"请检查游戏/模拟器路径设置",
-1,
)
self.script_result = "游戏/模拟器启动失败"
break
# 添加静默进程标记
if self.set["Game"]["Style"] == "Emulator":
Config.silence_list.append(self.game_path)
self.update_log_text.emit(
f"正在等待游戏/模拟器完成启动\n请等待{self.set['Game']['WaitTime']}s"
)
self.sleep(self.set["Game"]["WaitTime"])
# 10s后移除静默进程标记
if self.set["Game"]["Style"] == "Emulator":
QTimer.singleShot(
10000,
partial(Config.silence_list.remove, self.game_path),
)
# 运行脚本任务
logger.info(
f"{self.name} | 运行脚本任务:{self.script_exe_path},参数:{self.set['Script']['Arguments']}"
)
self.script_process_manager.open_process(
self.script_exe_path,
str(self.set["Script"]["Arguments"]).split(" "),
tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0,
)
# 监测运行状态
self.start_monitor(start_time)
if self.script_result == "Success!":
# 标记任务完成
run_book = True
# 中止相关程序
self.script_process_manager.kill()
System.kill_process(self.script_exe_path)
if self.set["Game"]["Enabled"]:
self.game_process_manager.kill()
if self.set["Game"]["IfForceClose"]:
System.kill_process(self.game_path)
logger.info(
f"{self.name} | 配置: {sub[0]} - 通用脚本进程完成代理任务"
)
self.update_log_text.emit(
"检测到通用脚本进程完成代理任务\n正在等待相关程序结束\n请等待10s"
)
self.sleep(10)
else:
logger.error(
f"{self.name} | 配置: {sub[0]} - 代理任务异常: {self.script_result}"
)
# 打印中止信息
# 此时log变量内存储的就是出现异常的日志信息可以保存或发送用于问题排查
self.update_log_text.emit(
f"{self.script_result}\n正在中止相关程序\n请等待10s"
)
# 中止相关程序
self.script_process_manager.kill()
if self.set["Game"]["Enabled"]:
self.game_process_manager.kill()
if self.set["Game"]["IfForceClose"]:
System.kill_process(self.game_path)
# 推送异常通知
Notify.push_plyer(
"用户自动代理出现异常!",
f"用户 {sub[0].replace("_", " 今天的")}出现一次异常",
f"{sub[0].replace("_", " ")}出现异常",
1,
)
if i == self.set["Run"]["RunTimesLimit"] - 1:
self.play_sound.emit("子任务失败")
else:
self.play_sound.emit(self.script_result)
self.sleep(10)
# 执行任务后脚本
if (
sub_data["Info"]["IfScriptAfterTask"]
and Path(sub_data["Info"]["ScriptAfterTask"]).exists()
):
self.execute_script_task(
Path(sub_data["Info"]["ScriptAfterTask"]), "脚本后任务"
)
# 保存运行日志以及统计信息
Config.save_general_log(
Config.app_path
/ f"history/{curdate}/{sub_data['Info']['Name']}/{start_time.strftime("%H-%M-%S")}.log",
self.check_script_log(start_time),
self.script_result,
)
# 发送统计信息
statistics = {
"sub_index": sub[2],
"sub_info": sub[0],
"start_time": sub_start_time.strftime("%Y-%m-%d %H:%M:%S"),
"end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"sub_result": "代理成功" if run_book else self.script_result,
}
self.push_notification(
"统计信息",
f"{current_date} | 配置 {sub[0]} 的自动代理统计报告",
statistics,
sub_data,
)
if run_book:
# 成功完成代理的用户修改相关参数
if (
sub_data["Data"]["ProxyTimes"] == 0
and sub_data["Info"]["RemainedDay"] != -1
):
sub_data["Info"]["RemainedDay"] -= 1
sub_data["Data"]["ProxyTimes"] += 1
sub[1] = "完成"
Notify.push_plyer(
"成功完成一个自动代理任务!",
f"已完成配置 {sub[0].replace("_", " 今天的")}任务",
f"已完成 {sub[0].replace("_", "")}",
3,
)
else:
# 录入代理失败的用户
sub[1] = "异常"
self.update_user_list.emit(self.sub_list)
# 设置通用脚本模式
elif self.mode == "设置通用脚本":
# 配置通用脚本
self.set_sub()
try:
# 创建通用脚本任务
logger.info(f"{self.name} | 无参数启动通用脚本:{self.script_exe_path}")
self.script_process_manager.open_process(
self.script_exe_path,
tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0,
)
# 记录当前时间
start_time = datetime.now()
# 监测通用脚本运行状态
self.start_monitor(start_time)
self.sub_config_path.mkdir(parents=True, exist_ok=True)
if self.set["Script"]["ConfigPathMode"] == "文件夹":
shutil.copytree(
self.script_config_path,
self.sub_config_path,
dirs_exist_ok=True,
)
else:
shutil.copy(self.script_config_path, self.sub_config_path)
except Exception as e:
logger.error(f"{self.name} | 启动通用脚本时出现异常:{e}")
self.push_info_bar.emit(
"error",
"启动通用脚本时出现异常",
"请检查相关设置",
-1,
)
result_text = ""
# 导出结果
if self.mode in ["自动代理"]:
# 关闭可能未正常退出的通用脚本进程
if self.isInterruptionRequested:
self.script_process_manager.kill(if_force=True)
System.kill_process(self.script_exe_path)
if self.set["Game"]["Enabled"]:
self.game_process_manager.kill(if_force=True)
if self.set["Game"]["IfForceClose"]:
System.kill_process(self.game_path)
# 更新用户数据
updated_info = {_[2]: self.data[_[2]] for _ in self.sub_list}
self.update_sub_info.emit(self.config_path.name, updated_info)
error_index = [_[2] for _ in self.sub_list if _[1] == "异常"]
over_index = [_[2] for _ in self.sub_list if _[1] == "完成"]
wait_index = [_[2] for _ in self.sub_list if _[1] == "等待"]
# 保存运行日志
title = (
f"{current_date} | {self.name}{self.mode[:4]}任务报告"
if self.name != ""
else f"{current_date} | {self.mode[:4]}任务报告"
)
result = {
"title": f"{self.mode[:4]}任务报告",
"script_name": (self.name if self.name != "" else "空白"),
"start_time": begin_time,
"end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"completed_count": len(over_index),
"uncompleted_count": len(error_index) + len(wait_index),
"failed_sub": [
self.data[_]["Config"]["Info"]["Name"] for _ in error_index
],
"waiting_sub": [
self.data[_]["Config"]["Info"]["Name"] for _ in wait_index
],
}
# 生成结果文本
result_text = (
f"任务开始时间:{result['start_time']},结束时间:{result['end_time']}\n"
f"已完成数:{result['completed_count']},未完成数:{result['uncompleted_count']}\n\n"
)
if len(result["failed_sub"]) > 0:
result_text += f"{self.mode[2:4]}未成功的配置:\n{"\n".join(result['failed_sub'])}\n"
if len(result["waiting_sub"]) > 0:
result_text += f"\n未开始{self.mode[2:4]}的配置:\n{"\n".join(result['waiting_sub'])}\n"
# 推送代理结果通知
Notify.push_plyer(
title.replace("报告", "已完成!"),
f"已完成配置数:{len(over_index)},未完成配置数:{len(error_index) + len(wait_index)}",
f"已完成配置数:{len(over_index)},未完成配置数:{len(error_index) + len(wait_index)}",
10,
)
self.push_notification("代理结果", title, result)
self.log_monitor.deleteLater()
self.log_monitor_timer.deleteLater()
self.accomplish.emit({"Time": begin_time, "History": result_text})
def requestInterruption(self) -> None:
logger.info(f"{self.name} | 收到任务中止申请")
if len(self.log_monitor.files()) != 0:
self.interrupt.emit()
self.script_result = "任务被手动中止"
self.isInterruptionRequested = True
self.wait_loop.quit()
def push_question(self, title: str, message: str) -> bool:
self.question.emit(title, message)
self.question_loop.exec()
return self.response
def __capture_response(self, response: bool) -> None:
self.response = response
def sleep(self, time: int) -> None:
"""非阻塞型等待"""
QTimer.singleShot(time * 1000, self.wait_loop.quit)
self.wait_loop.exec()
def refresh_log(self) -> None:
"""刷新脚本日志"""
with self.script_log_path.open(mode="r", encoding="utf-8") as f:
pass
# 一分钟内未执行日志变化检查,强制检查一次
if (datetime.now() - self.last_check_time).total_seconds() > 60:
self.log_monitor.fileChanged.emit("1分钟超时检查")
def strptime(
self, date_string: str, format: str, default_date: datetime
) -> datetime:
"""根据指定格式解析日期字符串"""
# 时间字段映射表
time_fields = {
"%Y": "year",
"%m": "month",
"%d": "day",
"%H": "hour",
"%M": "minute",
"%S": "second",
"%f": "microsecond",
}
date = datetime.strptime(date_string, format)
# 构建参数字典
datetime_kwargs = {}
for format_code, field_name in time_fields.items():
if format_code in format:
datetime_kwargs[field_name] = getattr(date, field_name)
else:
datetime_kwargs[field_name] = getattr(default_date, field_name)
return datetime(**datetime_kwargs)
def check_script_log(self, start_time: datetime) -> list:
"""获取脚本日志并检查以判断脚本程序运行状态"""
self.last_check_time = datetime.now()
# 获取日志
logs = []
if_log_start = False
with self.script_log_path.open(mode="r", encoding="utf-8") as f:
for entry in f:
if not if_log_start:
try:
entry_time = self.strptime(
entry[self.log_time_range[0] : self.log_time_range[1]],
self.set["Script"]["LogTimeFormat"],
self.last_check_time,
)
if entry_time > start_time:
if_log_start = True
logs.append(entry)
except ValueError:
pass
else:
logs.append(entry)
log = "".join(logs)
# 更新日志
if len(logs) > 100:
self.update_log_text.emit("".join(logs[-100:]))
else:
self.update_log_text.emit("".join(logs))
if "自动代理" in self.mode:
# 获取最近一条日志的时间
latest_time = start_time
for _ in logs[::-1]:
try:
latest_time = self.strptime(
_[self.log_time_range[0] : self.log_time_range[1]],
self.set["Script"]["LogTimeFormat"],
self.last_check_time,
)
break
except ValueError:
pass
for success_sign in self.success_log:
if success_sign in log:
self.script_result = "Success!"
break
else:
if self.isInterruptionRequested:
self.script_result = "任务被手动中止"
elif datetime.now() - latest_time > timedelta(
minutes=self.set["Run"]["RunTimeLimit"]
):
self.script_result = "脚本进程超时"
else:
for error_sign in self.error_log:
if error_sign in log:
self.script_result = f"异常日志:{error_sign}"
break
else:
if self.script_process_manager.is_running():
self.script_result = "Wait"
elif self.success_log:
self.script_result = "脚本在完成任务前退出"
else:
self.script_result = "Success!"
elif self.mode == "设置通用脚本":
if self.script_process_manager.is_running():
self.script_result = "Wait"
else:
self.script_result = "Success!"
if self.script_result != "Wait":
self.quit_monitor()
return logs
def start_monitor(self, start_time: datetime) -> None:
"""开始监视通用脚本日志"""
logger.info(f"{self.name} | 开始监视通用脚本日志")
self.log_monitor.addPath(str(self.script_log_path))
self.log_monitor.fileChanged.connect(lambda: self.check_script_log(start_time))
self.log_monitor_timer.start(1000)
self.last_check_time = datetime.now()
self.monitor_loop.exec()
def quit_monitor(self) -> None:
"""退出通用脚本日志监视进程"""
if len(self.log_monitor.files()) != 0:
logger.info(f"{self.name} | 退出通用脚本日志监视")
self.log_monitor.removePath(str(self.script_log_path))
self.log_monitor.fileChanged.disconnect()
self.log_monitor_timer.stop()
self.last_check_time = None
self.monitor_loop.quit()
def set_sub(self, index: str = "") -> dict:
"""配置通用脚本运行参数"""
logger.info(f"{self.name} | 配置脚本运行参数: {index}")
# 配置前关闭可能未正常退出的脚本进程
System.kill_process(self.script_exe_path)
# 预导入配置文件
if self.mode == "设置通用脚本":
if self.sub_config_path.exists():
if self.set["Script"]["ConfigPathMode"] == "文件夹":
shutil.copytree(
self.sub_config_path,
self.script_config_path,
dirs_exist_ok=True,
)
elif (self.sub_config_path / self.script_config_path.name).exists():
shutil.copy(
self.sub_config_path / self.script_config_path.name,
self.script_config_path,
)
else:
if self.set["Script"]["ConfigPathMode"] == "文件夹":
shutil.copytree(
self.data[index]["Path"] / "ConfigFiles",
self.script_config_path,
dirs_exist_ok=True,
)
else:
shutil.copy(
self.data[index]["Path"]
/ "ConfigFiles"
/ self.script_config_path.name,
self.script_config_path,
)
def execute_script_task(self, script_path: Path, task_name: str) -> bool:
"""执行脚本任务并等待结束"""
try:
logger.info(f"{self.name} | 开始执行{task_name}: {script_path}")
# 根据文件类型选择执行方式
if script_path.suffix.lower() == ".py":
cmd = [sys.executable, script_path]
elif script_path.suffix.lower() in [".bat", ".cmd", ".exe"]:
cmd = [str(script_path)]
elif script_path.suffix.lower() == "":
logger.warning(f"{self.name} | {task_name}脚本没有指定后缀名,无法执行")
return False
else:
# 使用系统默认程序打开
os.startfile(str(script_path))
return True
# 执行脚本并等待结束
result = subprocess.run(
cmd,
cwd=script_path.parent,
stdin=subprocess.DEVNULL,
creationflags=(
subprocess.CREATE_NO_WINDOW
if Config.get(Config.function_IfSilence)
else 0
),
timeout=600,
capture_output=True,
errors="ignore",
)
if result.returncode == 0:
logger.info(f"{self.name} | {task_name}执行成功")
if result.stdout.strip():
logger.info(f"{self.name} | {task_name}输出: {result.stdout}")
return True
else:
logger.error(
f"{self.name} | {task_name}执行失败,返回码: {result.returncode}"
)
if result.stderr.strip():
logger.error(f"{self.name} | {task_name}错误输出: {result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.error(f"{self.name} | {task_name}执行超时")
return False
except Exception as e:
logger.exception(f"{self.name} | 执行{task_name}时出现异常: {e}")
return False
def push_notification(
self,
mode: str,
title: str,
message: Union[str, dict],
sub_data: Dict[str, Dict[str, Union[str, int, bool]]] = None,
) -> None:
"""通过所有渠道推送通知"""
env = Environment(
loader=FileSystemLoader(str(Config.app_path / "resources/html"))
)
if mode == "代理结果" and (
Config.get(Config.notify_SendTaskResultTime) == "任何时刻"
or (
Config.get(Config.notify_SendTaskResultTime) == "仅失败时"
and message["uncompleted_count"] != 0
)
):
# 生成文本通知内容
message_text = (
f"任务开始时间:{message['start_time']},结束时间:{message['end_time']}\n"
f"已完成数:{message['completed_count']},未完成数:{message['uncompleted_count']}\n\n"
)
if len(message["failed_sub"]) > 0:
message_text += f"{self.mode[2:4]}未成功的配置:\n{"\n".join(message['failed_sub'])}\n"
if len(message["waiting_sub"]) > 0:
message_text += f"\n未开始{self.mode[2:4]}的配置:\n{"\n".join(message['waiting_sub'])}\n"
# 生成HTML通知内容
message["failed_sub"] = "".join(message["failed_sub"])
message["waiting_sub"] = "".join(message["waiting_sub"])
template = env.get_template("general_result.html")
message_html = template.render(message)
# ServerChan的换行是两个换行符。故而将\n替换为\n\n
serverchan_message = message_text.replace("\n", "\n\n")
# 发送全局通知
if Config.get(Config.notify_IfSendMail):
Notify.send_mail(
"网页", title, message_html, Config.get(Config.notify_ToAddress)
)
if Config.get(Config.notify_IfServerChan):
Notify.ServerChanPush(
title,
f"{serverchan_message}\n\nAUTO_MAA 敬上",
Config.get(Config.notify_ServerChanKey),
Config.get(Config.notify_ServerChanTag),
Config.get(Config.notify_ServerChanChannel),
)
if Config.get(Config.notify_IfCompanyWebHookBot):
Notify.CompanyWebHookBotPush(
title,
f"{message_text}\n\nAUTO_MAA 敬上",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
elif mode == "统计信息":
message_text = (
f"开始时间: {message['start_time']}\n"
f"结束时间: {message['end_time']}\n"
f"通用脚本执行结果: {message['sub_result']}\n\n"
)
# 生成HTML通知内容
template = env.get_template("general_statistics.html")
message_html = template.render(message)
# ServerChan的换行是两个换行符。故而将\n替换为\n\n
serverchan_message = message_text.replace("\n", "\n\n")
# 发送全局通知
if Config.get(Config.notify_IfSendStatistic):
if Config.get(Config.notify_IfSendMail):
Notify.send_mail(
"网页", title, message_html, Config.get(Config.notify_ToAddress)
)
if Config.get(Config.notify_IfServerChan):
Notify.ServerChanPush(
title,
f"{serverchan_message}\n\nAUTO_MAA 敬上",
Config.get(Config.notify_ServerChanKey),
Config.get(Config.notify_ServerChanTag),
Config.get(Config.notify_ServerChanChannel),
)
if Config.get(Config.notify_IfCompanyWebHookBot):
Notify.CompanyWebHookBotPush(
title,
f"{message_text}\n\nAUTO_MAA 敬上",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
# 发送用户单独通知
if sub_data["Notify"]["Enabled"] and sub_data["Notify"]["IfSendStatistic"]:
# 发送邮件通知
if sub_data["Notify"]["IfSendMail"]:
if sub_data["Notify"]["ToAddress"]:
Notify.send_mail(
"网页",
title,
message_html,
sub_data["Notify"]["ToAddress"],
)
else:
logger.error(
f"{self.name} | 用户邮箱地址为空,无法发送用户单独的邮件通知"
)
# 发送ServerChan通知
if sub_data["Notify"]["IfServerChan"]:
if sub_data["Notify"]["ServerChanKey"]:
Notify.ServerChanPush(
title,
f"{serverchan_message}\n\nAUTO_MAA 敬上",
sub_data["Notify"]["ServerChanKey"],
sub_data["Notify"]["ServerChanTag"],
sub_data["Notify"]["ServerChanChannel"],
)
else:
logger.error(
f"{self.name} |用户ServerChan密钥为空无法发送用户单独的ServerChan通知"
)
# 推送CompanyWebHookBot通知
if sub_data["Notify"]["IfCompanyWebHookBot"]:
if sub_data["Notify"]["CompanyWebHookBotUrl"]:
Notify.CompanyWebHookBotPush(
title,
f"{message_text}\n\nAUTO_MAA 敬上",
sub_data["Notify"]["CompanyWebHookBotUrl"],
)
else:
logger.error(
f"{self.name} |用户CompanyWebHookBot密钥为空无法发送用户单独的CompanyWebHookBot通知"
)
return None

View File

@@ -1,822 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Union, Optional, Literal
class OutBase(BaseModel):
code: int = Field(default=200, description="状态码")
status: str = Field(default="success", description="操作状态")
message: str = Field(default="操作成功", description="操作消息")
class InfoOut(OutBase):
data: Dict[str, Any] = Field(..., description="收到的服务器数据")
class VersionOut(OutBase):
if_need_update: bool = Field(..., description="后端代码是否需要更新")
current_hash: str = Field(..., description="后端代码当前哈希值")
current_time: str = Field(..., description="后端代码当前时间戳")
current_version: str = Field(..., description="后端当前版本号")
class NoticeOut(OutBase):
if_need_show: bool = Field(..., description="是否需要显示公告")
data: Dict[str, str] = Field(
..., description="公告信息, key为公告标题, value为公告内容"
)
class ComboBoxItem(BaseModel):
label: str = Field(..., description="展示值")
value: Optional[str] = Field(..., description="实际值")
class ComboBoxOut(OutBase):
data: List[ComboBoxItem] = Field(..., description="下拉框选项")
class GetStageIn(BaseModel):
type: Literal[
"Today",
"ALL",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] = Field(
...,
description="选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项",
)
class GlobalConfig_Function(BaseModel):
HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
None, description="历史记录保留时间, 0表示永久保存"
)
IfAllowSleep: Optional[bool] = Field(default=None, description="允许休眠")
IfSilence: Optional[bool] = Field(default=None, description="静默模式")
BossKey: Optional[str] = Field(default=None, description="模拟器老板键")
IfAgreeBilibili: Optional[bool] = Field(
default=None, description="同意哔哩哔哩用户协议"
)
IfSkipMumuSplashAds: Optional[bool] = Field(
default=None, description="跳过Mumu模拟器启动广告"
)
class GlobalConfig_Voice(BaseModel):
Enabled: Optional[bool] = Field(default=None, description="语音功能是否启用")
Type: Optional[Literal["simple", "noisy"]] = Field(
default=None, description="语音类型, simple为简洁, noisy为聒噪"
)
class GlobalConfig_Start(BaseModel):
IfSelfStart: Optional[bool] = Field(
default=None, description="是否在系统启动时自动运行"
)
IfMinimizeDirectly: Optional[bool] = Field(
default=None, description="启动时是否直接最小化到托盘而不显示主窗口"
)
class GlobalConfig_UI(BaseModel):
IfShowTray: Optional[bool] = Field(default=None, description="是否常态显示托盘图标")
IfToTray: Optional[bool] = Field(default=None, description="是否最小化到托盘")
class CustomWebhook(BaseModel):
id: str = Field(..., description="Webhook唯一标识")
name: str = Field(..., description="Webhook名称")
url: str = Field(..., description="Webhook URL")
template: str = Field(..., description="消息模板")
enabled: bool = Field(default=True, description="是否启用")
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
method: Optional[Literal["POST", "GET"]] = Field(
default="POST", description="请求方法"
)
class GlobalConfig_Notify(BaseModel):
SendTaskResultTime: Optional[Literal["不推送", "任何时刻", "仅失败时"]] = Field(
default=None, description="任务结果推送时机"
)
IfSendStatistic: Optional[bool] = Field(
default=None, description="是否发送统计信息"
)
IfSendSixStar: Optional[bool] = Field(
default=None, description="是否发送公招六星通知"
)
IfPushPlyer: Optional[bool] = Field(default=None, description="是否推送系统通知")
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
SMTPServerAddress: Optional[str] = Field(default=None, description="SMTP服务器地址")
AuthorizationCode: Optional[str] = Field(default=None, description="SMTP授权码")
FromAddress: Optional[str] = Field(default=None, description="邮件发送地址")
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
IfServerChan: Optional[bool] = Field(
default=None, description="是否使用ServerChan推送"
)
ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥")
CustomWebhooks: Optional[List[CustomWebhook]] = Field(
default=None, description="自定义Webhook列表"
)
class GlobalConfig_Update(BaseModel):
IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新")
Source: Optional[Literal["GitHub", "MirrorChyan", "AutoSite"]] = Field(
default=None, description="更新源: GitHub源, Mirror酱源, 自建源"
)
ProxyAddress: Optional[str] = Field(default=None, description="网络代理地址")
MirrorChyanCDK: Optional[str] = Field(default=None, description="Mirror酱CDK")
class GlobalConfig(BaseModel):
Function: Optional[GlobalConfig_Function] = Field(
default=None, description="功能相关配置"
)
Voice: Optional[GlobalConfig_Voice] = Field(
default=None, description="语音相关配置"
)
Start: Optional[GlobalConfig_Start] = Field(
default=None, description="启动相关配置"
)
UI: Optional[GlobalConfig_UI] = Field(default=None, description="界面相关配置")
Notify: Optional[GlobalConfig_Notify] = Field(
default=None, description="通知相关配置"
)
Update: Optional[GlobalConfig_Update] = Field(
default=None, description="更新相关配置"
)
class QueueIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["QueueConfig"] = Field(..., description="配置类型")
class QueueItemIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["QueueItem"] = Field(..., description="配置类型")
class TimeSetIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["TimeSet"] = Field(..., description="配置类型")
class QueueItem_Info(BaseModel):
ScriptId: Optional[str] = Field(
default=None, description="任务所对应的脚本ID, 为None时表示未选择"
)
class QueueItem(BaseModel):
Info: Optional[QueueItem_Info] = Field(default=None, description="队列项")
class TimeSet_Info(BaseModel):
Enabled: Optional[bool] = Field(default=None, description="是否启用")
Time: Optional[str] = Field(default=None, description="时间设置, 格式为HH:MM")
class TimeSet(BaseModel):
Info: Optional[TimeSet_Info] = Field(default=None, description="时间项")
class QueueConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="队列名称")
TimeEnabled: Optional[bool] = Field(default=None, description="是否启用定时")
StartUpEnabled: Optional[bool] = Field(default=None, description="是否启动时运行")
AfterAccomplish: Optional[
Literal[
"NoAction", "KillSelf", "Sleep", "Hibernate", "Shutdown", "ShutdownForce"
]
] = Field(default=None, description="完成后操作")
class QueueConfig(BaseModel):
Info: Optional[QueueConfig_Info] = Field(default=None, description="队列信息")
class ScriptIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["MaaConfig", "GeneralConfig"] = Field(..., description="配置类型")
class UserIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["MaaUserConfig", "GeneralUserConfig"] = Field(
..., description="配置类型"
)
class MaaUserConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="用户名")
Id: Optional[str] = Field(default=None, description="用户ID")
Mode: Optional[Literal["简洁", "详细"]] = Field(
default=None, description="用户配置模式"
)
StageMode: Optional[str] = Field(default=None, description="关卡配置模式")
Server: Optional[
Literal["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
] = Field(default=None, description="服务器")
Status: Optional[bool] = Field(default=None, description="用户状态")
RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
Annihilation: Optional[
Literal[
"Close",
"Annihilation",
"Chernobog@Annihilation",
"LungmenOutskirts@Annihilation",
"LungmenDowntown@Annihilation",
]
] = Field(default=None, description="剿灭模式")
Routine: Optional[bool] = Field(default=None, description="是否启用日常")
InfrastMode: Optional[Literal["Normal", "Rotation", "Custom"]] = Field(
default=None, description="基建模式"
)
InfrastPath: Optional[str] = Field(default=None, description="自定义基建文件路径")
Password: Optional[str] = Field(default=None, description="密码")
Notes: Optional[str] = Field(default=None, description="备注")
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药数量")
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
default=None, description="连战次数"
)
Stage: Optional[str] = Field(default=None, description="关卡选择")
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
IfSkland: Optional[bool] = Field(default=None, description="是否启用森空岛签到")
SklandToken: Optional[str] = Field(default=None, description="SklandToken")
class MaaUserConfig_Data(BaseModel):
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
LastAnnihilationDate: Optional[str] = Field(
default=None, description="上次剿灭日期"
)
LastSklandDate: Optional[str] = Field(
default=None, description="上次森空岛签到日期"
)
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
IfPassCheck: Optional[bool] = Field(default=None, description="是否通过人工排查")
class MaaUserConfig_Task(BaseModel):
IfWakeUp: Optional[bool] = Field(default=None, description="开始唤醒")
IfRecruiting: Optional[bool] = Field(default=None, description="自动公招")
IfBase: Optional[bool] = Field(default=None, description="基建换班")
IfCombat: Optional[bool] = Field(default=None, description="刷理智")
IfMall: Optional[bool] = Field(default=None, description="获取信用及购物")
IfMission: Optional[bool] = Field(default=None, description="领取奖励")
IfAutoRoguelike: Optional[bool] = Field(default=None, description="自动肉鸽")
IfReclamation: Optional[bool] = Field(default=None, description="生息演算")
class MaaUserConfig_Notify(BaseModel):
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
IfSendStatistic: Optional[bool] = Field(
default=None, description="是否发送统计信息"
)
IfSendSixStar: Optional[bool] = Field(default=None, description="是否发送高资喜报")
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
IfServerChan: Optional[bool] = Field(
default=None, description="是否使用Server酱推送"
)
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
CustomWebhooks: Optional[List[CustomWebhook]] = Field(
default=None, description="用户自定义Webhook列表"
)
class GeneralUserConfig_Notify(BaseModel):
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
IfSendStatistic: Optional[bool] = Field(
default=None, description="是否发送统计信息"
)
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
IfServerChan: Optional[bool] = Field(
default=None, description="是否使用Server酱推送"
)
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
IfCompanyWebHookBot: Optional[bool] = Field(
default=None, description="是否使用Webhook推送"
)
CompanyWebHookBotUrl: Optional[str] = Field(
default=None, description="企微Webhook Bot URL"
)
class MaaUserConfig(BaseModel):
Info: Optional[MaaUserConfig_Info] = Field(default=None, description="基础信息")
Data: Optional[MaaUserConfig_Data] = Field(default=None, description="用户数据")
Task: Optional[MaaUserConfig_Task] = Field(default=None, description="任务列表")
Notify: Optional[MaaUserConfig_Notify] = Field(default=None, description="单独通知")
class MaaConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="脚本名称")
Path: Optional[str] = Field(default=None, description="脚本路径")
class MaaConfig_Run(BaseModel):
TaskTransitionMethod: Optional[Literal["NoAction", "ExitGame", "ExitEmulator"]] = (
Field(default=None, description="简洁任务间切换方式")
)
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
ADBSearchRange: Optional[int] = Field(default=None, description="ADB端口搜索范围")
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
AnnihilationTimeLimit: Optional[int] = Field(
default=None, description="剿灭超时限制"
)
RoutineTimeLimit: Optional[int] = Field(default=None, description="日常超时限制")
AnnihilationWeeklyLimit: Optional[bool] = Field(
default=None, description="剿灭每周仅代理至上限"
)
class MaaConfig(BaseModel):
Info: Optional[MaaConfig_Info] = Field(default=None, description="脚本基础信息")
Run: Optional[MaaConfig_Run] = Field(default=None, description="脚本运行配置")
class GeneralUserConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="用户名")
Status: Optional[bool] = 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="备注")
class GeneralUserConfig_Data(BaseModel):
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
class GeneralUserConfig(BaseModel):
Info: Optional[GeneralUserConfig_Info] = Field(default=None, description="用户信息")
Data: Optional[GeneralUserConfig_Data] = Field(default=None, description="用户数据")
Notify: Optional[GeneralUserConfig_Notify] = Field(
default=None, description="单独通知"
)
class GeneralConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="脚本名称")
RootPath: Optional[str] = Field(default=None, description="脚本根目录")
class GeneralConfig_Script(BaseModel):
ScriptPath: Optional[str] = Field(default=None, description="脚本可执行文件路径")
Arguments: Optional[str] = Field(default=None, description="脚本启动附加命令参数")
IfTrackProcess: Optional[bool] = Field(
default=None, description="是否追踪脚本子进程"
)
ConfigPath: Optional[str] = Field(default=None, description="配置文件路径")
ConfigPathMode: Optional[Literal["File", "Folder"]] = Field(
default=None, description="配置文件类型: 单个文件, 文件夹"
)
UpdateConfigMode: Optional[Literal["Never", "Success", "Failure", "Always"]] = (
Field(
default=None,
description="更新配置时机, 从不, 仅成功时, 仅失败时, 任务结束时",
)
)
LogPath: Optional[str] = Field(default=None, description="日志文件路径")
LogPathFormat: Optional[str] = Field(default=None, description="日志文件名格式")
LogTimeStart: Optional[int] = Field(default=None, description="日志时间戳开始位置")
LogTimeEnd: Optional[int] = Field(default=None, description="日志时间戳结束位置")
LogTimeFormat: Optional[str] = Field(default=None, description="日志时间戳格式")
SuccessLog: Optional[str] = Field(default=None, description="成功时日志")
ErrorLog: Optional[str] = Field(default=None, description="错误时日志")
class GeneralConfig_Game(BaseModel):
Enabled: Optional[bool] = Field(
default=None, description="游戏/模拟器相关功能是否启用"
)
Type: Optional[Literal["Emulator", "Client"]] = Field(
default=None, description="类型: 模拟器, PC端"
)
Path: Optional[str] = Field(default=None, description="游戏/模拟器程序路径")
Arguments: Optional[str] = Field(default=None, description="游戏/模拟器启动参数")
WaitTime: Optional[int] = Field(default=None, description="游戏/模拟器等待启动时间")
IfForceClose: Optional[bool] = Field(
default=None, description="是否强制关闭游戏/模拟器进程"
)
class GeneralConfig_Run(BaseModel):
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
RunTimeLimit: Optional[int] = Field(default=None, description="日志超时限制")
class GeneralConfig(BaseModel):
Info: Optional[GeneralConfig_Info] = Field(default=None, description="脚本基础信息")
Script: Optional[GeneralConfig_Script] = Field(default=None, description="脚本配置")
Game: Optional[GeneralConfig_Game] = Field(default=None, description="游戏配置")
Run: Optional[GeneralConfig_Run] = Field(default=None, description="运行配置")
class PlanIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["MaaPlanConfig"] = Field(..., description="配置类型")
class MaaPlanConfig_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="计划表名称")
Mode: Optional[Literal["ALL", "Weekly"]] = Field(
default=None, description="计划表模式"
)
class MaaPlanConfig_Item(BaseModel):
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药")
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
None, description="连战次数"
)
Stage: Optional[str] = Field(default=None, description="关卡选择")
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
class MaaPlanConfig(BaseModel):
Info: Optional[MaaPlanConfig_Info] = Field(default=None, description="基础信息")
ALL: Optional[MaaPlanConfig_Item] = Field(default=None, description="全局")
Monday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周一")
Tuesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周二")
Wednesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周三")
Thursday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周四")
Friday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周五")
Saturday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周六")
Sunday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周日")
class HistoryIndexItem(BaseModel):
date: str = Field(..., description="日期")
status: Literal["完成", "异常"] = Field(..., description="状态")
jsonFile: str = Field(..., description="对应JSON文件")
class HistoryData(BaseModel):
index: Optional[List[HistoryIndexItem]] = Field(
default=None, description="历史记录索引列表"
)
recruit_statistics: Optional[Dict[str, int]] = Field(
default=None, description="公招统计数据, key为星级, value为对应的公招数量"
)
drop_statistics: Optional[Dict[str, Dict[str, int]]] = Field(
default=None,
description="掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }",
)
error_info: Optional[Dict[str, str]] = Field(
default=None, description="报错信息, key为时间戳, value为错误描述"
)
log_content: Optional[str] = Field(
default=None, description="日志内容, 仅在提取单条历史记录数据时返回"
)
class ScriptCreateIn(BaseModel):
type: Literal["MAA", "General"] = Field(
..., description="脚本类型: MAA脚本, 通用脚本"
)
class ScriptCreateOut(OutBase):
scriptId: str = Field(..., description="新创建的脚本ID")
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本配置数据")
class ScriptGetIn(BaseModel):
scriptId: Optional[str] = Field(
default=None, description="脚本ID, 未携带时表示获取所有脚本数据"
)
class ScriptGetOut(OutBase):
index: List[ScriptIndexItem] = Field(..., description="脚本索引列表")
data: Dict[str, Union[MaaConfig, GeneralConfig]] = Field(
..., description="脚本数据字典, key来自于index列表的uid"
)
class ScriptUpdateIn(BaseModel):
scriptId: str = Field(..., description="脚本ID")
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本更新数据")
class ScriptDeleteIn(BaseModel):
scriptId: str = Field(..., description="脚本ID")
class ScriptReorderIn(BaseModel):
indexList: List[str] = Field(..., description="脚本ID列表, 按新顺序排列")
class ScriptFileIn(BaseModel):
scriptId: str = Field(..., description="脚本ID")
jsonFile: str = Field(..., description="配置文件路径")
class ScriptUrlIn(BaseModel):
scriptId: str = Field(..., description="脚本ID")
url: str = Field(..., description="配置文件URL")
class ScriptUploadIn(BaseModel):
scriptId: str = Field(..., description="脚本ID")
config_name: str = Field(..., description="配置名称")
author: str = Field(..., description="作者")
description: str = Field(..., description="描述")
class UserInBase(BaseModel):
scriptId: str = Field(..., description="所属脚本ID")
class UserGetIn(UserInBase):
userId: Optional[str] = Field(
default=None, description="用户ID, 未携带时表示获取所有用户数据"
)
class UserGetOut(OutBase):
index: List[UserIndexItem] = Field(..., description="用户索引列表")
data: Dict[str, Union[MaaUserConfig, GeneralUserConfig]] = Field(
..., description="用户数据字典, key来自于index列表的uid"
)
class UserCreateOut(OutBase):
userId: str = Field(..., description="新创建的用户ID")
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
..., description="用户配置数据"
)
class UserUpdateIn(UserInBase):
userId: str = Field(..., description="用户ID")
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
..., description="用户更新数据"
)
class UserDeleteIn(UserInBase):
userId: str = Field(..., description="用户ID")
class UserReorderIn(UserInBase):
indexList: List[str] = Field(..., description="用户ID列表, 按新顺序排列")
class UserSetIn(UserInBase):
userId: str = Field(..., description="用户ID")
jsonFile: str = Field(..., description="JSON文件路径, 用于导入自定义基建文件")
class PlanCreateIn(BaseModel):
type: Literal["MaaPlan"]
class PlanCreateOut(OutBase):
planId: str = Field(..., description="新创建的计划ID")
data: MaaPlanConfig = Field(..., description="计划配置数据")
class PlanGetIn(BaseModel):
planId: Optional[str] = Field(
default=None, description="计划ID, 未携带时表示获取所有计划数据"
)
class PlanGetOut(OutBase):
index: List[PlanIndexItem] = Field(..., description="计划索引列表")
data: Dict[str, MaaPlanConfig] = Field(..., description="计划列表或单个计划数据")
class PlanUpdateIn(BaseModel):
planId: str = Field(..., description="计划ID")
data: MaaPlanConfig = Field(..., description="计划更新数据")
class PlanDeleteIn(BaseModel):
planId: str = Field(..., description="计划ID")
class PlanReorderIn(BaseModel):
indexList: List[str] = Field(..., description="计划ID列表, 按新顺序排列")
class QueueCreateOut(OutBase):
queueId: str = Field(..., description="新创建的队列ID")
data: QueueConfig = Field(..., description="队列配置数据")
class QueueGetIn(BaseModel):
queueId: Optional[str] = Field(
default=None, description="队列ID, 未携带时表示获取所有队列数据"
)
class QueueGetOut(OutBase):
index: List[QueueIndexItem] = Field(..., description="队列索引列表")
data: Dict[str, QueueConfig] = Field(
..., description="队列数据字典, key来自于index列表的uid"
)
class QueueUpdateIn(BaseModel):
queueId: str = Field(..., description="队列ID")
data: QueueConfig = Field(..., description="队列更新数据")
class QueueDeleteIn(BaseModel):
queueId: str = Field(..., description="队列ID")
class QueueReorderIn(BaseModel):
indexList: List[str] = Field(..., description="按新顺序排列的调度队列UID列表")
class QueueSetInBase(BaseModel):
queueId: str = Field(..., description="所属队列ID")
class TimeSetGetIn(QueueSetInBase):
timeSetId: Optional[str] = Field(
default=None, description="时间设置ID, 未携带时表示获取所有时间设置数据"
)
class TimeSetGetOut(OutBase):
index: List[TimeSetIndexItem] = Field(..., description="时间设置索引列表")
data: Dict[str, TimeSet] = Field(
..., description="时间设置数据字典, key来自于index列表的uid"
)
class TimeSetCreateOut(OutBase):
timeSetId: str = Field(..., description="新创建的时间设置ID")
data: TimeSet = Field(..., description="时间设置配置数据")
class TimeSetUpdateIn(QueueSetInBase):
timeSetId: str = Field(..., description="时间设置ID")
data: TimeSet = Field(..., description="时间设置更新数据")
class TimeSetDeleteIn(QueueSetInBase):
timeSetId: str = Field(..., description="时间设置ID")
class TimeSetReorderIn(QueueSetInBase):
indexList: List[str] = Field(..., description="时间设置ID列表, 按新顺序排列")
class QueueItemGetIn(QueueSetInBase):
queueItemId: Optional[str] = Field(
default=None, description="队列项ID, 未携带时表示获取所有队列项数据"
)
class QueueItemGetOut(OutBase):
index: List[QueueItemIndexItem] = Field(..., description="队列项索引列表")
data: Dict[str, QueueItem] = Field(
..., description="队列项数据字典, key来自于index列表的uid"
)
class QueueItemCreateOut(OutBase):
queueItemId: str = Field(..., description="新创建的队列项ID")
data: QueueItem = Field(..., description="队列项配置数据")
class QueueItemUpdateIn(QueueSetInBase):
queueItemId: str = Field(..., description="队列项ID")
data: QueueItem = Field(..., description="队列项更新数据")
class QueueItemDeleteIn(QueueSetInBase):
queueItemId: str = Field(..., description="队列项ID")
class QueueItemReorderIn(QueueSetInBase):
indexList: List[str] = Field(..., description="队列项ID列表, 按新顺序排列")
class DispatchIn(BaseModel):
taskId: str = Field(
...,
description="目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID",
)
class TaskCreateIn(DispatchIn):
mode: Literal["自动代理", "人工排查", "设置脚本"] = Field(
..., description="任务模式"
)
class TaskCreateOut(OutBase):
websocketId: str = Field(..., description="新创建的任务ID")
class WebSocketMessage(BaseModel):
id: str = Field(..., description="消息ID, 为Main时表示消息来自主进程")
type: Literal["Update", "Message", "Info", "Signal"] = Field(
...,
description="消息类型 Update: 更新数据, Message: 请求弹出对话框, Info: 需要在UI显示的消息, Signal: 程序信号",
)
data: Dict[str, Any] = Field(..., description="消息数据, 具体内容根据type类型而定")
class PowerIn(BaseModel):
signal: Literal[
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
] = Field(..., description="电源操作信号")
class HistorySearchIn(BaseModel):
mode: Literal["按日合并", "按周合并", "按月合并"] = Field(
..., description="合并模式"
)
start_date: str = Field(..., description="开始日期, 格式YYYY-MM-DD")
end_date: str = Field(..., description="结束日期, 格式YYYY-MM-DD")
class HistorySearchOut(OutBase):
data: Dict[str, Dict[str, HistoryData]] = Field(
...,
description="历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }",
)
class HistoryDataGetIn(BaseModel):
jsonPath: str = Field(..., description="需要提取数据的历史记录JSON文件")
class HistoryDataGetOut(OutBase):
data: HistoryData = Field(..., description="历史记录数据")
class SettingGetOut(OutBase):
data: GlobalConfig = Field(..., description="全局设置数据")
class SettingUpdateIn(BaseModel):
data: GlobalConfig = Field(..., description="全局设置需要更新的数据")
class UpdateCheckIn(BaseModel):
current_version: str = Field(..., description="当前前端版本号")
if_force: bool = Field(default=False, description="是否强制拉取更新信息")
class UpdateCheckOut(OutBase):
if_need_update: bool = Field(..., description="是否需要更新前端")
latest_version: str = Field(..., description="最新前端版本号")
update_info: Dict[str, List[str]] = Field(..., description="版本更新信息字典")

View File

@@ -1,31 +1,37 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA服务包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .matomo import Matomo
from .notification import Notify
from .security import Crypto
from .system import System
from .update import Updater
from .skland import skland_sign_in
__all__ = ["Matomo", "Notify", "System", "Updater"]
__all__ = ["Notify", "Crypto", "System", "skland_sign_in"]

View File

@@ -1,125 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import asyncio
import aiohttp
import json
import uuid
import platform
import time
from typing import Dict, Any, Optional
from app.core import Config
from app.utils.logger import get_logger
logger = get_logger("信息上报")
class _MatomoHandler:
"""Matomo统计上报服务"""
base_url = "https://statistics.auto-mas.top/matomo.php"
site_id = "3"
def __init__(self):
self.session = None
async def _get_session(self):
"""获取HTTP会话"""
if self.session is None or self.session.closed:
timeout = aiohttp.ClientTimeout(total=10)
self.session = aiohttp.ClientSession(timeout=timeout)
return self.session
async def close(self):
"""关闭HTTP会话"""
if self.session and not self.session.closed:
await self.session.close()
def _build_base_params(self, custom_vars: Optional[Dict[str, Any]] = None):
"""构建基础参数"""
params = {
"idsite": self.site_id,
"rec": "1",
"action_name": "AUTO-MAS后端",
"_id": Config.get("Data", "UID")[:16],
"uid": Config.get("Data", "UID"),
"rand": str(uuid.uuid4().int)[:10],
"apiv": "1",
"h": time.strftime("%H"),
"m": time.strftime("%M"),
"s": time.strftime("%S"),
"ua": f"AUTO-MAS/{Config.version()} ({platform.system()} {platform.release()})",
}
# 添加自定义变量
if custom_vars is not None:
cvar = {}
for i, (key, value) in enumerate(custom_vars.items(), 1):
if i <= 5:
cvar[str(i)] = [str(key), str(value)]
if cvar:
params["_cvar"] = json.dumps(cvar)
return params
async def send_event(
self,
category: str,
action: str,
name: Optional[str] = None,
value: Optional[float] = None,
custom_vars: Optional[Dict[str, Any]] = None,
):
"""发送事件数据到Matomo
Args:
category: 事件类别,如 "Script", "Config", "User"
action: 事件动作,如 "Execute", "Update", "Login"
name: 事件名称,如具体的脚本名称
value: 事件值,如执行时长、文件大小等数值
custom_vars: 自定义变量字典
"""
try:
session = await self._get_session()
if session is None:
return
params = self._build_base_params(custom_vars)
params.update({"e_c": category, "e_a": action, "e_n": name, "e_v": value})
params = {k: v for k, v in params.items() if v is not None}
async with session.get(self.base_url, params=params) as response:
if response.status == 200:
logger.debug(f"Matomo事件上报成功: {category}/{action}")
else:
logger.warning(f"Matomo事件上报失败: {response.status}")
except asyncio.TimeoutError:
logger.warning("Matomo事件上报超时")
except Exception as e:
logger.error(f"Matomo事件上报错误: {e}")
Matomo = _MatomoHandler()

View File

@@ -1,94 +1,82 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA通知服务
v4.4
作者DLmaster_361
"""
import re
import smtplib
import requests
import time
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
import requests
from PySide6.QtCore import QObject, Signal
from loguru import logger
from plyer import notification
from app.core import Config
from app.utils import get_logger, ImageUtils
logger = get_logger("通知服务")
from app.services.security import Crypto
from app.utils.ImageUtils import ImageUtils
class Notification:
class Notification(QObject):
def __init__(self):
super().__init__()
push_info_bar = Signal(str, str, str, int)
async def push_plyer(self, title, message, ticker, t) -> bool:
"""
推送系统通知
def __init__(self, parent=None):
super().__init__(parent)
:param title: 通知标题
:param message: 通知内容
:param ticker: 通知横幅
:param t: 通知持续时间
:return: bool
"""
def push_plyer(self, title, message, ticker, t):
"""推送系统通知"""
if Config.get("Notify", "IfPushPlyer"):
if Config.get(Config.notify_IfPushPlyer):
logger.info(f"推送系统通知: {title}")
if notification.notify is not None:
notification.notify(
title=title,
message=message,
app_name="AUTO-MAS",
app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(),
timeout=t,
ticker=ticker,
toast=True,
)
else:
logger.error("plyer.notification 未正确导入, 无法推送系统通知")
notification.notify(
title=title,
message=message,
app_name="AUTO_MAA",
app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"),
timeout=t,
ticker=ticker,
toast=True,
)
return True
async def send_mail(self, mode, title, content, to_address) -> None:
"""
推送邮件通知
:param mode: 邮件内容模式, 支持 "文本""网页"
:param title: 邮件标题
:param content: 邮件内容
:param to_address: 收件人地址
"""
def send_mail(self, mode, title, content, to_address) -> None:
"""推送邮件通知"""
if (
Config.get("Notify", "SMTPServerAddress") == ""
or Config.get("Notify", "AuthorizationCode") == ""
Config.get(Config.notify_SMTPServerAddress) == ""
or Config.get(Config.notify_AuthorizationCode) == ""
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get("Notify", "FromAddress"),
Config.get(Config.notify_FromAddress),
)
)
or not bool(
@@ -101,330 +89,343 @@ class Notification:
logger.error(
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址"
)
raise ValueError(
"邮件通知的SMTP服务器地址、授权码、发件人地址或收件人地址未正确配置"
self.push_info_bar.emit(
"error",
"邮件通知推送异常",
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
-1,
)
return None
# 定义邮件正文
if mode == "文本":
message = MIMEText(content, "plain", "utf-8")
elif mode == "网页":
message = MIMEMultipart("alternative")
message["From"] = formataddr(
(
Header("AUTO-MAS通知服务", "utf-8").encode(),
Config.get("Notify", "FromAddress"),
try:
# 定义邮件正文
if mode == "文本":
message = MIMEText(content, "plain", "utf-8")
elif mode == "网页":
message = MIMEMultipart("alternative")
message["From"] = formataddr(
(
Header("AUTO_MAA通知服务", "utf-8").encode(),
Config.get(Config.notify_FromAddress),
)
) # 发件人显示的名字
message["To"] = formataddr(
(
Header("AUTO_MAA用户", "utf-8").encode(),
to_address,
)
) # 收件人显示的名字
message["Subject"] = Header(title, "utf-8")
if mode == "网页":
message.attach(MIMEText(content, "html", "utf-8"))
smtpObj = smtplib.SMTP_SSL(
Config.get(Config.notify_SMTPServerAddress),
465,
)
) # 发件人显示的名字
message["To"] = formataddr(
(Header("AUTO-MAS用户", "utf-8").encode(), to_address)
) # 收件人显示的名字
message["Subject"] = str(Header(title, "utf-8"))
if mode == "网页":
message.attach(MIMEText(content, "html", "utf-8"))
smtpObj = smtplib.SMTP_SSL(Config.get("Notify", "SMTPServerAddress"), 465)
smtpObj.login(
Config.get("Notify", "FromAddress"),
Config.get("Notify", "AuthorizationCode"),
)
smtpObj.sendmail(
Config.get("Notify", "FromAddress"), to_address, message.as_string()
)
smtpObj.quit()
logger.success(f"邮件发送成功: {title}")
async def ServerChanPush(self, title, content, send_key) -> None:
"""
使用Server酱推送通知
:param title: 通知标题
:param content: 通知内容
:param send_key: Server酱的SendKey
"""
smtpObj.login(
Config.get(Config.notify_FromAddress),
Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)),
)
smtpObj.sendmail(
Config.get(Config.notify_FromAddress),
to_address,
message.as_string(),
)
smtpObj.quit()
logger.success("邮件发送成功")
return None
except Exception as e:
logger.error(f"发送邮件时出错:\n{e}")
self.push_info_bar.emit("error", "发送邮件时出错", f"{e}", -1)
return None
return None
def ServerChanPush(self, title, content, send_key, tag, channel):
"""使用Server酱推送通知"""
if not send_key:
raise ValueError("ServerChan SendKey 不能为空")
# 构造 URL
if send_key.startswith("sctp"):
match = re.match(r"^sctp(\d+)t", send_key)
if match:
url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send"
else:
raise ValueError("SendKey 格式不正确 (sctp<int>)")
else:
url = f"https://sctapi.ftqq.com/{send_key}.send"
# 请求发送
params = {"title": title, "desp": content}
headers = {"Content-Type": "application/json;charset=utf-8"}
response = requests.post(
url, json=params, headers=headers, timeout=10, proxies=Config.get_proxies()
)
result = response.json()
if result.get("code") == 0:
logger.success(f"Server酱推送通知成功: {title}")
else:
raise Exception(f"ServerChan 推送通知失败: {response.text}")
async def CustomWebhookPush(self, title, content, webhook_config) -> None:
"""
自定义 Webhook 推送通知
:param title: 通知标题
:param content: 通知内容
:param webhook_config: Webhook配置对象
"""
if not webhook_config.get("url"):
raise ValueError("Webhook URL 不能为空")
if not webhook_config.get("enabled", True):
logger.info(
f"Webhook {webhook_config.get('name', 'Unknown')} 已禁用,跳过推送"
logger.error("请正确设置Server酱的SendKey")
self.push_info_bar.emit(
"error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1
)
return
# 解析模板
template = webhook_config.get(
"template", '{"title": "{title}", "content": "{content}"}'
)
# 替换模板变量
try:
import json
from datetime import datetime
# 准备模板变量
template_vars = {
"title": title,
"content": content,
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"date": datetime.now().strftime("%Y-%m-%d"),
"time": datetime.now().strftime("%H:%M:%S"),
}
logger.debug(f"原始模板: {template}")
logger.debug(f"模板变量: {template_vars}")
# 先尝试作为JSON模板处理
try:
# 解析模板为JSON对象然后替换其中的变量
template_obj = json.loads(template)
# 递归替换JSON对象中的变量
def replace_variables(obj):
if isinstance(obj, dict):
return {k: replace_variables(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [replace_variables(item) for item in obj]
elif isinstance(obj, str):
result = obj
for key, value in template_vars.items():
result = result.replace(f"{{{key}}}", str(value))
return result
else:
return obj
data = replace_variables(template_obj)
logger.debug(f"成功解析JSON模板: {data}")
except json.JSONDecodeError:
# 如果不是有效的JSON作为字符串模板处理
logger.debug("模板不是有效JSON作为字符串模板处理")
formatted_template = template
for key, value in template_vars.items():
# 转义特殊字符以避免JSON解析错误
safe_value = (
str(value)
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
formatted_template = formatted_template.replace(
f"{{{key}}}", safe_value
)
# 再次尝试解析为JSON
try:
data = json.loads(formatted_template)
logger.debug(f"字符串模板解析为JSON成功: {data}")
except json.JSONDecodeError:
# 最终作为纯文本发送
data = formatted_template
logger.debug(f"作为纯文本发送: {data}")
except Exception as e:
logger.warning(f"模板解析失败,使用默认格式: {e}")
data = {"title": title, "content": content}
# 准备请求头
headers = {"Content-Type": "application/json"}
if webhook_config.get("headers"):
headers.update(webhook_config["headers"])
# 发送请求
method = webhook_config.get("method", "POST").upper()
return None
try:
if method == "POST":
if isinstance(data, dict):
response = requests.post(
url=webhook_config["url"],
json=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
# 构造 URL
if send_key.startswith("sctp"):
match = re.match(r"^sctp(\d+)t", send_key)
if match:
url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send"
else:
response = requests.post(
url=webhook_config["url"],
data=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
else: # GET
params = data if isinstance(data, dict) else {"message": data}
response = requests.get(
url=webhook_config["url"],
params=params,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
raise ValueError("SendKey 格式错误sctp")
else:
url = f"https://sctapi.ftqq.com/{send_key}.send"
# 构建 tags 和 channel
def is_valid(s):
return s == "" or (
s == "|".join(s.split("|"))
and (s.count("|") == 0 or all(s.split("|")))
)
# 检查响应
if response.status_code == 200:
logger.success(
f"自定义Webhook推送成功: {webhook_config.get('name', 'Unknown')} - {title}"
)
tags = "|".join(_.strip() for _ in tag.split("|"))
channels = "|".join(_.strip() for _ in channel.split("|"))
options = {}
if is_valid(tags):
options["tags"] = tags
else:
raise Exception(f"HTTP {response.status_code}: {response.text}")
logger.warning("Server酱 Tag 配置不正确,将被忽略")
self.push_info_bar.emit(
"warning",
"Server酱通知推送异常",
"请正确设置 ServerChan 的 Tag",
-1,
)
if is_valid(channels):
options["channel"] = channels
else:
logger.warning("Server酱 Channel 配置不正确,将被忽略")
self.push_info_bar.emit(
"warning",
"Server酱通知推送异常",
"请正确设置 ServerChan 的 Channel",
-1,
)
# 请求发送
params = {"title": title, "desp": content, **options}
headers = {"Content-Type": "application/json;charset=utf-8"}
response = requests.post(
url,
json=params,
headers=headers,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
result = response.json()
if result.get("code") == 0:
logger.info("Server酱推送通知成功")
return True
else:
error_code = result.get("code", "-1")
logger.error(f"Server酱通知推送失败响应码{error_code}")
self.push_info_bar.emit(
"error", "Server酱通知推送失败", f"响应码:{error_code}", -1
)
return f"Server酱通知推送失败{error_code}"
except Exception as e:
raise Exception(
f"自定义Webhook推送失败 ({webhook_config.get('name', 'Unknown')}): {str(e)}"
logger.exception("Server酱通知推送异常")
self.push_info_bar.emit(
"error",
"Server酱通知推送异常",
"请检查相关设置和网络连接。如全部配置正确,请稍后再试。",
-1,
)
return f"Server酱通知推送异常{str(e)}"
async def WebHookPush(self, title, content, webhook_url) -> None:
"""
WebHook 推送通知 (兼容旧版企业微信格式)
:param title: 通知标题
:param content: 通知内容
:param webhook_url: WebHook地址
"""
if not webhook_url:
raise ValueError("WebHook 地址不能为空")
def CompanyWebHookBotPush(self, title, content, webhook_url):
"""使用企业微信群机器人推送通知"""
if webhook_url == "":
logger.error("请正确设置企业微信群机器人的WebHook地址")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"请正确设置企业微信群机器人的WebHook地址",
-1,
)
return None
content = f"{title}\n{content}"
data = {"msgtype": "text", "text": {"content": content}}
response = requests.post(
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
)
info = response.json()
for _ in range(3):
try:
response = requests.post(
url=webhook_url,
json=data,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
info = response.json()
break
except Exception as e:
err = e
time.sleep(0.1)
else:
logger.error(f"推送企业微信群机器人时出错:{err}")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送失败",
f"使用企业微信群机器人推送通知时出错:{err}",
-1,
)
return None
if info["errcode"] == 0:
logger.success(f"WebHook 推送通知成功: {title}")
logger.info("企业微信群机器人推送通知成功")
return True
else:
raise Exception(f"WebHook 推送通知失败: {response.text}")
logger.error(f"企业微信群机器人推送通知失败{info}")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送失败",
f"使用企业微信群机器人推送通知时出错:{err}",
-1,
)
return f"使用企业微信群机器人推送通知时出错:{err}"
async def CompanyWebHookBotPushImage(
self, image_path: Path, webhook_url: str
) -> None:
"""
使用企业微信群机器人推送图片通知
def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> bool:
"""使用企业微信群机器人推送图片通知"""
try:
# 压缩图片
ImageUtils.compress_image_if_needed(image_path)
:param image_path: 图片文件路径
:param webhook_url: 企业微信群机器人的WebHook地址
"""
# 检查图片是否存在
if not image_path.exists():
logger.error(
"图片推送异常 | 图片不存在或者压缩失败,请检查图片路径是否正确"
)
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"图片不存在或者压缩失败,请检查图片路径是否正确",
-1,
)
return False
if not webhook_url:
raise ValueError("webhook URL 不能为空")
if not webhook_url:
logger.error("请正确设置企业微信群机器人的WebHook地址")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"请正确设置企业微信群机器人的WebHook地址",
-1,
)
return False
# 压缩图片
ImageUtils.compress_image_if_needed(image_path)
# 获取图片base64和md5
try:
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
except Exception as e:
logger.exception(f"图片编码或MD5计算失败{e}")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
f"图片编码或MD5计算失败{e}",
-1,
)
return False
# 检查图片是否存在
if not image_path.exists():
raise FileNotFoundError(f"文件未找到: {image_path}")
data = {
"msgtype": "image",
"image": {"base64": image_base64, "md5": image_md5},
}
# 获取图片base64和md5
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
for _ in range(3):
try:
response = requests.post(
url=webhook_url,
json=data,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
info = response.json()
break
except requests.RequestException as e:
err = e
logger.warning(f"推送企业微信群机器人图片第{_+1}次失败:{e}")
time.sleep(0.1)
else:
logger.error(f"推送企业微信群机器人图片时出错:{err}")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"使用企业微信群机器人推送图片时出错:{err}",
-1,
)
return False
data = {
"msgtype": "image",
"image": {"base64": image_base64, "md5": image_md5},
}
if info.get("errcode") == 0:
logger.info("企业微信群机器人推送图片成功")
return True
else:
logger.error(f"企业微信群机器人推送图片失败:{info}")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"使用企业微信群机器人推送图片时出错:{info}",
-1,
)
return False
response = requests.post(
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
)
info = response.json()
except Exception as e:
logger.error(f"推送企业微信群机器人图片时发生未知异常:{e}")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"发生未知异常:{e}",
-1,
)
return False
if info.get("errcode") == 0:
logger.success(f"企业微信群机器人推送图片成功: {image_path.name}")
else:
raise Exception(f"企业微信群机器人推送图片失败: {response.text}")
async def send_test_notification(self) -> None:
def send_test_notification(self):
"""发送测试通知到所有已启用的通知渠道"""
logger.info("发送测试通知到所有已启用的通知渠道")
# 发送系统通知
await self.push_plyer(
self.push_plyer(
"测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
"测试通知",
3,
)
# 发送邮件通知
if Config.get("Notify", "IfSendMail"):
await self.send_mail(
if Config.get(Config.notify_IfSendMail):
self.send_mail(
"文本",
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
Config.get("Notify", "ToAddress"),
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_ToAddress),
)
# 发送Server酱通知
if Config.get("Notify", "IfServerChan"):
await self.ServerChanPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
Config.get("Notify", "ServerChanKey"),
if Config.get(Config.notify_IfServerChan):
self.ServerChanPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_ServerChanKey),
Config.get(Config.notify_ServerChanTag),
Config.get(Config.notify_ServerChanChannel),
)
# 发送自定义Webhook通知
try:
custom_webhooks = Config.get("Notify", "CustomWebhooks")
except AttributeError:
custom_webhooks = []
if custom_webhooks:
for webhook in custom_webhooks:
if webhook.get("enabled", True):
try:
await self.CustomWebhookPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
webhook,
)
except Exception as e:
logger.error(
f"自定义Webhook测试失败 ({webhook.get('name', 'Unknown')}): {e}"
)
# 发送企业微信机器人通知
if Config.get(Config.notify_IfCompanyWebHookBot):
self.CompanyWebHookBotPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
Notify.CompanyWebHookBotPushImage(
Config.app_path / "resources/images/notification/test_notify.png",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
logger.success("测试通知发送完成")
return True
Notify = Notification()

226
app/services/security.py Normal file
View File

@@ -0,0 +1,226 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA安全服务
v4.4
作者DLmaster_361
"""
from loguru import logger
import hashlib
import random
import secrets
import base64
import win32crypt
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Util.Padding import pad, unpad
from typing import List, Dict, Union
from app.core import Config
class CryptoHandler:
def get_PASSWORD(self, PASSWORD: str) -> None:
"""配置管理密钥"""
# 生成目录
Config.key_path.mkdir(parents=True, exist_ok=True)
# 生成RSA密钥对
key = RSA.generate(2048)
public_key_local = key.publickey()
private_key = key
# 保存RSA公钥
(Config.app_path / "data/key/public_key.pem").write_bytes(
public_key_local.exportKey()
)
# 生成密钥转换与校验随机盐
PASSWORD_salt = secrets.token_hex(random.randint(32, 1024))
(Config.app_path / "data/key/PASSWORDsalt.txt").write_text(
PASSWORD_salt,
encoding="utf-8",
)
verify_salt = secrets.token_hex(random.randint(32, 1024))
(Config.app_path / "data/key/verifysalt.txt").write_text(
verify_salt,
encoding="utf-8",
)
# 将管理密钥转化为AES-256密钥
AES_password = hashlib.sha256(
(PASSWORD + PASSWORD_salt).encode("utf-8")
).digest()
# 生成AES-256密钥校验哈希值并保存
AES_password_verify = hashlib.sha256(
AES_password + verify_salt.encode("utf-8")
).digest()
(Config.app_path / "data/key/AES_password_verify.bin").write_bytes(
AES_password_verify
)
# AES-256加密RSA私钥并保存密文
AES_key = AES.new(AES_password, AES.MODE_ECB)
private_key_local = AES_key.encrypt(pad(private_key.exportKey(), 32))
(Config.app_path / "data/key/private_key.bin").write_bytes(private_key_local)
def AUTO_encryptor(self, note: str) -> str:
"""使用AUTO_MAA的算法加密数据"""
if note == "":
return ""
# 读取RSA公钥
public_key_local = RSA.import_key(
(Config.app_path / "data/key/public_key.pem").read_bytes()
)
# 使用RSA公钥对数据进行加密
cipher = PKCS1_OAEP.new(public_key_local)
encrypted = cipher.encrypt(note.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
def AUTO_decryptor(self, note: str, PASSWORD: str) -> str:
"""使用AUTO_MAA的算法解密数据"""
if note == "":
return ""
# 读入RSA私钥密文、盐与校验哈希值
private_key_local = (
(Config.app_path / "data/key/private_key.bin").read_bytes().strip()
)
PASSWORD_salt = (
(Config.app_path / "data/key/PASSWORDsalt.txt")
.read_text(encoding="utf-8")
.strip()
)
verify_salt = (
(Config.app_path / "data/key/verifysalt.txt")
.read_text(encoding="utf-8")
.strip()
)
AES_password_verify = (
(Config.app_path / "data/key/AES_password_verify.bin").read_bytes().strip()
)
# 将管理密钥转化为AES-256密钥并验证
AES_password = hashlib.sha256(
(PASSWORD + PASSWORD_salt).encode("utf-8")
).digest()
AES_password_SHA = hashlib.sha256(
AES_password + verify_salt.encode("utf-8")
).digest()
if AES_password_SHA != AES_password_verify:
return "管理密钥错误"
else:
# AES解密RSA私钥
AES_key = AES.new(AES_password, AES.MODE_ECB)
private_key_pem = unpad(AES_key.decrypt(private_key_local), 32)
private_key = RSA.import_key(private_key_pem)
# 使用RSA私钥解密数据
decrypter = PKCS1_OAEP.new(private_key)
note = decrypter.decrypt(base64.b64decode(note)).decode("utf-8")
return note
def change_PASSWORD(self, PASSWORD_old: str, PASSWORD_new: str) -> None:
"""修改管理密钥"""
for member in Config.member_dict.values():
# 使用旧管理密钥解密
if member["Type"] == "Maa":
for user in member["UserData"].values():
user["Password"] = self.AUTO_decryptor(
user["Config"].get(user["Config"].Info_Password), PASSWORD_old
)
self.get_PASSWORD(PASSWORD_new)
for member in Config.member_dict.values():
# 使用新管理密钥重新加密
if member["Type"] == "Maa":
for user in member["UserData"].values():
user["Config"].set(
user["Config"].Info_Password,
self.AUTO_encryptor(user["Password"]),
)
user["Password"] = None
del user["Password"]
def reset_PASSWORD(self, PASSWORD_new: str) -> None:
"""重置管理密钥"""
self.get_PASSWORD(PASSWORD_new)
for member in Config.member_dict.values():
if member["Type"] == "Maa":
for user in member["UserData"].values():
user["Config"].set(user["Config"].Info_Password, "")
def win_encryptor(
self, note: str, description: str = None, entropy: bytes = None
) -> str:
"""使用Windows DPAPI加密数据"""
if note == "":
return ""
encrypted = win32crypt.CryptProtectData(
note.encode("utf-8"), description, entropy, None, None, 0
)
return base64.b64encode(encrypted).decode("utf-8")
def win_decryptor(self, note: str, entropy: bytes = None) -> str:
"""使用Windows DPAPI解密数据"""
if note == "":
return ""
decrypted = win32crypt.CryptUnprotectData(
base64.b64decode(note), entropy, None, None, 0
)
return decrypted[1].decode("utf-8")
def search_member(self) -> List[Dict[str, Union[Path, list]]]:
"""搜索所有脚本实例及其用户数据库路径"""
member_list = []
if (Config.app_path / "config/MaaConfig").exists():
for subdir in (Config.app_path / "config/MaaConfig").iterdir():
if subdir.is_dir():
member_list.append({"Path": subdir / "user_data.db"})
return member_list
def check_PASSWORD(self, PASSWORD: str) -> bool:
"""验证管理密钥"""
return bool(
self.AUTO_decryptor(self.AUTO_encryptor("-"), PASSWORD) != "管理密钥错误"
)
Crypto = CryptoHandler()

View File

@@ -1,47 +1,49 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 ClozyA
# This file incorporates work covered by the following copyright and
# permission notice:
#
# skland-checkin-ghaction Copyright © 2023 Yanstory
# https://github.com/Yanstory/skland-checkin-ghaction
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA森空岛服务
v4.4
作者DLmaster_361ClozyA
"""
from loguru import logger
import time
import json
import hmac
import asyncio
import hashlib
import requests
from urllib import parse
from app.core import Config
from app.utils.logger import get_logger
logger = get_logger("森空岛签到任务")
async def skland_sign_in(token) -> dict:
def skland_sign_in(token) -> dict:
"""森空岛签到"""
app_code = "4ca99fa6b56cc2ba"
@@ -69,17 +71,16 @@ async def skland_sign_in(token) -> dict:
"vName": "1.5.1",
}
# 生成签名
def generate_signature(token_for_sign: str, path, body_or_query):
"""
生成请求签名
:param token_for_sign: 用于加密的token
:param path: 请求路径 /api/v1/game/player/binding
:param body_or_query: GET用query字符串, POST用body字符串
:param body_or_query: GET用query字符串POST用body字符串
:return: (sign, 新的header_for_sign字典)
"""
t = str(int(time.time()) - 2) # 时间戳, -2秒以防服务器时间不一致
t = str(int(time.time()) - 2) # 时间戳,-2秒以防服务器时间不一致
token_bytes = token_for_sign.encode("utf-8")
header_ca = dict(header_for_sign)
header_ca["timestamp"] = t
@@ -90,10 +91,10 @@ async def skland_sign_in(token) -> dict:
md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
return md5, header_ca
# 获取带签名的header
def get_sign_header(url: str, method, body, old_header, sign_token):
"""
获取带签名的请求头
:param url: 请求完整url
:param method: 请求方式 GET/POST
:param body: POST请求体或GET时为None
@@ -101,7 +102,6 @@ async def skland_sign_in(token) -> dict:
:param sign_token: 当前会话的签名token
:return: 新请求头
"""
h = json.loads(json.dumps(old_header))
p = parse.urlparse(url)
if method.lower() == "get":
@@ -115,21 +115,15 @@ async def skland_sign_in(token) -> dict:
h[i] = header_ca[i]
return h
# 复制请求头并添加cred
def copy_header(cred):
"""
复制请求头并添加cred
:param cred: 当前会话的cred
:return: 新的请求头
"""
v = json.loads(json.dumps(header))
v["cred"] = cred
return v
async def login_by_token(token_code):
# 使用token一步步拿到cred和sign_token
def login_by_token(token_code):
"""
使用token一步步拿到cred和sign_token
:param token_code: 你的skyland token
:return: (cred, sign_token)
"""
@@ -139,52 +133,47 @@ async def skland_sign_in(token) -> dict:
token_code = t["data"]["content"]
except:
pass
grant_code = await get_grant_code(token_code)
return await get_cred(grant_code)
async def get_cred(grant):
"""
通过grant code获取cred和sign_token
:param grant: grant code
:return: (cred, sign_token)
"""
grant_code = get_grant_code(token_code)
return get_cred(grant_code)
# 通过grant code换cred和sign_token
def get_cred(grant):
rsp = requests.post(
cred_code_url,
json={"code": grant, "kind": 1},
headers=header_login,
proxies=Config.get_proxies(),
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
raise Exception(f"获得cred失败: {rsp.get('message')}")
raise Exception(f'获得cred失败{rsp.get("messgae")}')
sign_token = rsp["data"]["token"]
cred = rsp["data"]["cred"]
return cred, sign_token
async def get_grant_code(token):
"""
通过token获取grant code
:param token: 你的skyland token
:return: grant code
"""
# 通过token换grant code
def get_grant_code(token):
rsp = requests.post(
grant_code_url,
json={"appCode": app_code, "token": token, "type": 0},
headers=header_login,
proxies=Config.get_proxies(),
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["status"] != 0:
raise Exception(
f"使用token: {token[:3]}******{token[-3:]} 获得认证代码失败: {rsp.get('msg')}"
f'使用token: {token[:3]}******{token[-3:]} 获得认证代码失败{rsp.get("msg")}'
)
return rsp["data"]["code"]
async def get_binding_list(cred, sign_token):
# 获取已绑定的角色列表
def get_binding_list(cred, sign_token):
"""
查询绑定的角色列表
查询绑定的角色
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 角色列表
@@ -195,12 +184,15 @@ async def skland_sign_in(token) -> dict:
headers=get_sign_header(
binding_url, "get", None, copy_header(cred), sign_token
),
proxies=Config.get_proxies(),
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
logger.error(f"请求角色列表出现问题: {rsp['message']}")
logger.error(f"森空岛服务 | 请求角色列表出现问题{rsp['message']}")
if rsp.get("message") == "用户未登录":
logger.error(f"用户登录可能失效了, 请重新登录!")
logger.error(f"森空岛服务 | 用户登录可能失效了请重新登录!")
return v
# 只取明日方舟arknights的绑定账号
for i in rsp["data"]["list"]:
@@ -209,16 +201,16 @@ async def skland_sign_in(token) -> dict:
v.extend(i.get("bindingList"))
return v
async def do_sign(cred, sign_token) -> dict:
# 执行签到
def do_sign(cred, sign_token) -> dict:
"""
对所有绑定的角色进行签到
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 签到结果字典
"""
characters = await get_binding_list(cred, sign_token)
characters = get_binding_list(cred, sign_token)
result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
for character in characters:
@@ -233,7 +225,10 @@ async def skland_sign_in(token) -> dict:
sign_url, "post", body, copy_header(cred), sign_token
),
json=body,
proxies=Config.get_proxies(),
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
@@ -250,17 +245,17 @@ async def skland_sign_in(token) -> dict:
f"{character.get("nickName")}{character.get("channelName")}"
)
await asyncio.sleep(3)
time.sleep(3)
return result
# 主流程
try:
# 拿到cred和sign_token
cred, sign_token = await login_by_token(token)
await asyncio.sleep(1)
cred, sign_token = login_by_token(token)
time.sleep(1)
# 依次签到
return await do_sign(cred, sign_token)
return do_sign(cred, sign_token)
except Exception as e:
logger.exception(f"森空岛签到失败: {e}")
logger.error(f"森空岛服务 | 森空岛签到失败: {e}")
return {"成功": [], "重复": [], "失败": [], "总计": 0}

View File

@@ -1,28 +1,34 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA系统服务
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import QApplication
import sys
import ctypes
import asyncio
import win32gui
import win32process
import psutil
@@ -31,28 +37,24 @@ import tempfile
import getpass
from datetime import datetime
from pathlib import Path
from typing import Literal, Optional
from app.core import Config
from app.models.schema import WebSocketMessage
from app.utils.logger import get_logger
logger = get_logger("系统服务")
class _SystemHandler:
ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001
countdown = 60
def __init__(self) -> None:
self.power_task: Optional[asyncio.Task] = None
def __init__(self):
async def set_Sleep(self) -> None:
self.set_Sleep()
self.set_SelfStart()
def set_Sleep(self) -> None:
"""同步系统休眠状态"""
if Config.get("Function", "IfAllowSleep"):
if Config.get(Config.function_IfAllowSleep):
# 设置系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(
self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED
@@ -61,10 +63,10 @@ class _SystemHandler:
# 恢复系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS)
async def set_SelfStart(self) -> None:
def set_SelfStart(self) -> None:
"""同步开机自启"""
if Config.get("Start", "IfSelfStart") and not await self.is_startup():
if Config.get(Config.start_IfSelfStart) and not self.is_startup():
# 创建任务计划
try:
@@ -79,8 +81,8 @@ class _SystemHandler:
<RegistrationInfo>
<Date>{current_time}</Date>
<Author>{current_user}</Author>
<Description>AUTO-MAS自启动服务</Description>
<URI>\\AUTO-MAS_AutoStart</URI>
<Description>AUTO_MAA自启动服务</Description>
<URI>\\AUTO_MAA_AutoStart</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
@@ -115,7 +117,7 @@ class _SystemHandler:
</Settings>
<Actions Context="Author">
<Exec>
<Command>"{Path.cwd() / 'AUTO-MAS.exe'}"</Command>
<Command>"{Config.app_path_sys}"</Command>
</Exec>
</Actions>
</Task>"""
@@ -133,7 +135,7 @@ class _SystemHandler:
"schtasks",
"/create",
"/tn",
"AUTO-MAS_AutoStart",
"AUTO_MAA_AutoStart",
"/xml",
xml_file,
"/f",
@@ -145,11 +147,9 @@ class _SystemHandler:
)
if result.returncode == 0:
logger.success(
f"程序自启动任务计划已创建: {Path.cwd() / 'AUTO-MAS.exe'}"
)
logger.info(f"任务计划程序自启动已创建: {Config.app_path_sys}")
else:
logger.error(f"程序自启动任务计划创建失败: {result.stderr}")
logger.error(f"创建任务计划失败: {result.stderr}")
finally:
# 删除临时文件
@@ -159,14 +159,14 @@ class _SystemHandler:
pass
except Exception as e:
logger.exception(f"程序自启动任务计划创建失败: {e}")
logger.exception(f"设置任务计划程序自启动失败: {e}")
elif not Config.get("Start", "IfSelfStart") and await self.is_startup():
elif not Config.get(Config.start_IfSelfStart) and self.is_startup():
try:
result = subprocess.run(
["schtasks", "/delete", "/tn", "AUTO-MAS_AutoStart", "/f"],
["schtasks", "/delete", "/tn", "AUTO_MAA_AutoStart", "/f"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
@@ -174,24 +174,14 @@ class _SystemHandler:
)
if result.returncode == 0:
logger.success("程序自启动任务计划已删除")
logger.info("任务计划程序自启动已删除")
else:
logger.error(f"程序自启动任务计划删除失败: {result.stderr}")
logger.error(f"删除任务计划失败: {result.stderr}")
except Exception as e:
logger.exception(f"程序自启动任务计划删除失败: {e}")
logger.exception(f"删除任务计划程序自启动失败: {e}")
async def set_power(
self,
mode: Literal[
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
],
) -> None:
"""
执行系统电源操作
:param mode: 电源操作
"""
def set_power(self, mode) -> None:
if sys.platform.startswith("win"):
@@ -201,14 +191,9 @@ class _SystemHandler:
elif mode == "Shutdown":
await self.kill_emulator_processes()
logger.info("执行关机操作")
subprocess.run(["shutdown", "/s", "/t", "0"])
elif mode == "ShutdownForce":
logger.info("执行强制关机操作")
subprocess.run(["shutdown", "/s", "/t", "0", "/f"])
elif mode == "Hibernate":
logger.info("执行休眠操作")
@@ -221,10 +206,11 @@ class _SystemHandler:
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
)
elif mode == "KillSelf" and Config.server is not None:
elif mode == "KillSelf":
logger.info("执行退出主程序操作")
Config.server.should_exit = True
Config.main_window.close()
QApplication.quit()
sys.exit(0)
elif sys.platform.startswith("linux"):
@@ -247,75 +233,18 @@ class _SystemHandler:
logger.info("执行睡眠操作")
subprocess.run(["systemctl", "suspend"])
elif mode == "KillSelf" and Config.server is not None:
elif mode == "KillSelf":
logger.info("执行退出主程序操作")
Config.server.should_exit = True
Config.main_window.close()
QApplication.quit()
sys.exit(0)
async def _power_task(
self,
power_sign: Literal[
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
],
) -> None:
"""电源任务"""
await asyncio.sleep(self.countdown)
if power_sign == "KillSelf":
await Config.send_json(
WebSocketMessage(
id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
).model_dump()
)
await self.set_power(power_sign)
async def start_power_task(self):
"""开始电源任务"""
if self.power_task is None or self.power_task.done():
self.power_task = asyncio.create_task(self._power_task(Config.power_sign))
logger.info(
f"电源任务已启动, {self.countdown}秒后执行: {Config.power_sign}"
)
else:
logger.warning("已有电源任务在运行, 请勿重复启动")
async def cancel_power_task(self):
"""取消电源任务"""
if self.power_task is not None and not self.power_task.done():
self.power_task.cancel()
try:
await self.power_task
except asyncio.CancelledError:
logger.info("电源任务已取消")
else:
logger.warning("当前无电源任务在运行")
raise RuntimeError("当前无电源任务在运行")
async def kill_emulator_processes(self):
"""这里暂时仅支持 MuMu 模拟器"""
logger.info("正在清除模拟器进程")
keywords = ["Nemu", "nemu", "emulator", "MuMu"]
for proc in psutil.process_iter(["pid", "name"]):
try:
pname = proc.info["name"].lower()
if any(keyword.lower() in pname for keyword in keywords):
proc.kill()
logger.info(f"已关闭 MuMu 模拟器进程: {proc.info['name']}")
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
logger.success("模拟器进程清除完成")
async def is_startup(self) -> bool:
def is_startup(self) -> bool:
"""判断程序是否已经开机自启"""
try:
result = subprocess.run(
["schtasks", "/query", "/tn", "AUTO-MAS_AutoStart"],
["schtasks", "/query", "/tn", "AUTO_MAA_AutoStart"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
@@ -323,11 +252,11 @@ class _SystemHandler:
)
return result.returncode == 0
except Exception as e:
logger.exception(f"检查任务计划程序失败: {e}")
logger.error(f"检查任务计划程序失败: {e}")
return False
async def get_window_info(self) -> list:
"""获取当前前台窗口信息"""
def get_window_info(self) -> list:
"""获取当前窗口信息"""
def callback(hwnd, window_info):
if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd):
@@ -340,16 +269,10 @@ class _SystemHandler:
win32gui.EnumWindows(callback, window_info)
return window_info
async def kill_process(self, path: Path) -> None:
"""
根据路径中止进程
def kill_process(self, path: Path) -> None:
"""根据路径中止进程"""
:param path: 进程路径
"""
logger.info(f"开始中止进程: {path}")
for pid in await self.search_pids(path):
for pid in self.search_pids(path):
killprocess = subprocess.Popen(
f"taskkill /F /T /PID {pid}",
shell=True,
@@ -357,17 +280,8 @@ class _SystemHandler:
)
killprocess.wait()
logger.success(f"进程已中止: {path}")
async def search_pids(self, path: Path) -> list:
"""
根据路径查找进程PID
:param path: 进程路径
:return: 匹配的进程PID列表
"""
logger.info(f"开始查找进程 PID: {path}")
def search_pids(self, path: Path) -> list:
"""根据路径查找进程PID"""
pids = []
for proc in psutil.process_iter(["pid", "exe"]):
@@ -375,7 +289,7 @@ class _SystemHandler:
if proc.info["exe"] and proc.info["exe"].lower() == str(path).lower():
pids.append(proc.info["pid"])
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
# 进程可能在此期间已结束或无法访问, 忽略这些异常
# 进程可能在此期间已结束或无法访问忽略这些异常
pass
return pids

View File

@@ -1,389 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import re
import time
import json
import asyncio
import zipfile
import requests
import subprocess
from packaging import version
from datetime import datetime, timedelta
from typing import List, Dict, Optional
from pathlib import Path
from app.core import Config
from app.models.schema import WebSocketMessage
from app.utils.constants import MIRROR_ERROR_INFO
from app.utils.logger import get_logger
logger = get_logger("更新服务")
class _UpdateHandler:
def __init__(self) -> None:
self.is_locked: bool = False
self.remote_version: Optional[str] = None
self.last_check_time: Optional[datetime] = None
self.update_version_info: Optional[Dict[str, List[str]]] = None
self.mirror_chyan_download_url: Optional[str] = None
async def check_update(
self, current_version: str, if_force: bool = False
) -> tuple[bool, str, Dict[str, List[str]]]:
if (
not if_force
and self.remote_version is not None
and self.last_check_time is not None
and self.update_version_info is not None
and self.last_check_time > datetime.now() - timedelta(hours=4)
):
logger.info("四小时内已进行过一次检查, 直接使用缓存的版本更新信息")
return (
bool(
version.parse(self.remote_version) > version.parse(current_version)
),
self.remote_version,
self.update_version_info,
)
logger.info("开始检查更新")
response = requests.get(
f"https://mirrorchyan.com/api/resources/AUTO_MAA/latest?user_agent=AutoMasGui&current_version={current_version}&cdk={Config.get('Update', 'MirrorChyanCDK')}&channel=stable",
timeout=10,
proxies=Config.get_proxies(),
)
if response.status_code == 200:
version_info = response.json()
else:
result = response.json()
if result["code"] != 0:
if result["code"] in MIRROR_ERROR_INFO:
raise Exception(
f"获取版本信息时出错: {MIRROR_ERROR_INFO[result['code']]}"
)
else:
raise Exception(
"获取版本信息时出错: 意料之外的错误, 请及时联系项目组以获取来自 Mirror 酱的技术支持"
)
logger.success("获取版本信息成功")
self.last_check_time = datetime.now()
self.remote_version = version_info["data"]["version_name"]
if self.remote_version is None:
raise Exception("Mirror 酱未返回版本号, 请稍后重试")
if "url" in version_info["data"]:
self.mirror_chyan_download_url = version_info["data"]["url"]
if version.parse(self.remote_version) > version.parse(current_version):
# 版本更新信息
version_info_json: Dict[str, Dict[str, List[str]]] = json.loads(
re.sub(
r"^<!--\s*(.*?)\s*-->$",
r"\1",
version_info["data"]["release_note"].splitlines()[0],
)
)
self.update_version_info = {}
for v_i in [
info
for ver, info in version_info_json.items()
if version.parse(ver) > version.parse(current_version)
]:
for key, value in v_i.items():
if key not in self.update_version_info:
self.update_version_info[key] = []
self.update_version_info[key] += value
return True, self.remote_version, self.update_version_info
else:
return False, current_version, {}
async def download_update(self) -> None:
if self.is_locked:
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
).model_dump()
)
return None
self.is_locked = True
if self.remote_version is None:
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={"Failed": "未检测到可用的远程版本, 请先检查更新"},
).model_dump()
)
self.is_locked = False
return None
if (Path.cwd() / f"UpdatePack_{self.remote_version}.zip").exists():
logger.info(
f"更新包已存在: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
)
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={
"Accomplish": str(
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
)
},
).model_dump()
)
self.is_locked = False
return None
if Config.get("Update", "Source") == "GitHub":
download_url = f"https://github.com/AUTO-MAS-Project/AUTO-MAS/releases/download/{self.remote_version}/AUTO-MAS_{self.remote_version}.zip"
elif Config.get("Update", "Source") == "MirrorChyan":
if self.mirror_chyan_download_url is None:
logger.warning("MirrorChyan 未返回下载链接, 使用自建下载站")
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
else:
with requests.get(
self.mirror_chyan_download_url,
allow_redirects=True,
timeout=10,
stream=True,
proxies=Config.get_proxies(),
) as response:
if response.status_code == 200:
download_url = response.url
elif Config.get("Update", "Source") == "AutoSite":
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
else:
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={
"Failed": f"未知的下载源: {Config.get('Update', 'Source')}, 请检查配置文件"
},
).model_dump()
)
self.is_locked = False
return None
logger.info(f"开始下载: {download_url}")
check_times = 3
while check_times != 0:
try:
# 清理可能存在的临时文件
if (Path.cwd() / "download.temp").exists():
(Path.cwd() / "download.temp").unlink()
start_time = time.time()
response = requests.get(
download_url, timeout=10, stream=True, proxies=Config.get_proxies()
)
if response.status_code not in [200, 206]:
if check_times != -1:
check_times -= 1
logger.warning(
f"连接失败: {download_url}, 状态码: {response.status_code}, 剩余重试次数: {check_times}"
)
await asyncio.sleep(1)
continue
logger.info(f"连接成功: {download_url}, 状态码: {response.status_code}")
file_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
last_download_size = 0
speed = 0
last_time = time.time()
with (Path.cwd() / "download.temp").open(mode="wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
downloaded_size += len(chunk)
# 更新指定线程的下载进度, 每秒更新一次
if time.time() - last_time >= 1.0:
speed = (
(downloaded_size - last_download_size)
/ (time.time() - last_time)
/ 1024
)
last_download_size = downloaded_size
last_time = time.time()
await Config.send_json(
WebSocketMessage(
id="Update",
type="Update",
data={
"downloaded_size": downloaded_size,
"file_size": file_size,
"speed": speed,
},
).model_dump()
)
(Path.cwd() / "download.temp").rename(
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
)
logger.success(
f"下载完成: {download_url}, 实际下载大小: {downloaded_size} 字节, 耗时: {time.time() - start_time:.2f} 秒, 保存位置: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
)
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={
"Accomplish": str(
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
)
},
).model_dump()
)
self.is_locked = False
break
except Exception as e:
if check_times != -1:
check_times -= 1
logger.info(
f"下载出错: {download_url}, 错误信息: {e}, 剩余重试次数: {check_times}"
)
await asyncio.sleep(1)
else:
if (Path.cwd() / "download.temp").exists():
(Path.cwd() / "download.temp").unlink()
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={"Failed": f"下载失败: {download_url}"},
).model_dump()
)
self.is_locked = False
async def install_update(self):
if self.is_locked:
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
).model_dump()
)
return None
logger.info("开始应用更新")
self.is_locked = True
versions = {
version.parse(match.group(1)): f.name
for f in Path.cwd().glob("UpdatePack_*.zip")
if (match := re.match(r"UpdatePack_(.+)\.zip$", f.name))
}
logger.info(f"检测到的更新包: {versions.values()}")
if not versions:
await Config.send_json(
WebSocketMessage(
id="Update",
type="Signal",
data={"Failed": "未检测到更新包, 请先下载更新"},
).model_dump()
)
self.is_locked = False
return None
update_package = Path.cwd() / versions[max(versions)]
logger.info(f"开始解压: {update_package}{Path.cwd()}")
try:
with zipfile.ZipFile(update_package, "r") as zip_ref:
zip_ref.extractall(Path.cwd())
except Exception as e:
logger.error(f"解压失败, {type(e).__name__}: {e}")
await Config.send_json(
WebSocketMessage(
id="Update",
type="Info",
data={"Error": f"解压失败, {type(e).__name__}: {e}"},
).model_dump()
)
self.is_locked = False
return None
logger.success(f"解压完成: {update_package}{Path.cwd()}")
logger.info("正在删除临时文件与旧更新包文件")
if (Path.cwd() / "changes.json").exists():
(Path.cwd() / "changes.json").unlink()
for f in versions.values():
if (Path.cwd() / f).exists():
(Path.cwd() / f).unlink()
logger.info("启动更新程序")
self.is_locked = False
subprocess.Popen(
[Path.cwd() / "AUTO-MAS-Setup.exe"],
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
| subprocess.CREATE_NO_WINDOW,
)
Updater = _UpdateHandler()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2189
app/ui/Widget.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,35 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA图形化界面包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .main_window import AUTO_MAA
from .Widget import ProgressRingMessageBox
from .skland import skland_sign_in
from .general import GeneralManager
from .MAA import MaaManager
__all__ = ["skland_sign_in", "GeneralManager", "MaaManager"]
__all__ = ["AUTO_MAA", "ProgressRingMessageBox"]

559
app/ui/dispatch_center.py Normal file
View File

@@ -0,0 +1,559 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA调度中枢界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
BodyLabel,
CardWidget,
ScrollArea,
FluentIcon,
HeaderCardWidget,
FluentIcon,
TextBrowser,
ComboBox,
SubtitleLabel,
PushButton,
)
from PySide6.QtGui import QTextCursor
from typing import List, Dict
from app.core import Config, TaskManager, Task, MainInfoBar, SoundPlayer
from .Widget import StatefulItemCard, ComboBoxMessageBox, PivotArea
class DispatchCenter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度中枢")
self.multi_button = PushButton(FluentIcon.ADD, "添加任务", self)
self.multi_button.setToolTip("添加任务")
self.multi_button.clicked.connect(self.start_multi_task)
self.power_combox = ComboBox()
self.power_combox.addItem("无动作", userData="NoAction")
self.power_combox.addItem("退出软件", userData="KillSelf")
self.power_combox.addItem("睡眠", userData="Sleep")
self.power_combox.addItem("休眠", userData="Hibernate")
self.power_combox.addItem("关机", userData="Shutdown")
self.power_combox.setCurrentText("无动作")
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
self.pivotArea = PivotArea(self)
self.pivot = self.pivotArea.pivot
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: Dict[str, DispatchCenter.DispatchBox] = {}
dispatch_box = self.DispatchBox("主调度台", self)
self.script_list["主调度台"] = dispatch_box
self.stackedWidget.addWidget(self.script_list["主调度台"])
self.pivot.addItem(
routeKey="主调度台",
text="主调度台",
onClick=self.update_top_bar,
icon=FluentIcon.CAFE,
)
h_layout = QHBoxLayout()
h_layout.addWidget(self.multi_button)
h_layout.addWidget(self.pivotArea)
h_layout.addWidget(BodyLabel("全部完成后", self))
h_layout.addWidget(self.power_combox)
h_layout.setContentsMargins(11, 5, 11, 0)
self.Layout = QVBoxLayout(self)
self.Layout.addLayout(h_layout)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.stackedWidget.setCurrentWidget(self.script_list[index])
)
def add_board(self, task: Task) -> None:
"""添加一个调度台界面"""
dispatch_box = self.DispatchBox(task.name, self)
dispatch_box.top_bar.main_button.clicked.connect(
lambda: TaskManager.stop_task(task.name)
)
task.create_task_list.connect(dispatch_box.info.task.create_task)
task.create_user_list.connect(dispatch_box.info.user.create_user)
task.update_task_list.connect(dispatch_box.info.task.update_task)
task.update_user_list.connect(dispatch_box.info.user.update_user)
task.update_log_text.connect(dispatch_box.info.log_text.text.setText)
task.accomplish.connect(lambda: self.del_board(f"调度台_{task.name}"))
self.script_list[f"调度台_{task.name}"] = dispatch_box
self.stackedWidget.addWidget(self.script_list[f"调度台_{task.name}"])
self.pivot.addItem(routeKey=f"调度台_{task.name}", text=f"调度台 {task.name}")
def del_board(self, name: str) -> None:
"""删除指定子界面"""
self.pivot.setCurrentItem("主调度台")
self.stackedWidget.removeWidget(self.script_list[name])
self.script_list[name].deleteLater()
self.pivot.removeWidget(name)
def connect_main_board(self, task: Task) -> None:
"""连接主调度台"""
self.script_list["主调度台"].top_bar.Lable.setText(
f"{task.name} - {task.mode.replace("_主调度台","")}模式"
)
self.script_list["主调度台"].top_bar.Lable.show()
self.script_list["主调度台"].top_bar.object.hide()
self.script_list["主调度台"].top_bar.mode.hide()
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
self.script_list["主调度台"].top_bar.main_button.setText("中止任务")
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
lambda: TaskManager.stop_task(task.name)
)
task.create_task_list.connect(
self.script_list["主调度台"].info.task.create_task
)
task.create_user_list.connect(
self.script_list["主调度台"].info.user.create_user
)
task.update_task_list.connect(
self.script_list["主调度台"].info.task.update_task
)
task.update_user_list.connect(
self.script_list["主调度台"].info.user.update_user
)
task.update_log_text.connect(
self.script_list["主调度台"].info.log_text.text.setText
)
task.accomplish.connect(
lambda logs: self.disconnect_main_board(task.name, logs)
)
def disconnect_main_board(self, name: str, logs: list) -> None:
"""断开主调度台"""
self.script_list["主调度台"].top_bar.Lable.hide()
self.script_list["主调度台"].top_bar.object.show()
self.script_list["主调度台"].top_bar.mode.show()
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
self.script_list["主调度台"].top_bar.main_button.setText("开始任务")
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
self.script_list["主调度台"].top_bar.start_main_task
)
if len(logs) > 0:
history = ""
for log in logs:
history += (
f"任务名称:{log[0]}{log[1]["History"].replace("\n","\n ")}\n"
)
self.script_list["主调度台"].info.log_text.text.setText(history)
else:
self.script_list["主调度台"].info.log_text.text.setText("没有任务被执行")
def update_top_bar(self):
"""更新顶栏"""
self.script_list["主调度台"].top_bar.object.clear()
for name, info in Config.queue_dict.items():
self.script_list["主调度台"].top_bar.object.addItem(
(
"队列"
if info["Config"].get(info["Config"].queueSet_Name) == ""
else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}"
),
userData=name,
)
for name, info in Config.member_dict.items():
self.script_list["主调度台"].top_bar.object.addItem(
(
f"实例 - {info['Type']}"
if info["Config"].get_name() == ""
else f"实例 - {info['Type']} - {info['Config'].get_name()}"
),
userData=name,
)
if len(Config.queue_dict) == 1:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(0)
elif len(Config.member_dict) == 1:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(
len(Config.queue_dict)
)
else:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(-1)
self.script_list["主调度台"].top_bar.mode.clear()
self.script_list["主调度台"].top_bar.mode.addItems(["自动代理", "人工排查"])
self.script_list["主调度台"].top_bar.mode.setCurrentIndex(0)
def update_power_sign(self) -> None:
"""更新电源设置"""
mode_book = {
"NoAction": "无动作",
"KillSelf": "退出软件",
"Sleep": "睡眠",
"Hibernate": "休眠",
"Shutdown": "关机",
}
self.power_combox.currentIndexChanged.disconnect()
self.power_combox.setCurrentText(mode_book[Config.power_sign])
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
def set_power_sign(self) -> None:
"""设置所有任务完成后动作"""
if not Config.running_list:
self.power_combox.currentIndexChanged.disconnect()
self.power_combox.setCurrentText("无动作")
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
logger.warning("没有正在运行的任务,无法设置任务完成后动作")
MainInfoBar.push_info_bar(
"warning",
"没有正在运行的任务",
"无法设置任务完成后动作",
5000,
)
else:
Config.set_power_sign(self.power_combox.currentData())
def start_multi_task(self) -> None:
"""开始任务"""
# 获取所有可用的队列和实例
text_list = []
data_list = []
for name, info in Config.queue_dict.items():
if name in Config.running_list:
continue
text_list.append(
"队列"
if info["Config"].get(info["Config"].queueSet_Name) == ""
else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}"
)
data_list.append(name)
for name, info in Config.member_dict.items():
if name in Config.running_list:
continue
text_list.append(
f"实例 - {info['Type']}"
if info["Config"].get_name() == ""
else f"实例 - {info['Type']} - {info['Config'].get_name()}"
)
data_list.append(name)
choice = ComboBoxMessageBox(
self.window(),
"选择一个对象以添加相应多开任务",
["选择调度对象"],
[text_list],
[data_list],
)
if choice.exec() and choice.input[0].currentIndex() != -1:
if choice.input[0].currentData() in Config.running_list:
logger.warning(f"任务已存在:{choice.input[0].currentData()}")
MainInfoBar.push_info_bar(
"warning", "任务已存在", choice.input[0].currentData(), 5000
)
return None
if "调度队列" in choice.input[0].currentData():
logger.info(f"用户添加任务:{choice.input[0].currentData()}")
TaskManager.add_task(
"自动代理_新调度台",
choice.input[0].currentData(),
Config.queue_dict[choice.input[0].currentData()]["Config"].toDict(),
)
elif "脚本" in choice.input[0].currentData():
logger.info(f"用户添加任务:{choice.input[0].currentData()}")
TaskManager.add_task(
"自动代理_新调度台",
f"自定义队列 - {choice.input[0].currentData()}",
{"Queue": {"Member_1": choice.input[0].currentData()}},
)
class DispatchBox(QWidget):
def __init__(self, name: str, parent=None):
super().__init__(parent)
self.setObjectName(name)
self.top_bar = self.DispatchTopBar(self, name)
self.info = self.DispatchInfoCard(self)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(self.top_bar)
content_layout.addWidget(self.info)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
class DispatchTopBar(CardWidget):
def __init__(self, parent=None, name: str = None):
super().__init__(parent)
Layout = QHBoxLayout(self)
if name == "主调度台":
self.Lable = SubtitleLabel("", self)
self.Lable.hide()
self.object = ComboBox()
self.object.setPlaceholderText("请选择调度对象")
self.mode = ComboBox()
self.mode.setPlaceholderText("请选择调度模式")
self.main_button = PushButton("开始任务")
self.main_button.clicked.connect(self.start_main_task)
Layout.addWidget(self.Lable)
Layout.addWidget(self.object)
Layout.addWidget(self.mode)
Layout.addStretch(1)
Layout.addWidget(self.main_button)
else:
self.Lable = SubtitleLabel(name, self)
self.main_button = PushButton("中止任务")
Layout.addWidget(self.Lable)
Layout.addStretch(1)
Layout.addWidget(self.main_button)
def start_main_task(self):
"""开始任务"""
if self.object.currentIndex() == -1:
logger.warning("未选择调度对象")
MainInfoBar.push_info_bar(
"warning", "未选择调度对象", "请选择后再开始任务", 5000
)
return None
if self.mode.currentIndex() == -1:
logger.warning("未选择调度模式")
MainInfoBar.push_info_bar(
"warning", "未选择调度模式", "请选择后再开始任务", 5000
)
return None
if self.object.currentData() in Config.running_list:
logger.warning(f"任务已存在:{self.object.currentData()}")
MainInfoBar.push_info_bar(
"warning", "任务已存在", self.object.currentData(), 5000
)
return None
if (
"脚本" in self.object.currentData()
and Config.member_dict[self.object.currentData()]["Type"]
== "General"
and self.mode.currentData() == "人工排查"
):
logger.warning("通用脚本类型不存在人工排查功能")
MainInfoBar.push_info_bar(
"warning", "不支持的任务", "通用脚本无人工排查功能", 5000
)
return None
if "调度队列" in self.object.currentData():
logger.info(f"用户添加任务:{self.object.currentData()}")
TaskManager.add_task(
f"{self.mode.currentText()}_主调度台",
self.object.currentData(),
Config.queue_dict[self.object.currentData()]["Config"].toDict(),
)
elif "脚本" in self.object.currentData():
logger.info(f"用户添加任务:{self.object.currentData()}")
TaskManager.add_task(
f"{self.mode.currentText()}_主调度台",
"自定义队列",
{"Queue": {"Member_1": self.object.currentData()}},
)
class DispatchInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("调度信息")
self.task = self.TaskInfoCard(self)
self.user = self.UserInfoCard(self)
self.log_text = self.LogCard(self)
self.viewLayout.addWidget(self.task)
self.viewLayout.addWidget(self.user)
self.viewLayout.addWidget(self.log_text)
self.viewLayout.setStretch(0, 1)
self.viewLayout.setStretch(1, 1)
self.viewLayout.setStretch(2, 5)
def update_board(self, task_list: list, user_list: list, log: str):
"""更新调度信息"""
self.task.update_task(task_list)
self.user.update_user(user_list)
self.log_text.text.setText(log)
class TaskInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("任务队列")
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.task_cards: List[StatefulItemCard] = []
def create_task(self, task_list: list):
"""创建任务队列"""
while self.Layout.count() > 0:
item = self.Layout.takeAt(0)
if item.spacerItem():
self.Layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.task_cards = []
for task in task_list:
self.task_cards.append(StatefulItemCard(task))
self.Layout.addWidget(self.task_cards[-1])
self.Layout.addStretch(1)
def update_task(self, task_list: list):
"""更新任务队列"""
for i in range(len(task_list)):
self.task_cards[i].update_status(task_list[i][1])
class UserInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("用户队列")
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.user_cards: List[StatefulItemCard] = []
def create_user(self, user_list: list):
"""创建用户队列"""
while self.Layout.count() > 0:
item = self.Layout.takeAt(0)
if item.spacerItem():
self.Layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.user_cards = []
for user in user_list:
self.user_cards.append(StatefulItemCard(user))
self.Layout.addWidget(self.user_cards[-1])
self.Layout.addStretch(1)
def update_user(self, user_list: list):
"""更新用户队列"""
for i in range(len(user_list)):
self.user_cards[i].Label.setText(user_list[i][0])
self.user_cards[i].update_status(user_list[i][1])
class LogCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("日志")
self.text = TextBrowser()
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.viewLayout.addWidget(self.text)
self.text.textChanged.connect(self.to_end)
def to_end(self):
"""滚动到底部"""
self.text.moveCursor(QTextCursor.End)
self.text.ensureCursorVisible()

652
app/ui/downloader.py Normal file
View File

@@ -0,0 +1,652 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA更新器
v4.4
作者DLmaster_361
"""
from loguru import logger
import zipfile
import requests
import subprocess
import time
from functools import partial
from pathlib import Path
from PySide6.QtWidgets import QDialog, QVBoxLayout
from qfluentwidgets import (
ProgressBar,
IndeterminateProgressBar,
BodyLabel,
setTheme,
Theme,
)
from PySide6.QtGui import QCloseEvent
from PySide6.QtCore import QThread, Signal, QTimer, QEventLoop
from typing import List, Dict, Union
from app.core import Config
from app.services import System
def version_text(version_numb: list) -> str:
"""将版本号列表转为可读的文本信息"""
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
version = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
return version
class DownloadProcess(QThread):
"""分段下载子线程"""
progress = Signal(int)
accomplish = Signal(float)
def __init__(
self,
url: str,
start_byte: int,
end_byte: int,
download_path: Path,
check_times: int = -1,
) -> None:
super(DownloadProcess, self).__init__()
self.setObjectName(f"DownloadProcess-{url}-{start_byte}-{end_byte}")
self.url = url
self.start_byte = start_byte
self.end_byte = end_byte
self.download_path = download_path
self.check_times = check_times
@logger.catch
def run(self) -> None:
# 清理可能存在的临时文件
if self.download_path.exists():
self.download_path.unlink()
logger.info(
f"开始下载:{self.url},范围:{self.start_byte}-{self.end_byte},存储地址:{self.download_path}"
)
headers = (
{"Range": f"bytes={self.start_byte}-{self.end_byte}"}
if not (self.start_byte == -1 or self.end_byte == -1)
else None
)
while not self.isInterruptionRequested() and self.check_times != 0:
try:
start_time = time.time()
response = requests.get(
self.url,
headers=headers,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
if response.status_code not in [200, 206]:
if self.check_times != -1:
self.check_times -= 1
logger.error(
f"连接失败:{self.url},状态码:{response.status_code},剩余重试次数:{self.check_times}"
)
time.sleep(1)
continue
logger.info(f"连接成功:{self.url},状态码:{response.status_code}")
downloaded_size = 0
with self.download_path.open(mode="wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if self.isInterruptionRequested():
break
f.write(chunk)
downloaded_size += len(chunk)
self.progress.emit(downloaded_size)
if self.isInterruptionRequested():
if self.download_path.exists():
self.download_path.unlink()
self.accomplish.emit(0)
logger.info(f"下载中止:{self.url}")
else:
self.accomplish.emit(time.time() - start_time)
logger.success(
f"下载完成:{self.url},实际下载大小:{downloaded_size} 字节,耗时:{time.time() - start_time:.2f}"
)
break
except Exception as e:
if self.check_times != -1:
self.check_times -= 1
logger.exception(
f"下载出错:{self.url},错误信息:{e},剩余重试次数:{self.check_times}"
)
time.sleep(1)
else:
if self.download_path.exists():
self.download_path.unlink()
self.accomplish.emit(0)
logger.error(f"下载失败:{self.url}")
class ZipExtractProcess(QThread):
"""解压子线程"""
info = Signal(str)
accomplish = Signal()
def __init__(self, name: str, app_path: Path, download_path: Path) -> None:
super(ZipExtractProcess, self).__init__()
self.setObjectName(f"ZipExtractProcess-{name}")
self.name = name
self.app_path = app_path
self.download_path = download_path
@logger.catch
def run(self) -> None:
try:
logger.info(f"开始解压:{self.download_path}{self.app_path}")
while True:
if self.isInterruptionRequested():
self.download_path.unlink()
return None
try:
with zipfile.ZipFile(self.download_path, "r") as zip_ref:
zip_ref.extractall(self.app_path)
self.accomplish.emit()
logger.success(f"解压完成:{self.download_path}{self.app_path}")
break
except PermissionError:
if self.name == "AUTO_MAA":
self.info.emit(f"解压出错AUTO_MAA正在运行正在尝试将其关闭")
System.kill_process(self.app_path / "AUTO_MAA.exe")
else:
self.info.emit(f"解压出错:{self.name}正在运行,正在等待其关闭")
logger.warning(f"解压出错:{self.name}正在运行,正在等待其关闭")
time.sleep(1)
except Exception as e:
e = str(e)
e = "\n".join([e[_ : _ + 75] for _ in range(0, len(e), 75)])
self.info.emit(f"解压更新时出错:\n{e}")
logger.exception(f"解压更新时出错:{e}")
return None
class DownloadManager(QDialog):
"""下载管理器"""
speed_test_accomplish = Signal()
download_accomplish = Signal()
download_process_clear = Signal()
isInterruptionRequested = False
def __init__(self, app_path: Path, name: str, version: list, config: dict) -> None:
super().__init__()
self.app_path = app_path
self.name = name
self.version = version
self.config = config
self.download_path = app_path / "DOWNLOAD_TEMP.zip" # 临时下载文件的路径
self.download_process_dict: Dict[str, DownloadProcess] = {}
self.timer_dict: Dict[str, QTimer] = {}
self.if_speed_test_accomplish = False
self.resize(700, 70)
setTheme(Theme.AUTO, lazy=True)
# 创建垂直布局
self.Layout = QVBoxLayout(self)
self.info = BodyLabel("正在初始化", self)
self.progress_1 = IndeterminateProgressBar(self)
self.progress_2 = ProgressBar(self)
self.update_progress(0, 0, 0)
self.Layout.addWidget(self.info)
self.Layout.addStretch(1)
self.Layout.addWidget(self.progress_1)
self.Layout.addWidget(self.progress_2)
self.Layout.addStretch(1)
def run(self) -> None:
if self.name == "AUTO_MAA":
if self.config["mode"] == "Proxy":
self.test_speed_task1()
self.speed_test_accomplish.connect(self.download_task1)
elif self.config["mode"] == "MirrorChyan":
self.download_task1()
elif self.config["mode"] == "MirrorChyan":
self.download_task1()
def get_download_url(self, mode: str) -> Union[str, Dict[str, str]]:
"""获取下载链接"""
url_dict = {}
if mode == "测速":
url_dict["GitHub站"] = (
f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
url_dict["官方镜像站"] = (
f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
for name, download_url_head in self.config["download_dict"].items():
url_dict[name] = (
f"{download_url_head}AUTO_MAA_{version_text(self.version)}.zip"
)
for proxy_url in self.config["proxy_list"]:
url_dict[proxy_url] = (
f"{proxy_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
return url_dict
elif mode == "下载":
if self.name == "AUTO_MAA":
if self.config["mode"] == "Proxy":
if "selected" in self.config:
selected_url = self.config["selected"]
elif "speed_result" in self.config:
selected_url = max(
self.config["speed_result"],
key=self.config["speed_result"].get,
)
if selected_url == "GitHub站":
return f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif selected_url == "官方镜像站":
return f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif selected_url in self.config["download_dict"].keys():
return f"{self.config["download_dict"][selected_url]}AUTO_MAA_{version_text(self.version)}.zip"
else:
return f"{selected_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif self.config["mode"] == "MirrorChyan":
with requests.get(
self.config["url"],
allow_redirects=True,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
) as response:
if response.status_code == 200:
return response.url
elif self.config["mode"] == "MirrorChyan":
with requests.get(
self.config["url"],
allow_redirects=True,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
) as response:
if response.status_code == 200:
return response.url
def test_speed_task1(self) -> None:
if self.isInterruptionRequested:
return None
url_dict = self.get_download_url("测速")
self.test_speed_result: Dict[str, float] = {}
logger.info(f"测速链接:{url_dict}")
for name, url in url_dict.items():
if self.isInterruptionRequested:
break
# 创建测速线程下载4MB文件以测试下载速度
self.download_process_dict[name] = DownloadProcess(
url,
0,
4194304,
self.app_path / f"{name.replace('/','').replace(':','')}.zip",
10,
)
self.test_speed_result[name] = -1
self.download_process_dict[name].accomplish.connect(
partial(self.test_speed_task2, name)
)
self.download_process_dict[name].start()
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(partial(self.kill_speed_test, name))
timer.start(30000)
self.timer_dict[name] = timer
self.update_info("正在测速预计用时30秒")
self.update_progress(0, 1, 0)
def kill_speed_test(self, name: str) -> None:
if name in self.download_process_dict:
self.download_process_dict[name].requestInterruption()
def test_speed_task2(self, name: str, t: float) -> None:
# 计算下载速度
if self.isInterruptionRequested:
self.update_info(f"已中止测速进程:{name}")
self.test_speed_result[name] = 0
elif t != 0:
self.update_info(f"{name}{ 4 / t:.2f} MB/s")
self.test_speed_result[name] = 4 / t
else:
self.update_info(f"{name}{ 0:.2f} MB/s")
self.test_speed_result[name] = 0
self.update_progress(
0,
len(self.test_speed_result),
sum(1 for speed in self.test_speed_result.values() if speed != -1),
)
# 删除临时文件
if (self.app_path / f"{name.replace('/','').replace(':','')}.zip").exists():
(self.app_path / f"{name.replace('/','').replace(':','')}.zip").unlink()
# 清理下载线程
self.timer_dict[name].stop()
self.timer_dict[name].deleteLater()
self.timer_dict.pop(name)
self.download_process_dict[name].requestInterruption()
self.download_process_dict[name].quit()
self.download_process_dict[name].wait()
self.download_process_dict[name].deleteLater()
self.download_process_dict.pop(name)
if not self.download_process_dict:
self.download_process_clear.emit()
# 当有速度大于1 MB/s的链接或存在3个即以上链接测速完成时停止其他测速
if not self.if_speed_test_accomplish and (
sum(1 for speed in self.test_speed_result.values() if speed > 0) >= 3
or any(speed > 1 for speed in self.test_speed_result.values())
):
self.if_speed_test_accomplish = True
for timer in self.timer_dict.values():
timer.timeout.emit()
if any(speed == -1 for _, speed in self.test_speed_result.items()):
return None
# 保存测速结果
self.config["speed_result"] = self.test_speed_result
logger.info(f"测速结果:{self.test_speed_result}")
self.update_info("测速完成!")
self.speed_test_accomplish.emit()
def download_task1(self) -> None:
if self.isInterruptionRequested:
return None
url = self.get_download_url("下载")
self.downloaded_size_list: List[List[int, bool]] = []
response = requests.head(
url,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
self.file_size = int(response.headers.get("content-length", 0))
part_size = self.file_size // self.config["thread_numb"]
self.downloaded_size = 0
self.last_download_size = 0
self.last_time = time.time()
self.speed = 0
# 拆分下载任务,启用多线程下载
for i in range(self.config["thread_numb"]):
if self.isInterruptionRequested:
break
# 计算单任务下载范围
start_byte = i * part_size
end_byte = (
(i + 1) * part_size - 1
if (i != self.config["thread_numb"] - 1)
else self.file_size - 1
)
# 创建下载子线程
self.download_process_dict[f"part{i}"] = DownloadProcess(
url,
-1 if self.config["mode"] == "MirrorChyan" else start_byte,
-1 if self.config["mode"] == "MirrorChyan" else end_byte,
self.download_path.with_suffix(f".part{i}"),
1 if self.config["mode"] == "MirrorChyan" else -1,
)
self.downloaded_size_list.append([0, False])
self.download_process_dict[f"part{i}"].progress.connect(
partial(self.download_task2, i)
)
self.download_process_dict[f"part{i}"].accomplish.connect(
partial(self.download_task3, i)
)
self.download_process_dict[f"part{i}"].start()
def download_task2(self, index: str, current: int) -> None:
"""更新下载进度"""
self.downloaded_size_list[index][0] = current
self.downloaded_size = sum([_[0] for _ in self.downloaded_size_list])
self.update_progress(0, self.file_size, self.downloaded_size)
if time.time() - self.last_time >= 1.0:
self.speed = (
(self.downloaded_size - self.last_download_size)
/ (time.time() - self.last_time)
/ 1024
)
self.last_download_size = self.downloaded_size
self.last_time = time.time()
if self.speed >= 1024:
self.update_info(
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB {self.downloaded_size / self.file_size * 100:.2f}% 下载速度:{self.speed / 1024:.2f} MB/s",
)
else:
self.update_info(
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB {self.downloaded_size / self.file_size * 100:.2f}% 下载速度:{self.speed:.2f} KB/s",
)
def download_task3(self, index: str, t: float) -> None:
# 标记下载线程完成
self.downloaded_size_list[index][1] = True
# 清理下载线程
self.download_process_dict[f"part{index}"].requestInterruption()
self.download_process_dict[f"part{index}"].quit()
self.download_process_dict[f"part{index}"].wait()
self.download_process_dict[f"part{index}"].deleteLater()
self.download_process_dict.pop(f"part{index}")
if not self.download_process_dict:
self.download_process_clear.emit()
if (
any([not _[1] for _ in self.downloaded_size_list])
or self.isInterruptionRequested
):
return None
# 合并下载的分段文件
logger.info(
f"所有分段下载完成:{self.name},开始合并分段文件到 {self.download_path}"
)
with self.download_path.open(mode="wb") as outfile:
for i in range(self.config["thread_numb"]):
with self.download_path.with_suffix(f".part{i}").open(
mode="rb"
) as infile:
outfile.write(infile.read())
self.download_path.with_suffix(f".part{i}").unlink()
logger.success(
f"合并完成:{self.name},下载文件大小:{self.download_path.stat().st_size} 字节"
)
self.update_info("正在解压更新文件")
self.update_progress(0, 0, 0)
# 创建解压线程
self.zip_extract = ZipExtractProcess(
self.name, self.app_path, self.download_path
)
self.zip_loop = QEventLoop()
self.zip_extract.info.connect(self.update_info)
self.zip_extract.accomplish.connect(self.zip_loop.quit)
self.zip_extract.start()
self.zip_loop.exec()
self.update_info("正在删除临时文件")
self.update_progress(0, 0, 0)
if (self.app_path / "changes.json").exists():
(self.app_path / "changes.json").unlink()
if self.download_path.exists():
self.download_path.unlink()
# 下载完成后打开对应程序
if not self.isInterruptionRequested and self.name == "MAA":
subprocess.Popen(
[self.app_path / "MAA.exe"],
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
| subprocess.CREATE_NO_WINDOW,
)
if self.name == "AUTO_MAA":
self.update_info(f"即将安装{self.name}")
else:
self.update_info(f"{self.name}下载成功!")
self.update_progress(0, 100, 100)
self.download_accomplish.emit()
def update_info(self, text: str) -> None:
self.info.setText(text)
def update_progress(self, begin: int, end: int, current: int) -> None:
if begin == 0 and end == 0:
self.progress_2.setVisible(False)
self.progress_1.setVisible(True)
else:
self.progress_1.setVisible(False)
self.progress_2.setVisible(True)
self.progress_2.setRange(begin, end)
self.progress_2.setValue(current)
def requestInterruption(self) -> None:
"""请求中断下载任务"""
logger.info("收到下载任务中止请求")
self.isInterruptionRequested = True
if hasattr(self, "zip_extract") and self.zip_extract:
self.zip_extract.requestInterruption()
if hasattr(self, "zip_loop") and self.zip_loop:
self.zip_loop.quit()
for process in self.download_process_dict.values():
process.requestInterruption()
if self.download_process_dict:
loop = QEventLoop()
self.download_process_clear.connect(loop.quit)
loop.exec()
def closeEvent(self, event: QCloseEvent):
"""清理残余进程"""
self.requestInterruption()
event.accept()

361
app/ui/history.py Normal file
View File

@@ -0,0 +1,361 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA历史记录界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
)
from qfluentwidgets import (
ScrollArea,
FluentIcon,
HeaderCardWidget,
PushButton,
TextBrowser,
CardWidget,
ComboBox,
ZhDatePicker,
SubtitleLabel,
)
from PySide6.QtCore import Signal, QDate
import os
import subprocess
from datetime import datetime, timedelta
from functools import partial
from pathlib import Path
from typing import List, Dict
from app.core import Config, SoundPlayer
from .Widget import StatefulItemCard, QuantifiedItemCard, QuickExpandGroupCard
class History(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("历史记录")
self.history_top_bar = self.HistoryTopBar(self)
self.history_top_bar.search_history.connect(self.reload_history)
content_widget = QWidget()
self.content_layout = QVBoxLayout(content_widget)
self.content_layout.setContentsMargins(0, 0, 11, 0)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(self.history_top_bar)
layout.addWidget(scrollArea)
self.history_card_list = []
def reload_history(self, mode: str, start_date: QDate, end_date: QDate) -> None:
"""加载历史记录界面"""
SoundPlayer.play("历史记录查询")
while self.content_layout.count() > 0:
item = self.content_layout.takeAt(0)
if item.spacerItem():
self.content_layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.history_card_list = []
history_dict = Config.search_history(
mode,
datetime(start_date.year(), start_date.month(), start_date.day()),
datetime(end_date.year(), end_date.month(), end_date.day()),
)
for date, user_dict in history_dict.items():
self.history_card_list.append(self.HistoryCard(date, user_dict, self))
self.content_layout.addWidget(self.history_card_list[-1])
self.content_layout.addStretch(1)
class HistoryTopBar(CardWidget):
"""历史记录顶部工具栏"""
search_history = Signal(str, QDate, QDate)
def __init__(self, parent=None):
super().__init__(parent)
Layout = QHBoxLayout(self)
self.lable_1 = SubtitleLabel("查询范围:")
self.start_date = ZhDatePicker()
self.start_date.setDate(QDate(2019, 5, 1))
self.lable_2 = SubtitleLabel("")
self.end_date = ZhDatePicker()
server_date = Config.server_date()
self.end_date.setDate(
QDate(server_date.year, server_date.month, server_date.day)
)
self.mode = ComboBox()
self.mode.setPlaceholderText("请选择查询模式")
self.mode.addItems(["按日合并", "按周合并", "按月合并"])
self.select_month = PushButton(FluentIcon.TAG, "最近一月")
self.select_week = PushButton(FluentIcon.TAG, "最近一周")
self.search = PushButton(FluentIcon.SEARCH, "查询")
self.select_month.clicked.connect(lambda: self.select_date("month"))
self.select_week.clicked.connect(lambda: self.select_date("week"))
self.search.clicked.connect(
lambda: self.search_history.emit(
self.mode.currentText(),
self.start_date.getDate(),
self.end_date.getDate(),
)
)
Layout.addWidget(self.lable_1)
Layout.addWidget(self.start_date)
Layout.addWidget(self.lable_2)
Layout.addWidget(self.end_date)
Layout.addWidget(self.mode)
Layout.addStretch(1)
Layout.addWidget(self.select_month)
Layout.addWidget(self.select_week)
Layout.addWidget(self.search)
def select_date(self, date: str) -> None:
"""选中最近一段时间并启动查询"""
server_date = Config.server_date()
if date == "week":
begin_date = server_date - timedelta(weeks=1)
elif date == "month":
begin_date = server_date - timedelta(days=30)
self.start_date.setDate(
QDate(begin_date.year, begin_date.month, begin_date.day)
)
self.end_date.setDate(
QDate(server_date.year, server_date.month, server_date.day)
)
self.search.clicked.emit()
class HistoryCard(QuickExpandGroupCard):
"""历史记录卡片"""
def __init__(self, date: str, user_dict: Dict[str, List[Path]], parent=None):
super().__init__(
FluentIcon.HISTORY, date, f"{date}的历史运行记录与统计信息", parent
)
widget = QWidget()
Layout = QVBoxLayout(widget)
self.viewLayout.setContentsMargins(0, 0, 0, 0)
self.viewLayout.setSpacing(0)
self.addGroupWidget(widget)
self.user_history_card_list = []
for user, info in user_dict.items():
self.user_history_card_list.append(
self.UserHistoryCard(user, info, self)
)
Layout.addWidget(self.user_history_card_list[-1])
class UserHistoryCard(HeaderCardWidget):
"""用户历史记录卡片"""
def __init__(self, name: str, user_history: List[Path], parent=None):
super().__init__(parent)
self.setTitle(name)
self.user_history = user_history
self.index_card = self.IndexCard(self.user_history, self)
self.index_card.index_changed.connect(self.update_info)
self.statistics_card = QHBoxLayout()
self.log_card = self.LogCard(self)
self.viewLayout.addWidget(self.index_card)
self.viewLayout.addLayout(self.statistics_card)
self.viewLayout.addWidget(self.log_card)
self.viewLayout.setContentsMargins(0, 0, 0, 0)
self.viewLayout.setSpacing(0)
self.viewLayout.setStretch(0, 1)
self.viewLayout.setStretch(2, 4)
self.update_info("数据总览")
def get_statistics(self, mode: str) -> dict:
"""生成GUI相应结构化统计数据"""
history_info = Config.merge_statistic_info(
self.user_history if mode == "数据总览" else [Path(mode)]
)
statistics_info = {}
if "recruit_statistics" in history_info:
statistics_info["公招统计"] = list(
history_info["recruit_statistics"].items()
)
if "drop_statistics" in history_info:
for game_id, drops in history_info["drop_statistics"].items():
statistics_info[f"掉落统计:{game_id}"] = list(drops.items())
if mode == "数据总览" and "error_info" in history_info:
statistics_info["报错汇总"] = list(
history_info["error_info"].items()
)
return statistics_info
def update_info(self, index: str) -> None:
"""更新信息"""
# 移除已有统计信息UI组件
while self.statistics_card.count() > 0:
item = self.statistics_card.takeAt(0)
if item.spacerItem():
self.statistics_card.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
if index == "数据总览":
for name, item_list in self.get_statistics("数据总览").items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
self.log_card.hide()
else:
single_history = self.get_statistics(index)
log_path = Path(index).with_suffix(".log")
for name, item_list in single_history.items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
with log_path.open("r", encoding="utf-8") as f:
log = f.read()
self.log_card.text.setText(log)
self.log_card.open_file.clicked.disconnect()
self.log_card.open_file.clicked.connect(
lambda: os.startfile(log_path)
)
self.log_card.open_dir.clicked.disconnect()
self.log_card.open_dir.clicked.connect(
lambda: subprocess.Popen(["explorer", "/select,", log_path])
)
self.log_card.show()
self.viewLayout.setStretch(1, self.statistics_card.count())
self.setMinimumHeight(300)
class IndexCard(HeaderCardWidget):
index_changed = Signal(str)
def __init__(self, history_list: List[Path], parent=None):
super().__init__(parent)
self.setTitle("记录条目")
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.index_cards: List[StatefulItemCard] = []
index_list = Config.merge_statistic_info(history_list)["index"]
index_list.insert(0, ["数据总览", "运行", "数据总览"])
for index in index_list:
self.index_cards.append(StatefulItemCard(index[:2]))
self.index_cards[-1].clicked.connect(
partial(self.index_changed.emit, str(index[2]))
)
self.Layout.addWidget(self.index_cards[-1])
self.Layout.addStretch(1)
class StatisticsCard(HeaderCardWidget):
def __init__(self, name: str, item_list: list, parent=None):
super().__init__(parent)
self.setTitle(name)
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.item_cards: List[QuantifiedItemCard] = []
for item in item_list:
self.item_cards.append(QuantifiedItemCard(item))
self.Layout.addWidget(self.item_cards[-1])
if len(item_list) == 0:
self.Layout.addWidget(QuantifiedItemCard(["暂无记录", ""]))
self.Layout.addStretch(1)
class LogCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("日志")
self.text = TextBrowser(self)
self.open_file = PushButton("打开日志文件", self)
self.open_file.clicked.connect(lambda: print("打开日志文件"))
self.open_dir = PushButton("打开所在目录", self)
self.open_dir.clicked.connect(lambda: print("打开所在文件"))
Layout = QVBoxLayout()
h_layout = QHBoxLayout()
h_layout.addWidget(self.open_file)
h_layout.addWidget(self.open_dir)
Layout.addWidget(self.text)
Layout.addLayout(h_layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.viewLayout.addLayout(Layout)

410
app/ui/home.py Normal file
View File

@@ -0,0 +1,410 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QSpacerItem,
QSizePolicy,
QFileDialog,
)
from PySide6.QtCore import Qt, QSize, QUrl
from PySide6.QtGui import QDesktopServices, QColor
from qfluentwidgets import (
FluentIcon,
ScrollArea,
SimpleCardWidget,
PrimaryToolButton,
TextBrowser,
)
import re
import shutil
import json
from datetime import datetime
from pathlib import Path
from app.core import Config, MainInfoBar, Network
from .Widget import Banner, IconButton
class Home(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("主页")
self.banner = Banner()
self.banner_text = TextBrowser()
v_layout = QVBoxLayout(self.banner)
v_layout.setContentsMargins(0, 0, 0, 15)
v_layout.setSpacing(5)
v_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 空白占位符
v_layout.addItem(
QSpacerItem(10, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 顶部部分 (按钮组)
h1_layout = QHBoxLayout()
h1_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 左边留白区域
h1_layout.addStretch()
# 按钮组
buttonGroup = ButtonGroup()
buttonGroup.setMaximumHeight(320)
h1_layout.addWidget(buttonGroup)
# 空白占位符
h1_layout.addItem(
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 将顶部水平布局添加到垂直布局
v_layout.addLayout(h1_layout)
# 中间留白区域
v_layout.addItem(
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
v_layout.addStretch()
# 中间留白区域
v_layout.addItem(
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
v_layout.addStretch()
# 底部部分 (图片切换按钮)
h2_layout = QHBoxLayout()
h2_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 左边留白区域
h2_layout.addItem(
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# # 公告卡片
# noticeCard = NoticeCard()
# h2_layout.addWidget(noticeCard)
h2_layout.addStretch()
# 自定义图像按钮布局
self.imageButton = PrimaryToolButton(FluentIcon.IMAGE_EXPORT)
self.imageButton.setFixedSize(56, 56)
self.imageButton.setIconSize(QSize(32, 32))
self.imageButton.clicked.connect(self.get_home_image)
v1_layout = QVBoxLayout()
v1_layout.addWidget(self.imageButton, alignment=Qt.AlignmentFlag.AlignBottom)
h2_layout.addLayout(v1_layout)
# 空白占位符
h2_layout.addItem(
QSpacerItem(25, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 将底部水平布局添加到垂直布局
v_layout.addLayout(h2_layout)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(self.banner)
content_layout.addWidget(self.banner_text)
content_layout.setStretch(0, 2)
content_layout.setStretch(1, 3)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
self.set_banner()
def get_home_image(self) -> None:
"""获取主页图片"""
if Config.get(Config.function_HomeImageMode) == "默认":
pass
elif Config.get(Config.function_HomeImageMode) == "自定义":
file_path, _ = QFileDialog.getOpenFileName(
self, "打开自定义主页图片", "", "图片文件 (*.png *.jpg *.bmp)"
)
if file_path:
for file in Config.app_path.glob(
"resources/images/Home/BannerCustomize.*"
):
file.unlink()
shutil.copy(
file_path,
Config.app_path
/ f"resources/images/Home/BannerCustomize{Path(file_path).suffix}",
)
logger.info(f"自定义主页图片更换成功:{file_path}")
MainInfoBar.push_info_bar(
"success",
"主页图片更换成功",
"自定义主页图片更换成功!",
3000,
)
else:
logger.warning("自定义主页图片更换失败:未选择图片文件")
MainInfoBar.push_info_bar(
"warning",
"主页图片更换失败",
"未选择图片文件!",
5000,
)
elif Config.get(Config.function_HomeImageMode) == "主题图像":
# 从远程服务器获取最新主题图像
network = Network.add_task(
mode="get",
url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/theme_image.json",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
theme_image = network_result["response_json"]
else:
logger.warning(
f"获取最新主题图像时出错:{network_result['error_message']}"
)
MainInfoBar.push_info_bar(
"warning",
"获取最新主题图像时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
return None
if (Config.app_path / "resources/theme_image.json").exists():
with (Config.app_path / "resources/theme_image.json").open(
mode="r", encoding="utf-8"
) as f:
theme_image_local = json.load(f)
time_local = datetime.strptime(
theme_image_local["time"], "%Y-%m-%d %H:%M"
)
else:
time_local = datetime.strptime("2000-01-01 00:00", "%Y-%m-%d %H:%M")
if not (
Config.app_path / "resources/images/Home/BannerTheme.jpg"
).exists() or (
datetime.now()
> datetime.strptime(theme_image["time"], "%Y-%m-%d %H:%M")
> time_local
):
network = Network.add_task(
mode="get_file",
url=theme_image["url"],
path=Config.app_path / "resources/images/Home/BannerTheme.jpg",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
with (Config.app_path / "resources/theme_image.json").open(
mode="w", encoding="utf-8"
) as f:
json.dump(theme_image, f, ensure_ascii=False, indent=4)
logger.success(f"主题图像「{theme_image["name"]}」下载成功")
MainInfoBar.push_info_bar(
"success",
"主题图像下载成功",
f"{theme_image["name"]}」下载成功!",
3000,
)
else:
logger.warning(
f"下载最新主题图像时出错:{network_result['error_message']}"
)
MainInfoBar.push_info_bar(
"warning",
"下载最新主题图像时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
else:
logger.info("主题图像已是最新")
MainInfoBar.push_info_bar(
"info",
"主题图像已是最新",
"主题图像已是最新!",
3000,
)
self.set_banner()
def set_banner(self):
"""设置主页图像"""
if Config.get(Config.function_HomeImageMode) == "默认":
self.banner.set_banner_image(
str(Config.app_path / "resources/images/Home/BannerDefault.png")
)
self.imageButton.hide()
self.banner_text.setVisible(False)
elif Config.get(Config.function_HomeImageMode) == "自定义":
for file in Config.app_path.glob("resources/images/Home/BannerCustomize.*"):
self.banner.set_banner_image(str(file))
break
self.imageButton.show()
self.banner_text.setVisible(False)
elif Config.get(Config.function_HomeImageMode) == "主题图像":
self.banner.set_banner_image(
str(Config.app_path / "resources/images/Home/BannerTheme.jpg")
)
self.imageButton.show()
self.banner_text.setVisible(True)
if (Config.app_path / "resources/theme_image.json").exists():
with (Config.app_path / "resources/theme_image.json").open(
mode="r", encoding="utf-8"
) as f:
theme_image = json.load(f)
html_content = theme_image["html"]
else:
html_content = "<h1>主题图像</h1><p>主题图像信息未知</p>"
self.banner_text.setHtml(re.sub(r"<img[^>]*>", "", html_content))
class ButtonGroup(SimpleCardWidget):
"""显示主页和 GitHub 按钮的竖直按钮组"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setFixedSize(56, 180)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 创建主页按钮
home_button = IconButton(
FluentIcon.HOME.icon(color=QColor("#fff")),
tip_title="AUTO_MAA官网",
tip_content="AUTO_MAA官方文档站",
isTooltip=True,
)
home_button.setIconSize(QSize(32, 32))
home_button.clicked.connect(self.open_home)
layout.addWidget(home_button)
# 创建 GitHub 按钮
github_button = IconButton(
FluentIcon.GITHUB.icon(color=QColor("#fff")),
tip_title="Github仓库",
tip_content="如果本项目有帮助到您~\n不妨给项目点一个Star⭐",
isTooltip=True,
)
github_button.setIconSize(QSize(32, 32))
github_button.clicked.connect(self.open_github)
layout.addWidget(github_button)
# # 创建 文档 按钮
# doc_button = IconButton(
# FluentIcon.DICTIONARY.icon(color=QColor("#fff")),
# tip_title="自助排障文档",
# tip_content="点击打开自助排障文档,好孩子都能看懂",
# isTooltip=True,
# )
# doc_button.setIconSize(QSize(32, 32))
# doc_button.clicked.connect(self.open_doc)
# layout.addWidget(doc_button)
# 创建 Q群 按钮
doc_button = IconButton(
FluentIcon.CHAT.icon(color=QColor("#fff")),
tip_title="官方社群",
tip_content="加入官方群聊【AUTO_MAA绝赞DeBug中",
isTooltip=True,
)
doc_button.setIconSize(QSize(32, 32))
doc_button.clicked.connect(self.open_chat)
layout.addWidget(doc_button)
# 创建 MirrorChyan 按钮
doc_button = IconButton(
FluentIcon.SHOPPING_CART.icon(color=QColor("#fff")),
tip_title="非官方店铺",
tip_content="获取 MirrorChyan CDK更新快人一步",
isTooltip=True,
)
doc_button.setIconSize(QSize(32, 32))
doc_button.clicked.connect(self.open_sales)
layout.addWidget(doc_button)
def _normalBackgroundColor(self):
return QColor(0, 0, 0, 96)
def open_home(self):
"""打开主页链接"""
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
def open_github(self):
"""打开 GitHub 链接"""
QDesktopServices.openUrl(QUrl("https://github.com/DLmaster361/AUTO_MAA"))
def open_chat(self):
"""打开 Q群 链接"""
QDesktopServices.openUrl(QUrl("https://qm.qq.com/q/bd9fISNoME"))
def open_doc(self):
"""打开 文档 链接"""
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
def open_sales(self):
"""打开 MirrorChyan 链接"""
QDesktopServices.openUrl(
QUrl("https://mirrorchyan.com/zh/get-start?source=auto_maa-home")
)

508
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,508 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import QApplication, QSystemTrayIcon
from qfluentwidgets import (
Action,
SystemTrayMenu,
SplashScreen,
FluentIcon,
setTheme,
isDarkTheme,
SystemThemeListener,
Theme,
MSFluentWindow,
NavigationItemPosition,
)
from PySide6.QtGui import QIcon, QCloseEvent
from PySide6.QtCore import QTimer
from datetime import datetime, timedelta
import shutil
import darkdetect
from app.core import Config, TaskManager, MainTimer, MainInfoBar, SoundPlayer
from app.services import Notify, Crypto, System
from .home import Home
from .member_manager import MemberManager
from .plan_manager import PlanManager
from .queue_manager import QueueManager
from .dispatch_center import DispatchCenter
from .history import History
from .setting import Setting
class AUTO_MAA(MSFluentWindow):
def __init__(self):
super().__init__()
self.setWindowIcon(QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")))
version_numb = list(map(int, Config.VERSION.split(".")))
version_text = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
if version_numb[3] == 0
else f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
self.setWindowTitle(f"AUTO_MAA - {version_text}")
self.switch_theme()
self.splashScreen = SplashScreen(self.windowIcon(), self)
self.show_ui("显示主窗口", if_quick=True)
Config.main_window = self.window()
# 创建主窗口
self.home = Home(self)
self.plan_manager = PlanManager(self)
self.member_manager = MemberManager(self)
self.queue_manager = QueueManager(self)
self.dispatch_center = DispatchCenter(self)
self.history = History(self)
self.setting = Setting(self)
self.addSubInterface(
self.home,
FluentIcon.HOME,
"主页",
FluentIcon.HOME,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.member_manager,
FluentIcon.ROBOT,
"脚本管理",
FluentIcon.ROBOT,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.plan_manager,
FluentIcon.CALENDAR,
"计划管理",
FluentIcon.CALENDAR,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.queue_manager,
FluentIcon.BOOK_SHELF,
"调度队列",
FluentIcon.BOOK_SHELF,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.dispatch_center,
FluentIcon.IOT,
"调度中心",
FluentIcon.IOT,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.history,
FluentIcon.HISTORY,
"历史记录",
FluentIcon.HISTORY,
NavigationItemPosition.BOTTOM,
)
self.addSubInterface(
self.setting,
FluentIcon.SETTING,
"设置",
FluentIcon.SETTING,
NavigationItemPosition.BOTTOM,
)
self.stackedWidget.currentChanged.connect(self.__currentChanged)
# 创建系统托盘及其菜单
self.tray = QSystemTrayIcon(
QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")), self
)
self.tray.setToolTip("AUTO_MAA")
self.tray_menu = SystemTrayMenu("AUTO_MAA", self)
# 显示主界面菜单项
self.tray_menu.addAction(
Action(
FluentIcon.CAFE,
"显示主界面",
triggered=lambda: self.show_ui("显示主窗口"),
)
)
self.tray_menu.addSeparator()
# 开始任务菜单项
self.tray_menu.addActions(
[
Action(FluentIcon.PLAY, "运行自动代理", triggered=self.start_main_task),
Action(
FluentIcon.PAUSE,
"中止所有任务",
triggered=lambda: TaskManager.stop_task("ALL"),
),
]
)
self.tray_menu.addSeparator()
# 退出主程序菜单项
self.tray_menu.addAction(
Action(
FluentIcon.POWER_BUTTON,
"退出主程序",
triggered=lambda: System.set_power("KillSelf"),
)
)
# 设置托盘菜单
self.tray.setContextMenu(self.tray_menu)
self.tray.activated.connect(self.on_tray_activated)
self.set_min_method()
Config.sub_info_changed.connect(self.member_manager.refresh_dashboard)
Config.power_sign_changed.connect(self.dispatch_center.update_power_sign)
TaskManager.create_gui.connect(self.dispatch_center.add_board)
TaskManager.connect_gui.connect(self.dispatch_center.connect_main_board)
Notify.push_info_bar.connect(MainInfoBar.push_info_bar)
self.setting.ui.card_IfShowTray.checkedChanged.connect(
lambda: self.show_ui("配置托盘")
)
self.setting.ui.card_IfToTray.checkedChanged.connect(self.set_min_method)
self.setting.function.card_HomeImageMode.comboBox.currentIndexChanged.connect(
lambda index: (
self.home.get_home_image() if index == 2 else self.home.set_banner()
)
)
self.splashScreen.finish()
self.themeListener = SystemThemeListener(self)
self.themeListener.systemThemeChanged.connect(self.switch_theme)
self.themeListener.start()
def switch_theme(self) -> None:
"""切换主题"""
setTheme(
Theme(darkdetect.theme()) if darkdetect.theme() else Theme.LIGHT, lazy=True
)
QTimer.singleShot(300, lambda: setTheme(Theme.AUTO, lazy=True))
# 云母特效启用时需要增加重试机制
# 云母特效不兼容Win10,如果True则通过云母进行主题转换,False则根据当前主题设置背景颜色
if self.isMicaEffectEnabled():
QTimer.singleShot(
300,
lambda: self.windowEffect.setMicaEffect(self.winId(), isDarkTheme()),
)
else:
# 根据当前主题设置背景颜色
if isDarkTheme():
self.setStyleSheet(
"""
CardWidget {background-color: #313131;}
HeaderCardWidget {background-color: #313131;}
background-color: #313131;
"""
)
else:
self.setStyleSheet("background-color: #ffffff;")
def set_min_method(self) -> None:
"""设置最小化方法"""
if Config.get(Config.ui_IfToTray):
self.titleBar.minBtn.clicked.disconnect()
self.titleBar.minBtn.clicked.connect(lambda: self.show_ui("隐藏到托盘"))
else:
self.titleBar.minBtn.clicked.disconnect()
self.titleBar.minBtn.clicked.connect(self.window().showMinimized)
def on_tray_activated(self, reason):
"""双击返回主界面"""
if reason == QSystemTrayIcon.DoubleClick:
self.show_ui("显示主窗口")
def show_ui(
self, mode: str, if_quick: bool = False, if_start: bool = False
) -> None:
"""配置窗口状态"""
if Config.args.mode != "gui":
return None
self.switch_theme()
if mode == "显示主窗口":
# 配置主窗口
if not self.window().isVisible():
size = list(
map(
int,
Config.get(Config.ui_size).split("x"),
)
)
location = list(
map(
int,
Config.get(Config.ui_location).split("x"),
)
)
if self.window().isMaximized():
self.window().showNormal()
self.window().setGeometry(location[0], location[1], size[0], size[1])
self.window().show()
if not if_quick:
if (
Config.get(Config.ui_maximized)
and not self.window().isMaximized()
):
self.titleBar.maxBtn.click()
SoundPlayer.play("欢迎回来")
self.show_ui("配置托盘")
elif if_start:
if Config.get(Config.ui_maximized) and not self.window().isMaximized():
self.titleBar.maxBtn.click()
self.show_ui("配置托盘")
# 如果窗口不在屏幕内,则重置窗口位置
if not any(
self.window().geometry().intersects(screen.availableGeometry())
for screen in QApplication.screens()
):
self.window().showNormal()
self.window().setGeometry(100, 100, 1200, 700)
self.window().raise_()
self.window().activateWindow()
while Config.info_bar_list:
info_bar_item = Config.info_bar_list.pop(0)
MainInfoBar.push_info_bar(
info_bar_item["mode"],
info_bar_item["title"],
info_bar_item["content"],
info_bar_item["time"],
)
elif mode == "配置托盘":
if Config.get(Config.ui_IfShowTray):
self.tray.show()
else:
self.tray.hide()
elif mode == "隐藏到托盘":
# 保存窗口相关属性
if not self.window().isMaximized():
Config.set(
Config.ui_size,
f"{self.geometry().width()}x{self.geometry().height()}",
)
Config.set(
Config.ui_location,
f"{self.geometry().x()}x{self.geometry().y()}",
)
Config.set(Config.ui_maximized, self.window().isMaximized())
Config.save()
# 隐藏主窗口
if not if_quick:
self.window().hide()
self.tray.show()
def start_up_task(self) -> None:
"""启动时任务"""
# 清理旧日志
self.clean_old_logs()
# 清理安装包
if (Config.app_path / "AUTO_MAA-Setup.exe").exists():
try:
(Config.app_path / "AUTO_MAA-Setup.exe").unlink()
except Exception:
pass
# 检查密码
self.setting.check_PASSWORD()
# 获取关卡号信息
Config.get_stage()
# 获取主题图像
if Config.get(Config.function_HomeImageMode) == "主题图像":
self.home.get_home_image()
# 直接运行主任务
if Config.get(Config.start_IfRunDirectly):
self.start_main_task()
# 获取公告
self.setting.show_notice(if_first=True)
# 检查更新
if Config.get(Config.update_IfAutoUpdate):
self.setting.check_update(if_first=True)
# 直接最小化
if Config.get(Config.start_IfMinimizeDirectly):
self.titleBar.minBtn.click()
if Config.args.config:
for config in [_ for _ in Config.args.config if _ in Config.queue_dict]:
TaskManager.add_task(
"自动代理_新调度台",
config,
Config.queue_dict["调度队列_1"]["Config"].toDict(),
)
for config in [_ for _ in Config.args.config if _ in Config.member_dict]:
TaskManager.add_task(
"自动代理_新调度台",
"自定义队列",
{"Queue": {"Member_1": config}},
)
if not any(
_ in (list(Config.member_dict.keys()) + list(Config.queue_dict.keys()))
for _ in Config.args.config
):
logger.warning(
"当前运行模式为命令行模式,由于您使用了错误的 --config 参数进行配置,程序自动退出"
)
System.set_power("KillSelf")
elif Config.args.mode == "cli":
logger.warning(
"当前运行模式为命令行模式,由于您未使用 --config 参数进行配置,程序自动退出"
)
System.set_power("KillSelf")
def clean_old_logs(self):
"""
删除超过用户设定天数的日志文件(基于目录日期)
"""
if Config.get(Config.function_HistoryRetentionTime) == 0:
logger.info("由于用户设置日志永久保留,跳过日志清理")
return
deleted_count = 0
for date_folder in (Config.app_path / "history").iterdir():
if not date_folder.is_dir():
continue # 只处理日期文件夹
try:
# 只检查 `YYYY-MM-DD` 格式的文件夹
folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d")
if datetime.now() - folder_date > timedelta(
days=Config.get(Config.function_HistoryRetentionTime)
):
shutil.rmtree(date_folder, ignore_errors=True)
deleted_count += 1
logger.info(f"已删除超期日志目录: {date_folder}")
except ValueError:
logger.warning(f"非日期格式的目录: {date_folder}")
logger.info(f"清理完成: {deleted_count} 个日期目录")
def start_main_task(self) -> None:
"""启动主任务"""
if "调度队列_1" in Config.queue_dict:
logger.info("自动添加任务调度队列_1")
TaskManager.add_task(
"自动代理_主调度台",
"调度队列_1",
Config.queue_dict["调度队列_1"]["Config"].toDict(),
)
elif "脚本_1" in Config.member_dict:
logger.info("自动添加任务脚本_1")
TaskManager.add_task(
"自动代理_主调度台", "自定义队列", {"Queue": {"Member_1": "脚本_1"}}
)
else:
logger.warning("启动主任务失败:未找到有效的主任务配置文件")
MainInfoBar.push_info_bar(
"warning", "启动主任务失败", "「调度队列_1」与「脚本_1」均不存在", -1
)
def __currentChanged(self, index: int) -> None:
"""切换界面时任务"""
if index == 1:
self.member_manager.reload_plan_name()
elif index == 3:
self.queue_manager.reload_member_name()
elif index == 4:
self.dispatch_center.pivot.setCurrentItem("主调度台")
self.dispatch_center.update_top_bar()
def closeEvent(self, event: QCloseEvent):
"""清理残余进程"""
self.show_ui("隐藏到托盘", if_quick=True)
# 清理各功能线程
MainTimer.Timer.stop()
MainTimer.Timer.deleteLater()
MainTimer.LongTimer.stop()
MainTimer.LongTimer.deleteLater()
TaskManager.stop_task("ALL")
# 关闭主题监听
self.themeListener.terminate()
self.themeListener.deleteLater()
logger.info("AUTO_MAA主程序关闭")
logger.info("----------------END----------------")
event.accept()

3469
app/ui/member_manager.py Normal file

File diff suppressed because it is too large Load Diff

498
app/ui/plan_manager.py Normal file
View File

@@ -0,0 +1,498 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA计划管理界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHeaderView,
)
from qfluentwidgets import (
Action,
FluentIcon,
MessageBox,
HeaderCardWidget,
CommandBar,
TableWidget,
)
from typing import List, Dict, Union
import shutil
from app.core import Config, MainInfoBar, MaaPlanConfig, SoundPlayer
from .Widget import (
ComboBoxMessageBox,
LineEditSettingCard,
ComboBoxSettingCard,
SpinBoxSetting,
EditableComboBoxSetting,
ComboBoxSetting,
PivotArea,
)
class PlanManager(QWidget):
"""计划管理父界面"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("计划管理")
layout = QVBoxLayout(self)
self.tools = CommandBar()
self.plan_manager = self.PlanSettingBox(self)
# 逐个添加动作
self.tools.addActions(
[
Action(FluentIcon.ADD_TO, "新建计划表", triggered=self.add_setting_box),
Action(
FluentIcon.REMOVE_FROM, "删除计划表", triggered=self.del_setting_box
),
]
)
self.tools.addSeparator()
self.tools.addActions(
[
Action(
FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_setting_box
),
Action(
FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box
),
]
)
self.tools.addSeparator()
layout.addWidget(self.tools)
layout.addWidget(self.plan_manager)
def add_setting_box(self):
"""添加一个计划表"""
choice = ComboBoxMessageBox(
self.window(),
"选择一个计划类型以添加相应计划表",
["选择计划类型"],
[["MAA"]],
)
if choice.exec() and choice.input[0].currentIndex() != -1:
if choice.input[0].currentText() == "MAA":
index = len(Config.plan_dict) + 1
maa_plan_config = MaaPlanConfig()
maa_plan_config.load(
Config.app_path / f"config/MaaPlanConfig/计划_{index}/config.json",
maa_plan_config,
)
maa_plan_config.save()
Config.plan_dict[f"计划_{index}"] = {
"Type": "Maa",
"Path": Config.app_path / f"config/MaaPlanConfig/计划_{index}",
"Config": maa_plan_config,
}
self.plan_manager.add_MaaPlanSettingBox(index)
self.plan_manager.switch_SettingBox(index)
logger.success(f"计划管理 计划_{index} 添加成功")
MainInfoBar.push_info_bar(
"success", "操作成功", f"添加计划表 计划_{index}", 3000
)
SoundPlayer.play("添加计划表")
def del_setting_box(self):
"""删除一个计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("删除计划表时未选择计划表")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("删除计划表时调度队列未停止运行")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
choice = MessageBox("确认", f"确定要删除 {name} 吗?", self.window())
if choice.exec():
self.plan_manager.clear_SettingBox()
shutil.rmtree(Config.plan_dict[name]["Path"])
Config.change_plan(name, "固定")
for i in range(int(name[3:]) + 1, len(Config.plan_dict) + 1):
if Config.plan_dict[f"计划_{i}"]["Path"].exists():
Config.plan_dict[f"计划_{i}"]["Path"].rename(
Config.plan_dict[f"计划_{i}"]["Path"].with_name(f"计划_{i-1}")
)
Config.change_plan(f"计划_{i}", f"计划_{i-1}")
self.plan_manager.show_SettingBox(max(int(name[3:]) - 1, 1))
logger.success(f"计划表 {name} 删除成功")
MainInfoBar.push_info_bar("success", "操作成功", f"删除计划表 {name}", 3000)
SoundPlayer.play("删除计划表")
def left_setting_box(self):
"""向左移动计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("向左移动计划表时未选择计划表")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
index = int(name[3:])
if index == 1:
logger.warning("向左移动计划表时已到达最左端")
MainInfoBar.push_info_bar(
"warning", "已经是第一个计划表", "无法向左移动", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("向左移动计划表时调度队列未停止运行")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
self.plan_manager.clear_SettingBox()
Config.plan_dict[name]["Path"].rename(
Config.plan_dict[name]["Path"].with_name("计划_0")
)
Config.change_plan(name, "计划_0")
Config.plan_dict[f"计划_{index-1}"]["Path"].rename(
Config.plan_dict[name]["Path"]
)
Config.change_plan(f"计划_{index-1}", name)
Config.plan_dict[name]["Path"].with_name("计划_0").rename(
Config.plan_dict[f"计划_{index-1}"]["Path"]
)
Config.change_plan("计划_0", f"计划_{index-1}")
self.plan_manager.show_SettingBox(index - 1)
logger.success(f"计划表 {name} 左移成功")
MainInfoBar.push_info_bar("success", "操作成功", f"左移计划表 {name}", 3000)
def right_setting_box(self):
"""向右移动计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("向右移动计划表时未选择计划表")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
index = int(name[3:])
if index == len(Config.plan_dict):
logger.warning("向右移动计划表时已到达最右端")
MainInfoBar.push_info_bar(
"warning", "已经是最后一个计划表", "无法向右移动", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("向右移动计划表时调度队列未停止运行")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
self.plan_manager.clear_SettingBox()
Config.plan_dict[name]["Path"].rename(
Config.plan_dict[name]["Path"].with_name("计划_0")
)
Config.change_plan(name, "计划_0")
Config.plan_dict[f"计划_{index+1}"]["Path"].rename(
Config.plan_dict[name]["Path"]
)
Config.change_plan(f"计划_{index+1}", name)
Config.plan_dict[name]["Path"].with_name("计划_0").rename(
Config.plan_dict[f"计划_{index+1}"]["Path"]
)
Config.change_plan("计划_0", f"计划_{index+1}")
self.plan_manager.show_SettingBox(index + 1)
logger.success(f"计划表 {name} 右移成功")
MainInfoBar.push_info_bar("success", "操作成功", f"右移计划表 {name}", 3000)
class PlanSettingBox(QWidget):
"""计划管理子页面组"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("计划管理页面组")
self.pivotArea = PivotArea(self)
self.pivot = self.pivotArea.pivot
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: List[PlanManager.PlanSettingBox.MaaPlanSettingBox] = []
self.Layout = QVBoxLayout(self)
self.Layout.addWidget(self.pivotArea)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.switch_SettingBox(
int(index[3:]), if_chang_pivot=False
)
)
self.show_SettingBox(1)
def show_SettingBox(self, index) -> None:
"""加载所有子界面"""
Config.search_plan()
for name, info in Config.plan_dict.items():
if info["Type"] == "Maa":
self.add_MaaPlanSettingBox(int(name[3:]))
self.switch_SettingBox(index)
def switch_SettingBox(self, index: int, if_chang_pivot: bool = True) -> None:
"""切换到指定的子界面"""
if len(Config.plan_dict) == 0:
return None
if index > len(Config.plan_dict):
return None
if if_chang_pivot:
self.pivot.setCurrentItem(self.script_list[index - 1].objectName())
self.stackedWidget.setCurrentWidget(self.script_list[index - 1])
def clear_SettingBox(self) -> None:
"""清空所有子界面"""
for sub_interface in self.script_list:
Config.stage_refreshed.disconnect(sub_interface.refresh_stage)
self.stackedWidget.removeWidget(sub_interface)
sub_interface.deleteLater()
self.script_list.clear()
self.pivot.clear()
def add_MaaPlanSettingBox(self, uid: int) -> None:
"""添加一个MAA设置界面"""
maa_plan_setting_box = self.MaaPlanSettingBox(uid, self)
self.script_list.append(maa_plan_setting_box)
self.stackedWidget.addWidget(self.script_list[-1])
self.pivot.addItem(routeKey=f"计划_{uid}", text=f"计划 {uid}")
class MaaPlanSettingBox(HeaderCardWidget):
"""MAA类计划设置界面"""
def __init__(self, uid: int, parent=None):
super().__init__(parent)
self.setObjectName(f"计划_{uid}")
self.setTitle("MAA计划表")
self.config = Config.plan_dict[f"计划_{uid}"]["Config"]
self.card_Name = LineEditSettingCard(
icon=FluentIcon.EDIT,
title="计划表名称",
content="用于标识计划表的名称",
text="请输入计划表名称",
qconfig=self.config,
configItem=self.config.Info_Name,
parent=self,
)
self.card_Mode = ComboBoxSettingCard(
icon=FluentIcon.DICTIONARY,
title="计划模式",
content="全局模式下计划内容固定,周计划模式下计划按周一到周日切换",
texts=["全局", "周计划"],
qconfig=self.config,
configItem=self.config.Info_Mode,
parent=self,
)
self.table = TableWidget(self)
self.table.setColumnCount(8)
self.table.setRowCount(7)
self.table.setHorizontalHeaderLabels(
["全局", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]
)
self.table.setVerticalHeaderLabels(
[
"吃理智药",
"连战次数",
"关卡选择",
"备选 - 1",
"备选 - 2",
"备选 - 3",
"剩余理智",
]
)
self.table.setAlternatingRowColors(False)
self.table.setEditTriggers(TableWidget.NoEditTriggers)
for col in range(8):
self.table.horizontalHeader().setSectionResizeMode(
col, QHeaderView.ResizeMode.Stretch
)
for row in range(7):
self.table.verticalHeader().setSectionResizeMode(
row, QHeaderView.ResizeMode.ResizeToContents
)
self.item_dict: Dict[
str,
Dict[
str,
Union[SpinBoxSetting, ComboBoxSetting, EditableComboBoxSetting],
],
] = {}
for col, (group, name_dict) in enumerate(
self.config.config_item_dict.items()
):
self.item_dict[group] = {}
for row, (name, configItem) in enumerate(name_dict.items()):
if name == "MedicineNumb":
self.item_dict[group][name] = SpinBoxSetting(
range=(0, 1024),
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif name == "SeriesNumb":
self.item_dict[group][name] = ComboBoxSetting(
texts=["AUTO", "6", "5", "4", "3", "2", "1", "不选择"],
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif name == "Stage_Remain":
self.item_dict[group][name] = EditableComboBoxSetting(
value=Config.stage_dict[group]["value"],
texts=[
"不使用" if _ == "当前/上次" else _
for _ in Config.stage_dict[group]["text"]
],
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif "Stage" in name:
self.item_dict[group][name] = EditableComboBoxSetting(
value=Config.stage_dict[group]["value"],
texts=Config.stage_dict[group]["text"],
qconfig=self.config,
configItem=configItem,
parent=self,
)
self.table.setCellWidget(row, col, self.item_dict[group][name])
Layout = QVBoxLayout()
Layout.addWidget(self.card_Name)
Layout.addWidget(self.card_Mode)
Layout.addWidget(self.table)
self.viewLayout.addLayout(Layout)
self.viewLayout.setSpacing(3)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.card_Mode.comboBox.currentIndexChanged.connect(self.switch_mode)
Config.stage_refreshed.connect(self.refresh_stage)
self.switch_mode()
def switch_mode(self) -> None:
"""切换计划模式"""
for group, name_dict in self.item_dict.items():
for name, setting_item in name_dict.items():
setting_item.setEnabled(
(group == "ALL")
== (self.config.get(self.config.Info_Mode) == "ALL")
)
def refresh_stage(self):
for group, name_dict in self.item_dict.items():
for name, setting_item in name_dict.items():
if name == "Stage_Remain":
setting_item.reLoadOptions(
Config.stage_dict[group]["value"],
[
"不使用" if _ == "当前/上次" else _
for _ in Config.stage_dict[group]["text"]
],
)
elif "Stage" in name:
setting_item.reLoadOptions(
Config.stage_dict[group]["value"],
Config.stage_dict[group]["text"],
)

709
app/ui/queue_manager.py Normal file
View File

@@ -0,0 +1,709 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA调度队列界面
v4.4
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
Action,
ScrollArea,
FluentIcon,
MessageBox,
HeaderCardWidget,
CommandBar,
)
from typing import List
from app.core import QueueConfig, Config, MainInfoBar, SoundPlayer
from .Widget import (
SwitchSettingCard,
ComboBoxSettingCard,
LineEditSettingCard,
TimeEditSettingCard,
NoOptionComboBoxSettingCard,
HistoryCard,
PivotArea,
)
class QueueManager(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度队列")
layout = QVBoxLayout(self)
self.tools = CommandBar()
self.queue_manager = self.QueueSettingBox(self)
# 逐个添加动作
self.tools.addActions(
[
Action(
FluentIcon.ADD_TO, "新建调度队列", triggered=self.add_setting_box
),
Action(
FluentIcon.REMOVE_FROM,
"删除调度队列",
triggered=self.del_setting_box,
),
]
)
self.tools.addSeparator()
self.tools.addActions(
[
Action(
FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_setting_box
),
Action(
FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box
),
]
)
layout.addWidget(self.tools)
layout.addWidget(self.queue_manager)
def add_setting_box(self):
"""添加一个调度队列"""
index = len(Config.queue_dict) + 1
queue_config = QueueConfig()
queue_config.load(
Config.app_path / f"config/QueueConfig/调度队列_{index}.json", queue_config
)
queue_config.save()
Config.queue_dict[f"调度队列_{index}"] = {
"Path": Config.app_path / f"config/QueueConfig/调度队列_{index}.json",
"Config": queue_config,
}
self.queue_manager.add_SettingBox(index)
self.queue_manager.switch_SettingBox(index)
logger.success(f"调度队列_{index} 添加成功")
MainInfoBar.push_info_bar("success", "操作成功", f"添加 调度队列_{index}", 3000)
SoundPlayer.play("添加调度队列")
def del_setting_box(self):
"""删除一个调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
if name in Config.running_list:
logger.warning("调度队列正在运行")
MainInfoBar.push_info_bar(
"warning", "调度队列正在运行", "请先停止调度队列", 5000
)
return None
choice = MessageBox("确认", f"确定要删除 {name} 吗?", self.window())
if choice.exec():
self.queue_manager.clear_SettingBox()
Config.queue_dict[name]["Path"].unlink()
for i in range(int(name[5:]) + 1, len(Config.queue_dict) + 1):
if Config.queue_dict[f"调度队列_{i}"]["Path"].exists():
Config.queue_dict[f"调度队列_{i}"]["Path"].rename(
Config.queue_dict[f"调度队列_{i}"]["Path"].with_name(
f"调度队列_{i-1}.json"
)
)
self.queue_manager.show_SettingBox(max(int(name[5:]) - 1, 1))
logger.success(f"{name} 删除成功")
MainInfoBar.push_info_bar("success", "操作成功", f"删除 {name}", 3000)
SoundPlayer.play("删除调度队列")
def left_setting_box(self):
"""向左移动调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
index = int(name[5:])
if index == 1:
logger.warning("向左移动调度队列时已到达最左端")
MainInfoBar.push_info_bar(
"warning", "已经是第一个调度队列", "无法向左移动", 5000
)
return None
if name in Config.running_list or f"调度队列_{index-1}" in Config.running_list:
logger.warning("相关调度队列正在运行")
MainInfoBar.push_info_bar(
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
)
return None
self.queue_manager.clear_SettingBox()
Config.queue_dict[name]["Path"].rename(
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
)
Config.queue_dict[f"调度队列_{index-1}"]["Path"].rename(
Config.queue_dict[name]["Path"]
)
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
Config.queue_dict[f"调度队列_{index-1}"]["Path"]
)
self.queue_manager.show_SettingBox(index - 1)
logger.success(f"{name} 左移成功")
MainInfoBar.push_info_bar("success", "操作成功", f"左移 {name}", 3000)
def right_setting_box(self):
"""向右移动调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
index = int(name[5:])
if index == len(Config.queue_dict):
logger.warning("向右移动调度队列时已到达最右端")
MainInfoBar.push_info_bar(
"warning", "已经是最后一个调度队列", "无法向右移动", 5000
)
return None
if name in Config.running_list or f"调度队列_{index+1}" in Config.running_list:
logger.warning("相关调度队列正在运行")
MainInfoBar.push_info_bar(
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
)
return None
self.queue_manager.clear_SettingBox()
Config.queue_dict[name]["Path"].rename(
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
)
Config.queue_dict[f"调度队列_{index+1}"]["Path"].rename(
Config.queue_dict[name]["Path"]
)
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
Config.queue_dict[f"调度队列_{index+1}"]["Path"]
)
self.queue_manager.show_SettingBox(index + 1)
logger.success(f"{name} 右移成功")
MainInfoBar.push_info_bar("success", "操作成功", f"右移 {name}", 3000)
def reload_member_name(self):
"""刷新调度队列成员"""
member_list = [
["禁用"] + [_ for _ in Config.member_dict.keys()],
["未启用"]
+ [
(
k
if v["Config"].get_name() == ""
else f"{k} - {v["Config"].get_name()}"
)
for k, v in Config.member_dict.items()
],
]
for script in self.queue_manager.script_list:
script.task.card_Member_1.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_2.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_3.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_4.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_5.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_6.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_7.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_8.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_9.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
script.task.card_Member_10.reLoadOptions(
value=member_list[0], texts=member_list[1]
)
class QueueSettingBox(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度队列管理")
self.pivotArea = PivotArea()
self.pivot = self.pivotArea.pivot
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: List[
QueueManager.QueueSettingBox.QueueMemberSettingBox
] = []
self.Layout = QVBoxLayout(self)
self.Layout.addWidget(self.pivotArea)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.switch_SettingBox(
int(index[5:]), if_change_pivot=False
)
)
self.show_SettingBox(1)
def show_SettingBox(self, index) -> None:
"""加载所有子界面"""
Config.search_queue()
for name in Config.queue_dict.keys():
self.add_SettingBox(int(name[5:]))
self.switch_SettingBox(index)
def switch_SettingBox(self, index: int, if_change_pivot: bool = True) -> None:
"""切换到指定的子界面"""
if len(Config.queue_dict) == 0:
return None
if index > len(Config.queue_dict):
return None
if if_change_pivot:
self.pivot.setCurrentItem(self.script_list[index - 1].objectName())
self.stackedWidget.setCurrentWidget(self.script_list[index - 1])
def clear_SettingBox(self) -> None:
"""清空所有子界面"""
for sub_interface in self.script_list:
self.stackedWidget.removeWidget(sub_interface)
sub_interface.deleteLater()
self.script_list.clear()
self.pivot.clear()
def add_SettingBox(self, uid: int) -> None:
"""添加一个调度队列设置界面"""
setting_box = self.QueueMemberSettingBox(uid, self)
self.script_list.append(setting_box)
self.stackedWidget.addWidget(self.script_list[-1])
self.pivot.addItem(routeKey=f"调度队列_{uid}", text=f"调度队列 {uid}")
class QueueMemberSettingBox(QWidget):
def __init__(self, uid: int, parent=None):
super().__init__(parent)
self.setObjectName(f"调度队列_{uid}")
self.config = Config.queue_dict[f"调度队列_{uid}"]["Config"]
self.queue_set = self.QueueSetSettingCard(self.config, self)
self.time = self.TimeSettingCard(self.config, self)
self.task = self.TaskSettingCard(self.config, self)
self.history = HistoryCard(
qconfig=self.config,
configItem=self.config.Data_LastProxyHistory,
parent=self,
)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 11, 0)
content_layout.addWidget(self.queue_set)
content_layout.addWidget(self.time)
content_layout.addWidget(self.task)
content_layout.addWidget(self.history)
content_layout.addStretch(1)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
class QueueSetSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("队列设置")
self.config = config
self.card_Name = LineEditSettingCard(
icon=FluentIcon.EDIT,
title="调度队列名称",
content="用于标识调度队列的名称",
text="请输入调度队列名称",
qconfig=self.config,
configItem=self.config.queueSet_Name,
parent=self,
)
self.card_Enable = SwitchSettingCard(
icon=FluentIcon.HOME,
title="状态",
content="调度队列状态,仅启用时会执行定时任务",
qconfig=self.config,
configItem=self.config.queueSet_Enabled,
parent=self,
)
self.card_AfterAccomplish = ComboBoxSettingCard(
icon=FluentIcon.POWER_BUTTON,
title="调度队列结束后",
content="选择调度队列结束后的操作",
texts=[
"无动作",
"退出AUTO_MAA",
"睡眠win系统需禁用休眠",
"休眠",
"关机",
],
qconfig=self.config,
configItem=self.config.queueSet_AfterAccomplish,
parent=self,
)
Layout = QVBoxLayout()
Layout.addWidget(self.card_Name)
Layout.addWidget(self.card_Enable)
Layout.addWidget(self.card_AfterAccomplish)
self.viewLayout.addLayout(Layout)
class TimeSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("定时设置")
self.config = config
widget_1 = QWidget()
Layout_1 = QVBoxLayout(widget_1)
widget_2 = QWidget()
Layout_2 = QVBoxLayout(widget_2)
Layout = QHBoxLayout()
self.card_Time_0 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 1",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_0,
configItem_time=self.config.time_TimeSet_0,
parent=self,
)
self.card_Time_1 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 2",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_1,
configItem_time=self.config.time_TimeSet_1,
parent=self,
)
self.card_Time_2 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 3",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_2,
configItem_time=self.config.time_TimeSet_2,
parent=self,
)
self.card_Time_3 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 4",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_3,
configItem_time=self.config.time_TimeSet_3,
parent=self,
)
self.card_Time_4 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 5",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_4,
configItem_time=self.config.time_TimeSet_4,
parent=self,
)
self.card_Time_5 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 6",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_5,
configItem_time=self.config.time_TimeSet_5,
parent=self,
)
self.card_Time_6 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 7",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_6,
configItem_time=self.config.time_TimeSet_6,
parent=self,
)
self.card_Time_7 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 8",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_7,
configItem_time=self.config.time_TimeSet_7,
parent=self,
)
self.card_Time_8 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 9",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_8,
configItem_time=self.config.time_TimeSet_8,
parent=self,
)
self.card_Time_9 = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title="定时 10",
content=None,
qconfig=self.config,
configItem_bool=self.config.time_TimeEnabled_9,
configItem_time=self.config.time_TimeSet_9,
parent=self,
)
Layout_1.addWidget(self.card_Time_0)
Layout_1.addWidget(self.card_Time_1)
Layout_1.addWidget(self.card_Time_2)
Layout_1.addWidget(self.card_Time_3)
Layout_1.addWidget(self.card_Time_4)
Layout_2.addWidget(self.card_Time_5)
Layout_2.addWidget(self.card_Time_6)
Layout_2.addWidget(self.card_Time_7)
Layout_2.addWidget(self.card_Time_8)
Layout_2.addWidget(self.card_Time_9)
Layout.addWidget(widget_1)
Layout.addWidget(widget_2)
self.viewLayout.addLayout(Layout)
class TaskSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("任务队列")
self.config = config
member_list = [
["禁用"] + [_ for _ in Config.member_dict.keys()],
["未启用"]
+ [
(
k
if v["Config"].get_name() == ""
else f"{k} - {v["Config"].get_name()}"
)
for k, v in Config.member_dict.items()
],
]
self.card_Member_1 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 1",
content="第一个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_1,
parent=self,
)
self.card_Member_2 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 2",
content="第二个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_2,
parent=self,
)
self.card_Member_3 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 3",
content="第三个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_3,
parent=self,
)
self.card_Member_4 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 4",
content="第四个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_4,
parent=self,
)
self.card_Member_5 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 5",
content="第五个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_5,
parent=self,
)
self.card_Member_6 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 6",
content="第六个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_6,
parent=self,
)
self.card_Member_7 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 7",
content="第七个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_7,
parent=self,
)
self.card_Member_8 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 8",
content="第八个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_8,
parent=self,
)
self.card_Member_9 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 9",
content="第九个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_9,
parent=self,
)
self.card_Member_10 = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title="任务实例 10",
content="第十个调起的脚本任务实例",
value=member_list[0],
texts=member_list[1],
qconfig=self.config,
configItem=self.config.queue_Member_10,
parent=self,
)
Layout = QVBoxLayout()
Layout.addWidget(self.card_Member_1)
Layout.addWidget(self.card_Member_2)
Layout.addWidget(self.card_Member_3)
Layout.addWidget(self.card_Member_4)
Layout.addWidget(self.card_Member_5)
Layout.addWidget(self.card_Member_6)
Layout.addWidget(self.card_Member_7)
Layout.addWidget(self.card_Member_8)
Layout.addWidget(self.card_Member_9)
Layout.addWidget(self.card_Member_10)
self.viewLayout.addLayout(Layout)

1302
app/ui/setting.py Normal file

File diff suppressed because it is too large Load Diff

88
app/utils/AUTO_MAA.iss Normal file
View File

@@ -0,0 +1,88 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "AUTO_MAA"
#define MyAppVersion ""
#define MyAppPublisher "AUTO_MAA Team"
#define MyAppURL "https://doc.automaa.xyz/"
#define MyAppExeName "AUTO_MAA.exe"
#define MyAppPath ""
#define OutputDir ""
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{D116A92A-E174-4699-B777-61C5FD837B19}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName=D:\{#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
; on anything but x64 and Windows 11 on Arm.
ArchitecturesAllowed=x64compatible
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
; meaning it should use the native 64-bit Program Files directory and
; the 64-bit view of the registry.
ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=yes
LicenseFile={#MyAppPath}\LICENSE
; Remove the following line to run in administrative install mode (install for all users).
PrivilegesRequired=lowest
OutputDir={#OutputDir}
OutputBaseFilename=AUTO_MAA-Setup
SetupIconFile={#MyAppPath}\resources\icons\AUTO_MAA.ico
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "Chinese"; MessagesFile: "{#MyAppPath}\resources\docs\ChineseSimplified.isl"
Name: "English"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MyAppPath}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\app\*"; DestDir: "{app}\app"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppPath}\resources\*"; DestDir: "{app}\resources"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppPath}\main.py"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\requirements.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall
[Code]
var
DeleteDataQuestion: Boolean;
function InitializeUninstall: Boolean;
begin
DeleteDataQuestion := MsgBox('您确认要完全移除 AUTO_MAA 的所有用户数据文件与子组件吗?', mbConfirmation, MB_YESNO) = IDYES;
Result := True;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
begin
DelTree(ExpandConstant('{app}\app'), True, True, True);
DelTree(ExpandConstant('{app}\resources'), True, True, True);
if DeleteDataQuestion then
begin
DelTree(ExpandConstant('{app}'), True, True, True);
end;
end;
end;

View File

@@ -1,24 +1,29 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2025 ClozyA
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA图像组件
v4.4
作者ClozyA
"""
import base64
import hashlib
@@ -49,10 +54,12 @@ class ImageUtils:
@staticmethod
def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
"""
如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径Path对象
如果图片大于max_size_mb则压缩并覆盖原文件返回原始路径Path对象
"""
RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0及以后
if hasattr(Image, "Resampling"): # Pillow 9.1.0及以后
RESAMPLE = Image.Resampling.LANCZOS
else:
RESAMPLE = Image.ANTIALIAS
max_size = max_size_mb * 1024 * 1024
if image_path.stat().st_size <= max_size:
@@ -63,7 +70,7 @@ class ImageUtils:
quality = 90 if suffix in [".jpg", ".jpeg"] else None
step = 5
if quality is not None:
if suffix in [".jpg", ".jpeg"]:
while True:
img.save(image_path, quality=quality, optimize=True)
if image_path.stat().st_size <= max_size or quality <= 10:
@@ -83,6 +90,6 @@ class ImageUtils:
height = int(height * 0.95)
img = img.resize((width, height), RESAMPLE)
else:
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩")
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩")
return image_path

View File

@@ -1,156 +0,0 @@
import asyncio
import aiofiles
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Optional, List, Awaitable
from .logger import get_logger
logger = get_logger("日志监控器")
TIME_FIELDS = {
"%Y": "year",
"%m": "month",
"%d": "day",
"%H": "hour",
"%M": "minute",
"%S": "second",
"%f": "microsecond",
}
"""时间字段映射表"""
def strptime(date_string: str, format: str, default_date: datetime) -> datetime:
"""根据指定格式解析日期字符串"""
date = datetime.strptime(date_string, format)
# 构建参数字典
datetime_kwargs = {}
for format_code, field_name in TIME_FIELDS.items():
if format_code in format:
datetime_kwargs[field_name] = getattr(date, field_name)
else:
datetime_kwargs[field_name] = getattr(default_date, field_name)
return datetime(**datetime_kwargs)
class LogMonitor:
def __init__(
self,
time_stamp_range: tuple[int, int],
time_format: str,
callback: Callable[[List[str]], Awaitable[None]],
encoding: str = "utf-8",
):
self.time_stamp_range = time_stamp_range
self.time_format = time_format
self.callback = callback
self.encoding = encoding
self.log_file_path: Optional[Path] = None
self.log_start_time: datetime = datetime.now()
self.last_callback_time: datetime = datetime.now()
self.log_contents: List[str] = []
self.task: Optional[asyncio.Task] = None
self.__is_running = False
async def monitor_log(self):
"""监控日志文件的主循环"""
if self.log_file_path is None or not self.log_file_path.exists():
raise ValueError("日志文件路径未设置或文件不存在")
logger.info(f"开始监控日志文件: {self.log_file_path}")
while self.__is_running:
logger.debug("正在检查日志文件...")
log_contents = []
if_log_start = False
# 检查文件是否仍然存在
if not self.log_file_path.exists():
logger.warning(f"日志文件不存在: {self.log_file_path}")
continue
# 尝试读取文件
try:
async with aiofiles.open(
self.log_file_path, "r", encoding=self.encoding
) as f:
async for line in f:
if not if_log_start:
try:
entry_time = strptime(
line[
self.time_stamp_range[
0
] : self.time_stamp_range[1]
],
self.time_format,
self.last_callback_time,
)
if entry_time > self.log_start_time:
if_log_start = True
log_contents.append(line)
except (ValueError, IndexError):
continue
else:
log_contents.append(line)
except (FileNotFoundError, PermissionError) as e:
logger.warning(f"文件访问错误: {e}")
await asyncio.sleep(5)
continue
except UnicodeDecodeError as e:
logger.error(f"文件编码错误: {e}")
await asyncio.sleep(10)
continue
# 调用回调
if (
log_contents != self.log_contents
or datetime.now() - self.last_callback_time > timedelta(minutes=1)
):
self.log_contents = log_contents
self.last_callback_time = datetime.now()
# 安全调用回调函数
try:
await self.callback(log_contents)
except Exception as e:
logger.error(f"回调函数执行失败: {e}")
await asyncio.sleep(1)
async def start(self, log_file_path: Path, start_time: datetime) -> None:
"""启动监控"""
if log_file_path.is_dir():
raise ValueError(f"日志文件不能是目录: {log_file_path}")
if self.task is not None and not self.task.done():
await self.stop()
self.__is_running = True
self.log_contents = []
self.log_file_path = log_file_path
self.log_start_time = start_time
self.task = asyncio.create_task(self.monitor_log())
logger.info(f"日志监控已启动: {self.log_file_path}")
async def stop(self):
"""停止监控"""
logger.info("请求取消日志监控任务")
if self.task is not None and not self.task.done():
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
logger.info("日志监控任务已中止")
logger.success("日志监控任务已停止")
self.task = None

View File

@@ -1,54 +1,66 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA进程管理组件
v4.4
作者DLmaster_361
"""
import asyncio
import psutil
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from datetime import datetime
from PySide6.QtCore import QTimer, QObject, Signal
class ProcessManager:
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
class ProcessManager(QObject):
"""进程监视器类用于跟踪主进程及其所有子进程的状态"""
processClosed = Signal()
def __init__(self):
super().__init__()
self.main_pid = None
self.tracked_pids = set()
self.check_task = None
self.track_end_time = datetime.now()
async def open_process(
self, path: Path, args: list = [], tracking_time: int = 60
) -> None:
self.check_timer = QTimer()
self.check_timer.timeout.connect(self.check_processes)
def open_process(
self,
path: Path,
args: list = [],
tracking_time: int = 60,
) -> int:
"""
启动一个新进程并返回其pid, 并开始监视该进程
启动一个新进程并返回其pid并开始监视该进程
Parameters
----------
path: 可执行文件的路径
args: 启动参数列表
tracking_time: 子进程追踪持续时间(秒)
:param path: 可执行文件的路径
:param args: 启动参数列表
:param tracking_time: 子进程追踪持续时间(秒)
:return: 新进程的PID
"""
process = subprocess.Popen(
@@ -60,24 +72,24 @@ class ProcessManager:
stderr=subprocess.DEVNULL,
)
await self.start_monitoring(process.pid, tracking_time)
self.start_monitoring(process.pid, tracking_time)
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
"""
启动进程监视器, 跟踪指定的主进程及其子进程
启动进程监视器跟踪指定的主进程及其子进程
:param pid: 被监视进程的PID
:param tracking_time: 子进程追踪持续时间(秒)
"""
await self.clear()
self.clear()
self.main_pid = pid
self.tracking_time = tracking_time
# 扫描并记录所有相关进程
try:
# 获取主进程
# 获取主进程及其子进程
main_proc = psutil.Process(self.main_pid)
self.tracked_pids.add(self.main_pid)
@@ -89,15 +101,16 @@ class ProcessManager:
except psutil.NoSuchProcess:
pass
# 启动持续追踪任务
if tracking_time > 0:
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
self.check_task = asyncio.create_task(self.track_processes())
# 启动持续追踪机制
self.start_time = datetime.now()
self.check_timer.start(100)
async def track_processes(self) -> None:
"""更新子进程列表"""
def check_processes(self) -> None:
"""检查跟踪的进程是否仍在运行,并更新子进程列表"""
# 仅在时限内持续更新跟踪的进程列表,发现新的子进程
if (datetime.now() - self.start_time).total_seconds() < self.tracking_time:
while datetime.now() < self.track_end_time:
current_pids = set(self.tracked_pids)
for pid in current_pids:
try:
@@ -108,9 +121,12 @@ class ProcessManager:
self.tracked_pids.add(child.pid)
except psutil.NoSuchProcess:
continue
await asyncio.sleep(0.1)
async def is_running(self) -> bool:
if not self.is_running():
self.clear()
self.processClosed.emit()
def is_running(self) -> bool:
"""检查所有跟踪的进程是否还在运行"""
for pid in self.tracked_pids:
@@ -123,9 +139,11 @@ class ProcessManager:
return False
async def kill(self, if_force: bool = False) -> None:
def kill(self, if_force: bool = False) -> None:
"""停止监视器并中止所有跟踪的进程"""
self.check_timer.stop()
for pid in self.tracked_pids:
try:
proc = psutil.Process(pid)
@@ -139,18 +157,13 @@ class ProcessManager:
except psutil.NoSuchProcess:
continue
await self.clear()
if self.main_pid:
self.processClosed.emit()
self.clear()
async def clear(self) -> None:
def clear(self) -> None:
"""清空跟踪的进程列表"""
if self.check_task is not None and not self.check_task.done():
self.check_task.cancel()
try:
await self.check_task
except asyncio.CancelledError:
pass
self.main_pid = None
self.check_timer.stop()
self.tracked_pids.clear()

View File

@@ -1,44 +1,35 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# This file is part of AUTO_MAA.
# AUTO-MAS is free software: you can redistribute it and/or modify
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
__version__ = "5.0.0"
"""
AUTO_MAA
AUTO_MAA工具包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .constants import *
from .logger import get_logger
from .ImageUtils import ImageUtils
from .LogMonitor import LogMonitor, strptime
from .ProcessManager import ProcessManager
from .security import dpapi_encrypt, dpapi_decrypt
__all__ = [
"constants",
"get_logger",
"ImageUtils",
"LogMonitor",
"ProcessManager",
"dpapi_encrypt",
"dpapi_decrypt",
"strptime",
]
__all__ = ["ImageUtils", "ProcessManager"]

View File

@@ -1,288 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 ClozyA
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from datetime import datetime
RESOURCE_STAGE_INFO = [
{"value": "-", "text": "当前/上次", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "1-7", "text": "1-7", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "R8-11", "text": "R8-11", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "12-17-HARD", "text": "12-17-HARD", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "LS-6", "text": "经验-6/5", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "CE-6", "text": "龙门币-6/5", "days": [2, 4, 6, 7]},
{"value": "AP-5", "text": "红票-5", "days": [1, 4, 6, 7]},
{"value": "CA-5", "text": "技能-5", "days": [2, 3, 5, 7]},
{"value": "SK-5", "text": "碳-5", "days": [1, 3, 5, 6]},
{"value": "PR-A-1", "text": "奶/盾芯片", "days": [1, 4, 5, 7]},
{"value": "PR-A-2", "text": "奶/盾芯片组", "days": [1, 4, 5, 7]},
{"value": "PR-B-1", "text": "术/狙芯片", "days": [1, 2, 5, 6]},
{"value": "PR-B-2", "text": "术/狙芯片组", "days": [1, 2, 5, 6]},
{"value": "PR-C-1", "text": "先/辅芯片", "days": [3, 4, 6, 7]},
{"value": "PR-C-2", "text": "先/辅芯片组", "days": [3, 4, 6, 7]},
{"value": "PR-D-1", "text": "近/特芯片", "days": [2, 3, 6, 7]},
{"value": "PR-D-2", "text": "近/特芯片组", "days": [2, 3, 6, 7]},
]
"""常规资源关信息"""
RESOURCE_STAGE_DATE_TEXT = {
"LS-6": "经验-6/5 | 常驻开放",
"CE-6": "龙门币-6/5 | 二四六日开放",
"AP-5": "红票-5 | 一四六日开放",
"CA-5": "技能-5 | 二三五日开放",
"SK-5": "碳-5 | 一三五六开放",
"PR-A-1": "奶/盾芯片 | 一四五日开放",
"PR-A-2": "奶/盾芯片组 | 一四五日开放",
"PR-B-1": "术/狙芯片 | 一二五六日开放",
"PR-B-2": "术/狙芯片组 | 一二五六日开放",
"PR-C-1": "先/辅芯片 | 三四六日开放",
"PR-C-2": "先/辅芯片组 | 三四六日开放",
"PR-D-1": "近/特芯片 | 二三六日开放",
"PR-D-2": "近/特芯片组 | 二三六日开放",
}
"""常规资源关开放日文本映射"""
RESOURCE_STAGE_DROP_INFO = {
"CE-6": {
"Display": "CE-6",
"Value": "CE-6",
"Drop": "4001",
"DropName": "龙门币",
"Activity": {"Tip": "二四六日", "StageName": "资源关卡"},
},
"AP-5": {
"Display": "AP-5",
"Value": "AP-5",
"Drop": "4006",
"DropName": "采购凭证",
"Activity": {"Tip": "一四六日", "StageName": "资源关卡"},
},
"CA-5": {
"Display": "CA-5",
"Value": "CA-5",
"Drop": "3303",
"DropName": "技巧概要",
"Activity": {"Tip": "二三五日", "StageName": "资源关卡"},
},
"LS-6": {
"Display": "LS-6",
"Value": "LS-6",
"Drop": "2004",
"DropName": "作战记录",
"Activity": {"Tip": "常驻开放", "StageName": "资源关卡"},
},
"SK-5": {
"Display": "SK-5",
"Value": "SK-5",
"Drop": "3114",
"DropName": "碳素组",
"Activity": {"Tip": "一三五六", "StageName": "资源关卡"},
},
"PR-A-1": {
"Display": "PR-A",
"Value": "PR-A",
"Drop": "PR-A",
"DropName": "奶/盾芯片",
"Activity": {"Tip": "一四五日", "StageName": "资源关卡"},
},
"PR-B-1": {
"Display": "PR-B",
"Value": "PR-B",
"Drop": "PR-B",
"DropName": "术/狙芯片",
"Activity": {"Tip": "一二五六", "StageName": "资源关卡"},
},
"PR-C-1": {
"Display": "PR-C",
"Value": "PR-C",
"Drop": "PR-C",
"DropName": "先/辅芯片",
"Activity": {"Tip": "三四六日", "StageName": "资源关卡"},
},
"PR-D-1": {
"Display": "PR-D",
"Value": "PR-D",
"Drop": "PR-D",
"DropName": "近/特芯片",
"Activity": {"Tip": "二三六日", "StageName": "资源关卡"},
},
}
"""常规资源关掉落信息"""
MATERIALS_MAP = {
"4001": "龙门币",
"4006": "采购凭证",
"2004": "高级作战记录",
"2003": "中级作战记录",
"2002": "初级作战记录",
"2001": "基础作战记录",
"3303": "技巧概要·卷3",
"3302": "技巧概要·卷2",
"3301": "技巧概要·卷1",
"30165": "重相位对映体",
"30155": "烧结核凝晶",
"30145": "晶体电子单元",
"30135": "D32钢",
"30125": "双极纳米片",
"30115": "聚合剂",
"31094": "手性屈光体",
"31093": "类凝结核",
"31084": "环烃预制体",
"31083": "环烃聚质",
"31074": "固化纤维板",
"31073": "褐素纤维",
"31064": "转质盐聚块",
"31063": "转质盐组",
"31054": "切削原液",
"31053": "化合切削液",
"31044": "精炼溶剂",
"31043": "半自然溶剂",
"31034": "晶体电路",
"31033": "晶体元件",
"31024": "炽合金块",
"31023": "炽合金",
"31014": "聚合凝胶",
"31013": "凝胶",
"30074": "白马醇",
"30073": "扭转醇",
"30084": "三水锰矿",
"30083": "轻锰矿",
"30094": "五水研磨石",
"30093": "研磨石",
"30104": "RMA70-24",
"30103": "RMA70-12",
"30014": "提纯源岩",
"30013": "固源岩组",
"30012": "固源岩",
"30011": "源岩",
"30064": "改量装置",
"30063": "全新装置",
"30062": "装置",
"30061": "破损装置",
"30034": "聚酸酯块",
"30033": "聚酸酯组",
"30032": "聚酸酯",
"30031": "酯原料",
"30024": "糖聚块",
"30023": "糖组",
"30022": "",
"30021": "代糖",
"30044": "异铁块",
"30043": "异铁组",
"30042": "异铁",
"30041": "异铁碎片",
"30054": "酮阵列",
"30053": "酮凝集组",
"30052": "酮凝集",
"30051": "双酮",
"3114": "碳素组",
"3113": "碳素",
"3112": "",
"3213": "先锋双芯片",
"3223": "近卫双芯片",
"3233": "重装双芯片",
"3243": "狙击双芯片",
"3253": "术师双芯片",
"3263": "医疗双芯片",
"3273": "辅助双芯片",
"3283": "特种双芯片",
"3212": "先锋芯片组",
"3222": "近卫芯片组",
"3232": "重装芯片组",
"3242": "狙击芯片组",
"3252": "术师芯片组",
"3262": "医疗芯片组",
"3272": "辅助芯片组",
"3282": "特种芯片组",
"3211": "先锋芯片",
"3221": "近卫芯片",
"3231": "重装芯片",
"3241": "狙击芯片",
"3251": "术师芯片",
"3261": "医疗芯片",
"3271": "辅助芯片",
"3281": "特种芯片",
"PR-A": "医疗/重装芯片",
"PR-B": "术师/狙击芯片",
"PR-C": "先锋/辅助芯片",
"PR-D": "近卫/特种芯片",
}
"""掉落物索引表"""
POWER_SIGN_MAP = {
"NoAction": "无动作",
"Shutdown": "关机",
"ShutdownForce": "强制关机",
"Hibernate": "休眠",
"Sleep": "睡眠",
"KillSelf": "退出程序",
}
"""电源操作类型索引表"""
RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
}
"""Windows保留名称列表"""
ILLEGAL_CHARS = set('<>:"/\\|?*')
"""文件名非法字符集合"""
MIRROR_ERROR_INFO = {
1001: "获取版本信息的URL参数不正确",
7001: "填入的 CDK 已过期",
7002: "填入的 CDK 错误",
7003: "填入的 CDK 今日下载次数已达上限",
7004: "填入的 CDK 类型和待下载的资源不匹配",
7005: "填入的 CDK 已被封禁",
8001: "对应架构和系统下的资源不存在",
8002: "错误的系统参数",
8003: "错误的架构参数",
8004: "错误的更新通道参数",
1: "未知错误类型",
}
"""MirrorChyan错误代码映射表"""
DEFAULT_DATETIME = datetime.strptime("2000-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
"""默认日期时间"""

View File

@@ -1,5 +0,0 @@
from .mumu import MumuManager
from .ldplayer import LDManager
from .utils import BaseDevice, DeviceStatus
__all__ = ["MumuManager", "LDManager", "BaseDevice", "DeviceStatus"]

View File

@@ -1,492 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import asyncio
import psutil
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Any
from app.utils.device_manager.utils import BaseDevice, DeviceStatus
from app.utils.logger import get_logger
class ProcessManager:
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
def __init__(self):
super().__init__()
self.main_pid = None
self.tracked_pids = set()
self.check_task = None
self.track_end_time = datetime.now()
async def open_process(
self, path: Path, args: list = [], tracking_time: int = 60
) -> None:
"""
启动一个新进程并返回其pid, 并开始监视该进程
Parameters
----------
path: 可执行文件的路径
args: 启动参数列表
tracking_time: 子进程追踪持续时间(秒)
"""
process = subprocess.Popen(
[path, *args],
cwd=path.parent,
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
await self.start_monitoring(process.pid, tracking_time)
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
"""
启动进程监视器, 跟踪指定的主进程及其子进程
:param pid: 被监视进程的PID
:param tracking_time: 子进程追踪持续时间(秒)
"""
await self.clear()
self.main_pid = pid
self.tracking_time = tracking_time
# 扫描并记录所有相关进程
try:
# 获取主进程
main_proc = psutil.Process(self.main_pid)
self.tracked_pids.add(self.main_pid)
# 递归获取所有子进程
if tracking_time:
for child in main_proc.children(recursive=True):
self.tracked_pids.add(child.pid)
except psutil.NoSuchProcess:
pass
# 启动持续追踪任务
if tracking_time > 0:
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
self.check_task = asyncio.create_task(self.track_processes())
async def track_processes(self) -> None:
"""更新子进程列表"""
while datetime.now() < self.track_end_time:
current_pids = set(self.tracked_pids)
for pid in current_pids:
try:
proc = psutil.Process(pid)
for child in proc.children():
if child.pid not in self.tracked_pids:
# 新发现的子进程
self.tracked_pids.add(child.pid)
except psutil.NoSuchProcess:
continue
await asyncio.sleep(0.1)
async def is_running(self) -> bool:
"""检查所有跟踪的进程是否还在运行"""
for pid in self.tracked_pids:
try:
proc = psutil.Process(pid)
if proc.is_running():
return True
except psutil.NoSuchProcess:
continue
return False
async def kill(self, if_force: bool = False) -> None:
"""停止监视器并中止所有跟踪的进程"""
for pid in self.tracked_pids:
try:
proc = psutil.Process(pid)
if if_force:
kill_process = subprocess.Popen(
["taskkill", "/F", "/T", "/PID", str(pid)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
kill_process.wait()
proc.terminate()
except psutil.NoSuchProcess:
continue
await self.clear()
async def clear(self) -> None:
"""清空跟踪的进程列表"""
if self.check_task is not None and not self.check_task.done():
self.check_task.cancel()
try:
await self.check_task
except asyncio.CancelledError:
pass
self.main_pid = None
self.tracked_pids.clear()
class GeneralDeviceManager(BaseDevice):
"""
通用设备管理器基于BaseDevice和ProcessManager实现
用于管理一般应用程序进程
"""
def __init__(self, executable_path: str, name: str = "通用设备"):
"""
初始化通用设备管理器
Args:
executable_path (str): 可执行文件的绝对路径
name (str): 设备管理器名称
"""
self.executable_path = Path(executable_path)
self.name = name
self.logger = get_logger(f"{name}管理器")
# 进程管理实例字典以idx为键
self.process_managers: Dict[str, ProcessManager] = {}
# 设备信息存储
self.device_info: Dict[str, Dict[str, Any]] = {}
# 默认等待时间
self.wait_time = 60
if not self.executable_path.exists():
raise FileNotFoundError(f"可执行文件不存在: {executable_path}")
async def start(self, idx: str, package_name: str = "") -> tuple[bool, int, dict]:
"""
启动设备
Args:
idx: 设备ID
package_name: 包名(可选)
Returns:
tuple[bool, int, dict]: (是否成功, 状态码, 启动信息)
"""
try:
# 检查是否已经在运行
current_status = await self.get_status(idx)
if current_status in [DeviceStatus.ONLINE, DeviceStatus.STARTING]:
self.logger.warning(f"设备{idx}已经在运行,状态: {current_status}")
return False, current_status, {}
# 创建进程管理器
if idx not in self.process_managers:
self.process_managers[idx] = ProcessManager()
# 准备启动参数
args = []
if package_name:
args.extend(["-pkg", package_name])
# 启动进程
await self.process_managers[idx].open_process(
self.executable_path, args, tracking_time=self.wait_time
)
# 等待进程启动
start_time = datetime.now()
timeout = timedelta(seconds=self.wait_time)
while datetime.now() - start_time < timeout:
if await self.process_managers[idx].is_running():
self.device_info[idx] = {
"title": f"{self.name}_{idx}",
"status": str(DeviceStatus.ONLINE),
"pid": self.process_managers[idx].main_pid,
"start_time": start_time.isoformat(),
}
self.logger.info(f"设备{idx}启动成功")
return True, DeviceStatus.ONLINE, self.device_info[idx]
await asyncio.sleep(0.1)
self.logger.error(f"设备{idx}启动超时")
return False, DeviceStatus.ERROR, {}
except Exception as e:
self.logger.error(f"启动设备{idx}失败: {str(e)}")
return False, DeviceStatus.ERROR, {}
async def close(self, idx: str) -> tuple[bool, int]:
"""
关闭设备或服务
Args:
idx: 设备ID
Returns:
tuple[bool, int]: (是否成功, 状态码)
"""
try:
if idx not in self.process_managers:
self.logger.warning(f"设备{idx}的进程管理器不存在")
return False, DeviceStatus.NOT_FOUND
# 检查进程是否在运行
if not await self.process_managers[idx].is_running():
self.logger.info(f"设备{idx}进程已经停止")
return True, DeviceStatus.OFFLINE
# 终止进程
await self.process_managers[idx].kill(if_force=False)
# 等待进程完全停止
stop_time = datetime.now()
timeout = timedelta(seconds=10) # 10秒超时
while datetime.now() - stop_time < timeout:
if not await self.process_managers[idx].is_running():
# 清理设备信息
if idx in self.device_info:
del self.device_info[idx]
self.logger.info(f"设备{idx}已成功关闭")
return True, DeviceStatus.OFFLINE
await asyncio.sleep(0.1)
# 强制终止
self.logger.warning(f"设备{idx}未能正常关闭,尝试强制终止")
await self.process_managers[idx].kill(if_force=True)
if idx in self.device_info:
del self.device_info[idx]
return True, DeviceStatus.OFFLINE
except Exception as e:
self.logger.error(f"关闭设备{idx}失败: {str(e)}")
return False, DeviceStatus.ERROR
async def get_status(self, idx: str) -> int:
"""
获取指定设备当前状态
Args:
idx: 设备ID
Returns:
int: 状态码
"""
try:
if idx not in self.process_managers:
return DeviceStatus.OFFLINE
if await self.process_managers[idx].is_running():
return DeviceStatus.ONLINE
else:
return DeviceStatus.OFFLINE
except Exception as e:
self.logger.error(f"获取设备{idx}状态失败: {str(e)}")
return DeviceStatus.ERROR
async def hide_device(self, idx: str) -> tuple[bool, int]:
"""
隐藏设备窗口
Args:
idx: 设备ID
Returns:
tuple[bool, int]: (是否成功, 状态码)
"""
try:
status = await self.get_status(idx)
if status != DeviceStatus.ONLINE:
return False, status
if (
idx not in self.process_managers
or not self.process_managers[idx].main_pid
):
return False, DeviceStatus.NOT_FOUND
# 窗口隐藏功能(简化实现)
# 注意完整的窗口隐藏功能需要更复杂的Windows API调用
self.logger.info(f"设备{idx}窗口隐藏请求已处理(简化实现)")
return True, DeviceStatus.ONLINE
self.logger.info(f"设备{idx}窗口已隐藏")
return True, DeviceStatus.ONLINE
except ImportError:
self.logger.warning("隐藏窗口功能需要pywin32库")
return False, DeviceStatus.ERROR
except Exception as e:
self.logger.error(f"隐藏设备{idx}窗口失败: {str(e)}")
return False, DeviceStatus.ERROR
async def show_device(self, idx: str) -> tuple[bool, int]:
"""
显示设备窗口
Args:
idx: 设备ID
Returns:
tuple[bool, int]: (是否成功, 状态码)
"""
try:
status = await self.get_status(idx)
if status != DeviceStatus.ONLINE:
return False, status
if (
idx not in self.process_managers
or not self.process_managers[idx].main_pid
):
return False, DeviceStatus.NOT_FOUND
# 窗口显示功能(简化实现)
# 注意完整的窗口显示功能需要更复杂的Windows API调用
self.logger.info(f"设备{idx}窗口显示请求已处理(简化实现)")
return True, DeviceStatus.ONLINE
self.logger.info(f"设备{idx}窗口已显示")
return True, DeviceStatus.ONLINE
except ImportError:
self.logger.warning("显示窗口功能需要pywin32库")
return False, DeviceStatus.ERROR
except Exception as e:
self.logger.error(f"显示设备{idx}窗口失败: {str(e)}")
return False, DeviceStatus.ERROR
async def get_all_info(self) -> dict[str, dict[str, str]]:
"""
获取所有设备信息
Returns:
dict[str, dict[str, str]]: 设备信息字典
结构示例:
{
"0": {
"title": "设备名称",
"status": "1"
}
}
"""
result = {}
for idx in list(self.process_managers.keys()):
try:
status = await self.get_status(idx)
if idx in self.device_info:
title = self.device_info[idx].get("title", f"{self.name}_{idx}")
else:
title = f"{self.name}_{idx}"
result[idx] = {"title": title, "status": str(status)}
except Exception as e:
self.logger.error(f"获取设备{idx}信息失败: {str(e)}")
result[idx] = {
"title": f"{self.name}_{idx}",
"status": str(DeviceStatus.ERROR),
}
return result
async def cleanup(self) -> None:
"""
清理所有资源
"""
self.logger.info("开始清理设备管理器资源")
for idx, pm in list(self.process_managers.items()):
try:
if await pm.is_running():
await pm.kill(if_force=True)
await pm.clear()
except Exception as e:
self.logger.error(f"清理设备{idx}资源失败: {str(e)}")
self.process_managers.clear()
self.device_info.clear()
self.logger.info("设备管理器资源清理完成")
def __del__(self):
"""析构函数,确保资源被正确释放"""
try:
# 注意析构函数中不能使用async/await
# 这里只是标记实际清理需要显式调用cleanup()
if hasattr(self, "process_managers") and self.process_managers:
self.logger.warning("设备管理器未正确清理请显式调用cleanup()方法")
except: # noqa: E722
pass
# 使用示例
if __name__ == "__main__":
async def main():
# 创建通用设备管理器
manager = GeneralDeviceManager(
executable_path=r"C:\Windows\System32\notepad.exe", name="记事本"
)
try:
# 启动设备
success, status, info = await manager.start("0")
print(f"启动结果: {success}, 状态: {status}, 信息: {info}")
if success:
# 获取所有设备信息
all_info = await manager.get_all_info()
print(f"所有设备信息: {all_info}")
# 等待5秒
await asyncio.sleep(5)
# 关闭设备
close_success, close_status = await manager.close("0")
print(f"关闭结果: {close_success}, 状态: {close_status}")
finally:
# 清理资源
await manager.cleanup()
# 运行示例
asyncio.run(main())

View File

@@ -1,278 +0,0 @@
"""
键盘工具模块
提供虚拟键码到keyboard库按键名称的转换功能以及相关的键盘操作辅助函数。
"""
import asyncio
import keyboard
from typing import List
from app.utils.logger import get_logger
logger = get_logger("键盘工具")
def vk_code_to_key_name(vk_code: int) -> str:
"""
将Windows虚拟键码转换为keyboard库识别的按键名称
Args:
vk_code (int): Windows虚拟键码
Returns:
str: keyboard库识别的按键名称
Examples:
>>> vk_code_to_key_name(0x1B)
'esc'
>>> vk_code_to_key_name(0x70)
'f1'
>>> vk_code_to_key_name(0x41)
'a'
"""
# Windows虚拟键码到keyboard库按键名称的映射
vk_mapping = {
# 常用功能键
0x1B: "esc", # VK_ESCAPE
0x0D: "enter", # VK_RETURN
0x20: "space", # VK_SPACE
0x08: "backspace", # VK_BACK
0x09: "tab", # VK_TAB
0x2E: "delete", # VK_DELETE
0x24: "home", # VK_HOME
0x23: "end", # VK_END
0x21: "page up", # VK_PRIOR
0x22: "page down", # VK_NEXT
0x2D: "insert", # VK_INSERT
# 修饰键
0x10: "shift", # VK_SHIFT
0x11: "ctrl", # VK_CONTROL
0x12: "alt", # VK_MENU
0x5B: "left windows", # VK_LWIN
0x5C: "right windows", # VK_RWIN
0x5D: "apps", # VK_APPS (右键菜单键)
# 方向键
0x25: "left", # VK_LEFT
0x26: "up", # VK_UP
0x27: "right", # VK_RIGHT
0x28: "down", # VK_DOWN
# 功能键 F1-F12
0x70: "f1",
0x71: "f2",
0x72: "f3",
0x73: "f4",
0x74: "f5",
0x75: "f6",
0x76: "f7",
0x77: "f8",
0x78: "f9",
0x79: "f10",
0x7A: "f11",
0x7B: "f12",
# 数字键 0-9
0x30: "0",
0x31: "1",
0x32: "2",
0x33: "3",
0x34: "4",
0x35: "5",
0x36: "6",
0x37: "7",
0x38: "8",
0x39: "9",
# 字母键 A-Z
0x41: "a",
0x42: "b",
0x43: "c",
0x44: "d",
0x45: "e",
0x46: "f",
0x47: "g",
0x48: "h",
0x49: "i",
0x4A: "j",
0x4B: "k",
0x4C: "l",
0x4D: "m",
0x4E: "n",
0x4F: "o",
0x50: "p",
0x51: "q",
0x52: "r",
0x53: "s",
0x54: "t",
0x55: "u",
0x56: "v",
0x57: "w",
0x58: "x",
0x59: "y",
0x5A: "z",
# 数字小键盘
0x60: "num 0",
0x61: "num 1",
0x62: "num 2",
0x63: "num 3",
0x64: "num 4",
0x65: "num 5",
0x66: "num 6",
0x67: "num 7",
0x68: "num 8",
0x69: "num 9",
0x6A: "num *",
0x6B: "num +",
0x6D: "num -",
0x6E: "num .",
0x6F: "num /",
0x90: "num lock",
# 标点符号和特殊键
0xBA: ";", # VK_OEM_1 (;:)
0xBB: "=", # VK_OEM_PLUS (=+)
0xBC: ",", # VK_OEM_COMMA (,<)
0xBD: "-", # VK_OEM_MINUS (-_)
0xBE: ".", # VK_OEM_PERIOD (.>)
0xBF: "/", # VK_OEM_2 (/?)
0xC0: "`", # VK_OEM_3 (`~)
0xDB: "[", # VK_OEM_4 ([{)
0xDC: "\\", # VK_OEM_5 (\|)
0xDD: "]", # VK_OEM_6 (]})
0xDE: "'", # VK_OEM_7 ('")
# 系统键
0x14: "caps lock", # VK_CAPITAL
0x91: "scroll lock", # VK_SCROLL
0x13: "pause", # VK_PAUSE
0x2C: "print screen", # VK_SNAPSHOT
# 媒体键
0xA0: "left shift", # VK_LSHIFT
0xA1: "right shift", # VK_RSHIFT
0xA2: "left ctrl", # VK_LCONTROL
0xA3: "right ctrl", # VK_RCONTROL
0xA4: "left alt", # VK_LMENU
0xA5: "right alt", # VK_RMENU
}
return vk_mapping.get(vk_code, f"vk_{vk_code}")
def vk_codes_to_key_names(vk_codes: List[int]) -> List[str]:
"""
将多个虚拟键码转换为keyboard库识别的按键名称列表
Args:
vk_codes (List[int]): Windows虚拟键码列表
Returns:
List[str]: keyboard库识别的按键名称列表
Examples:
>>> vk_codes_to_key_names([0x11, 0x12, 0x44])
['ctrl', 'alt', 'd']
"""
return [vk_code_to_key_name(vk) for vk in vk_codes]
async def send_key_combination(key_names: List[str], hold_time: float = 0.05) -> bool:
"""
发送按键组合
Args:
key_names (List[str]): 按键名称列表
hold_time (float): 按键保持时间(秒),默认 0.05 秒
Returns:
bool: 操作是否成功
Examples:
>>> await send_key_combination(['ctrl', 'alt', 'd'])
True
>>> await send_key_combination(['f1'])
True
"""
try:
if not key_names:
logger.warning("按键名称列表为空")
return False
if len(key_names) == 1:
# 单个按键
keyboard.press_and_release(key_names[0])
logger.debug(f"发送单个按键: {key_names[0]}")
else:
# 组合键:按下所有键,然后释放
logger.debug(f"发送组合键: {'+'.join(key_names)}")
for key in key_names:
keyboard.press(key)
await asyncio.sleep(hold_time) # 保持按键状态
for key in reversed(key_names):
keyboard.release(key)
return True
except Exception as e:
logger.error(f"发送按键组合失败: {e}")
return False
async def send_vk_combination(vk_codes: List[int], hold_time: float = 0.05) -> bool:
"""
发送虚拟键码组合
Args:
vk_codes (List[int]): Windows虚拟键码列表
hold_time (float): 按键保持时间(秒),默认 0.05 秒
Returns:
bool: 操作是否成功
Examples:
>>> await send_vk_combination([0x11, 0x12, 0x44]) # Ctrl+Alt+D
True
>>> await send_vk_combination([0x70]) # F1
True
"""
try:
key_names = vk_codes_to_key_names(vk_codes)
return await send_key_combination(key_names, hold_time)
except Exception as e:
logger.error(f"发送虚拟键码组合失败: {e}")
return False
def get_common_boss_keys() -> dict[str, List[int]]:
"""
获取常见的BOSS键组合
Returns:
dict[str, List[int]]: 常见BOSS键组合的名称和对应的虚拟键码
"""
return {
"alt_tab": [0x12, 0x09], # Alt+Tab
"ctrl_alt_d": [0x11, 0x12, 0x44], # Ctrl+Alt+D
"win_d": [0x5B, 0x44], # Win+D (显示桌面)
"win_m": [0x5B, 0x4D], # Win+M (最小化所有窗口)
"f1": [0x70], # F1
"f2": [0x71], # F2
"f3": [0x72], # F3
"f4": [0x73], # F4
"alt_f4": [0x12, 0x73], # Alt+F4 (关闭窗口)
"ctrl_shift_esc": [0x11, 0x10, 0x1B], # Ctrl+Shift+Esc (任务管理器)
}
def describe_vk_combination(vk_codes: List[int]) -> str:
"""
描述虚拟键码组合
Args:
vk_codes (List[int]): Windows虚拟键码列表
Returns:
str: 组合键的描述字符串
Examples:
>>> describe_vk_combination([0x11, 0x12, 0x44])
'Ctrl+Alt+D'
>>> describe_vk_combination([0x70])
'F1'
"""
key_names = vk_codes_to_key_names(vk_codes)
return "+".join(name.title() for name in key_names)

View File

@@ -1,329 +0,0 @@
import asyncio
from typing import Literal
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
from app.utils.logger import get_logger
from app.utils.device_manager.keyboard_utils import (
vk_codes_to_key_names,
send_key_combination,
)
import psutil
from pydantic import BaseModel
import win32gui
class EmulatorInfo(BaseModel):
idx: int
title: str
top_hwnd: int
bind_hwnd: int
in_android: int
pid: int
vbox_pid: int
width: int
height: int
density: int
class LDManager(BaseDevice):
"""
基于dnconsole.exe的模拟器管理
!需要管理员权限
"""
def __init__(self, exe_path: str) -> None:
"""_summary_
Args:
exe_path (str): dnconsole.exe的绝对路径
"""
self.runner = ExeRunner(exe_path, "gbk")
self.logger = get_logger("雷电模拟器管理器")
self.wait_time = 60 # 配置获取 后续改一下 单位为s
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
"""
启动指定模拟器
Returns:
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
"""
OK, info, status = await self.get_device_info(idx)
if status != DeviceStatus.OFFLINE:
self.logger.error(
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
)
return False, status, {}
if package_name:
result = self.runner.run(
"launch",
"--index",
idx,
"--packagename",
f'"{package_name}"',
)
else:
result = self.runner.run(
"launch",
"--index",
idx,
)
# 参考命令 dnconsole.exe launch --index 0
self.logger.debug(f"启动结果:{result}")
if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result}")
i = 0
while i < self.wait_time * 10:
OK, info, status = await self.get_device_info(idx)
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
return False, status, {}
if status == DeviceStatus.ONLINE:
self.logger.debug(info)
if OK and isinstance(info, EmulatorInfo):
pid: int = info.vbox_pid
adb_port = ""
adb_host_ip = await self.get_adb_ports(pid)
print(adb_host_ip)
if adb_host_ip:
return (
True,
status,
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
)
return True, status, {}
await asyncio.sleep(0.1)
i += 1
return False, DeviceStatus.UNKNOWN, {}
async def close(self, idx: str) -> tuple[bool, int]:
"""
关闭指定模拟器
Returns:
- tuple[bool, int]: 是否成功, 当前状态码
参考命令行:dnconsole.exe quit --index 0
"""
OK, info, status = await self.get_device_info(idx)
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
return False, DeviceStatus.NOT_FOUND
result = self.runner.run(
"quit",
"--index",
idx,
)
# 参考命令 dnconsole.exe quit --index 0
if result.returncode != 0:
return True, DeviceStatus.OFFLINE
i = 0
while i < self.wait_time * 10:
OK, info, status = await self.get_device_info(idx)
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
return False, status
if status == DeviceStatus.OFFLINE:
return True, DeviceStatus.OFFLINE
await asyncio.sleep(0.1)
i += 1
return False, DeviceStatus.UNKNOWN
async def get_status(self, idx: str) -> int:
"""
获取指定模拟器当前状态
返回值: 状态码
"""
_, _, status = await self.get_device_info(idx)
return status
async def get_device_info(
self,
idx: str,
data: dict[int, EmulatorInfo] | None = None,
) -> tuple[Literal[True], EmulatorInfo, int] | tuple[Literal[False], dict, int]:
"""
获取指定模拟器的信息和状态
Returns:
- tuple[bool, EmulatorInfo | dict, int]: 是否成功, 模拟器信息或空字典, 状态码
参考命令行:dnconsole.exe list2
"""
if not data:
result = await self._get_all_info()
else:
result = data
try:
emulator_info = result.get(int(idx))
print(emulator_info)
if not emulator_info:
self.logger.error(f"未找到模拟器{idx}的信息")
return False, {}, DeviceStatus.UNKNOWN
self.logger.debug(f"获取模拟器{idx}信息: {emulator_info}")
# 计算状态码
if emulator_info.in_android == 1:
status = DeviceStatus.ONLINE
elif emulator_info.in_android == 2:
if emulator_info.vbox_pid > 0:
status = DeviceStatus.STARTING
# 雷电启动后, vbox_pid为-1, 目前不知道有什么区别
else:
status = DeviceStatus.STARTING
elif emulator_info.in_android == 0:
status = DeviceStatus.OFFLINE
else:
status = DeviceStatus.UNKNOWN
self.logger.debug(f"获取模拟器{idx}状态: {status}")
return True, emulator_info, status
except: # noqa: E722
self.logger.error(f"获取模拟器{idx}信息失败")
return False, {}, DeviceStatus.UNKNOWN
async def _get_all_info(self) -> dict[int, EmulatorInfo]:
result = self.runner.run("list2")
# self.logger.debug(f"全部信息{result.stdout.strip()}")
if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result}")
emulators: dict[int, EmulatorInfo] = {}
data = result.stdout.strip()
for line in data.strip().splitlines():
parts = line.strip().split(",")
if len(parts) != 10:
raise ValueError(f"数据格式错误: {line}")
try:
info = EmulatorInfo(
idx=int(parts[0]),
title=parts[1],
top_hwnd=int(parts[2]),
bind_hwnd=int(parts[3]),
in_android=int(parts[4]),
pid=int(parts[5]),
vbox_pid=int(parts[6]),
width=int(parts[7]),
height=int(parts[8]),
density=int(parts[9]),
)
emulators[info.idx] = info
except Exception as e:
self.logger.warning(f"解析失败: {line}, 错误: {e}")
pass
return emulators
# ?wk雷电你都返回了什么啊
async def get_all_info(self) -> dict[str, dict[str, str]]:
"""
解析_emulator_info字典提取idx和title便于前端显示
"""
raw_data = await self._get_all_info()
result: dict[str, dict[str, str]] = {}
for info in raw_data.values():
OK, device_info, status = await self.get_device_info(
str(info.idx), raw_data
)
result[str(info.idx)] = {"title": info.title, "status": str(status)}
return result
async def send_boss_key(
self,
boss_keys: list[int],
result: EmulatorInfo,
is_show: bool = False,
# True: 显示, False: 隐藏
) -> bool:
"""
发送BOSS键
Args:
idx (str): 模拟器索引
boss_keys (list[int]): BOSS键的虚拟键码列表
result (EmulatorInfo): 模拟器信息
is_show (bool, optional): 将要隐藏或显示窗口,默认为 False隐藏
"""
hwnd = result.top_hwnd
try:
# 使用键盘工具发送按键组合
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
if not success:
return False
# 等待系统处理
await asyncio.sleep(0.5)
# 检查窗口可见性是否符合预期
current_visible = win32gui.IsWindowVisible(hwnd)
expected_visible = is_show
if current_visible == expected_visible:
return True
else:
# 如果第一次没有成功,再试一次
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
if not success:
return False
await asyncio.sleep(0.5)
current_visible = win32gui.IsWindowVisible(hwnd)
return current_visible == expected_visible
except Exception as e:
self.logger.error(f"发送BOSS键失败: {e}")
return False
async def hide_device(
self,
idx: str,
boss_keys: list[int] = [],
) -> tuple[bool, int]:
"""隐藏设备窗口"""
OK, result, status = await self.get_device_info(idx)
if not OK or not isinstance(result, EmulatorInfo):
return False, DeviceStatus.UNKNOWN
if status != DeviceStatus.ONLINE:
return False, status
return await self.send_boss_key(boss_keys, result, False), status
async def show_device(
self,
idx: str,
boss_keys: list[int] = [],
) -> tuple[bool, int]:
"""显示设备窗口"""
OK, result, status = await self.get_device_info(idx)
if not OK or not isinstance(result, EmulatorInfo):
return False, DeviceStatus.UNKNOWN
if status != DeviceStatus.ONLINE:
return False, status
return await self.send_boss_key(boss_keys, result, True), status
async def get_adb_ports(self, pid: int) -> int:
"""使用psutil获取adb端口"""
try:
process = psutil.Process(pid)
connections = process.connections(kind="inet")
for conn in connections:
if conn.status == psutil.CONN_LISTEN and conn.laddr.port != 2222:
return conn.laddr.port
return 0 # 如果没有找到合适的端口返回0
except: # noqa: E722
return 0
if __name__ == "__main__":
MANAGER_PATH = (
r"C:\leidian\LDPlayer9\dnconsole.exe" # 替换为实际的dnconsole.exe路径
)
idx = "0" # 替换为实际存在的模拟器实例索
manager = LDManager(MANAGER_PATH)
# asyncio.run(manager._get_all_info())
a = asyncio.run(manager.start("0"))
print(a)

View File

@@ -1,220 +0,0 @@
import asyncio
import json
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
from app.utils.logger import get_logger
class MumuManager(BaseDevice):
"""
基于MuMuManager.exe的模拟器管理
"""
def __init__(self, exe_path: str) -> None:
"""_summary_
Args:
exe_path (str): MuMuManager.exe的绝对路径
"""
self.runner = ExeRunner(exe_path, "utf-8")
self.logger = get_logger("MuMu管理器")
self.wait_time = 60 # 配置获取 后续改一下 单位为s
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
"""
启动指定模拟器
Returns:
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
"""
status = await self.get_status(idx)
if status != DeviceStatus.OFFLINE:
self.logger.error(
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
)
return False, status, {}
if package_name:
result = self.runner.run(
"control",
"-v",
idx,
"launch",
"-pkg",
package_name,
)
else:
result = self.runner.run(
"control",
"-v",
idx,
"launch",
)
# 参考命令 MuMuManager.exe control -v 2 launch
self.logger.debug(f"启动结果:{result}")
if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result}")
i = 0
while i < self.wait_time * 10:
status = await self.get_status(idx)
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
return False, status, {}
if status == DeviceStatus.ONLINE:
OK, info = await self.get_device_info(idx)
self.logger.debug(info)
if OK:
data = json.loads(info)
adb_port = data.get("adb_port")
adb_host_ip = data.get("adb_host_ip")
if adb_port and adb_host_ip:
return (
True,
status,
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
)
return True, status, {}
await asyncio.sleep(0.1)
i += 1
return False, DeviceStatus.UNKNOWN, {}
async def close(self, idx: str) -> tuple[bool, int]:
"""
关闭指定模拟器
Returns:
tuple[bool, int]: 是否成功, 当前状态码
"""
status = await self.get_status(idx)
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
return False, DeviceStatus.NOT_FOUND
result = self.runner.run(
"control",
"-v",
idx,
"shutdown",
)
# 参考命令 MuMuManager.exe control -v 2 shutdown
if result.returncode != 0:
return True, DeviceStatus.OFFLINE
i = 0
while i < self.wait_time * 10:
status = await self.get_status(idx)
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
return False, status
if status == DeviceStatus.OFFLINE:
return True, DeviceStatus.OFFLINE
await asyncio.sleep(0.1)
i += 1
return False, DeviceStatus.UNKNOWN
async def get_status(self, idx: str, data: str | None = None) -> int:
if not data:
OK, result_str = await self.get_device_info(idx)
self.logger.debug(f"获取状态结果{result_str}")
else:
OK, result_str = True, data
try:
result_json = json.loads(result_str)
if OK:
if result_json["is_android_started"]:
return DeviceStatus.STARTING
elif result_json["is_process_started"]:
return DeviceStatus.ONLINE
else:
return DeviceStatus.OFFLINE
else:
if result_json["errmsg"] == "unknown error":
return DeviceStatus.UNKNOWN
else:
return DeviceStatus.ERROR
except json.JSONDecodeError as e:
self.logger.error(f"JSON解析错误: {e}")
return DeviceStatus.UNKNOWN
async def get_device_info(self, idx: str) -> tuple[bool, str]:
result = self.runner.run(
"info",
"-v",
idx,
)
self.logger.debug(f"获取模拟器{idx}信息: {result}")
if result.returncode != 0:
return False, result.stdout.strip()
else:
return True, result.stdout.strip()
async def _get_all_info(self) -> str:
result = self.runner.run(
"info",
"-v",
"all",
)
# self.logger.debug(f"result{result.stdout.strip()}")
if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result}")
return result.stdout.strip()
async def get_all_info(self) -> dict[str, dict[str, str]]:
json_data = await self._get_all_info()
data = json.loads(json_data)
result: dict[str, dict[str, str]] = {}
if not data:
return result
if isinstance(data, dict) and "index" in data and "name" in data:
index = data["index"]
name = data["name"]
status = self.get_status(index, json_data)
result[index] = {
"title": name,
"status": str(status),
}
elif isinstance(data, dict):
for key, value in data.items():
if isinstance(value, dict) and "index" in value and "name" in value:
index = value["index"]
name = value["name"]
status = await self.get_status(index)
result[index] = {
"title": name,
"status": str(status),
}
return result
async def hide_device(self, idx: str) -> tuple[bool, int]:
"""隐藏设备窗口"""
status = await self.get_status(idx)
if status != DeviceStatus.ONLINE:
return False, status
result = self.runner.run(
"control",
"-v",
idx,
"hide_window",
)
if result.returncode != 0:
return False, status
return True, DeviceStatus.ONLINE
async def show_device(self, idx: str) -> tuple[bool, int]:
"""显示设备窗口"""
status = await self.get_status(idx)
if status != DeviceStatus.ONLINE:
return False, status
result = self.runner.run(
"control",
"-v",
idx,
"show_window",
)
if result.returncode != 0:
return False, status
return True, DeviceStatus.ONLINE

View File

@@ -1,104 +0,0 @@
import subprocess
import os
from abc import ABC, abstractmethod
from enum import IntEnum
class DeviceStatus(IntEnum):
ONLINE = 0
"""设备在线"""
OFFLINE = 1
"""设备离线"""
STARTING = 2
"""设备开启中"""
CLOSEING = 3
"""设备关闭中"""
ERROR = 4
"""错误"""
NOT_FOUND = 5
"""未找到设备"""
UNKNOWN = 10
class ExeRunner:
def __init__(self, exe_path, encoding) -> None:
"""
指定 exe 路径
!请传入绝对路径,使用/分隔路径
"""
if not os.path.isfile(exe_path):
raise FileNotFoundError(f"找不到文件: {exe_path}")
self.exe_path = os.path.abspath(exe_path) # 转为绝对路径
self.encoding = encoding
def run(self, *args) -> subprocess.CompletedProcess[str]:
"""
执行命令,返回结果
"""
cmd = [self.exe_path] + list(args)
print(f"执行: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding=self.encoding,
errors="replace",
)
return result
class BaseDevice(ABC):
@abstractmethod
async def start(self, idx: str, package_name: str) -> tuple[bool, int, dict]:
"""
启动设备
返回值: (是否成功, 状态码, 启动信息)
"""
...
@abstractmethod
async def close(self, idx: str) -> tuple[bool, int]:
"""
关闭设备或服务
返回值: (是否成功, 状态码)
"""
...
@abstractmethod
async def get_status(self, idx: str) -> int:
"""
获取指定模拟器当前状态
返回值: 状态码
"""
...
@abstractmethod
async def hide_device(self, idx: str) -> tuple[bool, int]:
"""
隐藏设备窗口
返回值: (是否成功, 状态码)
"""
...
@abstractmethod
async def show_device(self, idx: str) -> tuple[bool, int]:
"""
显示设备窗口
返回值: (是否成功, 状态码)
"""
...
async def get_all_info(self) -> dict[str, dict[str, str]]:
"""
获取设备信息
返回值: 设备字典
结构示例:
{
"0":{
"title": 模拟器名字,
"status": "1"
}
}
"""
...

View File

@@ -1,70 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 MoeSnowyFox
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
from loguru import logger as _logger
import sys
from pathlib import Path
(Path.cwd() / "debug").mkdir(parents=True, exist_ok=True)
_logger.remove()
_logger.add(
sink=Path.cwd() / "debug/app.log",
level="INFO",
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
enqueue=True,
backtrace=True,
diagnose=True,
rotation="1 week",
retention="1 month",
compression="zip",
)
_logger.add(
sink=sys.stderr,
level="DEBUG",
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
enqueue=True,
backtrace=True,
diagnose=True,
colorize=True,
)
_logger = _logger.patch(lambda record: record["extra"].setdefault("module", "未知模块"))
def get_logger(module_name: str):
"""
获取一个绑定 module 名的日志器
:param module_name: 模块名称, 如 "用户管理"
:return: 绑定后的 logger
"""
return _logger.bind(module=module_name)
__all__ = ["get_logger"]

143
app/utils/package.py Normal file
View File

@@ -0,0 +1,143 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA打包程序
v4.4
作者DLmaster_361
"""
import os
import sys
import json
import shutil
from pathlib import Path
def version_text(version_numb: list) -> str:
"""将版本号列表转为可读的文本信息"""
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
version = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
return version
def version_info_markdown(info: dict) -> str:
"""将版本信息字典转为markdown信息"""
version_info = ""
for key, value in info.items():
version_info += f"## {key}\n"
for v in value:
version_info += f"- {v}\n"
return version_info
if __name__ == "__main__":
root_path = Path(sys.argv[0]).resolve().parent
with (root_path / "resources/version.json").open(mode="r", encoding="utf-8") as f:
version = json.load(f)
main_version_numb = list(map(int, version["main_version"].split(".")))
print("Packaging AUTO_MAA main program ...")
os.system(
"powershell -Command python -m nuitka --standalone --onefile --mingw64 --windows-uac-admin"
" --enable-plugins=pyside6 --windows-console-mode=attach"
" --onefile-tempdir-spec='{TEMP}\\AUTO_MAA'"
" --windows-icon-from-ico=resources\\icons\\AUTO_MAA.ico"
" --company-name='AUTO_MAA Team' --product-name=AUTO_MAA"
f" --file-version={version['main_version']}"
f" --product-version={version['main_version']}"
" --file-description='AUTO_MAA Component'"
" --copyright='Copyright © 2024-2025 DLmaster361'"
" --assume-yes-for-downloads --output-filename=AUTO_MAA"
" --remove-output main.py"
)
print("AUTO_MAA main program packaging completed !")
print("start to create setup program ...")
(root_path / "AUTO_MAA").mkdir(parents=True, exist_ok=True)
shutil.move(root_path / "AUTO_MAA.exe", root_path / "AUTO_MAA/")
shutil.copytree(root_path / "app", root_path / "AUTO_MAA/app")
shutil.copytree(root_path / "resources", root_path / "AUTO_MAA/resources")
shutil.copy(root_path / "main.py", root_path / "AUTO_MAA/")
shutil.copy(root_path / "requirements.txt", root_path / "AUTO_MAA/")
shutil.copy(root_path / "README.md", root_path / "AUTO_MAA/")
shutil.copy(root_path / "LICENSE", root_path / "AUTO_MAA/")
with (root_path / "app/utils/AUTO_MAA.iss").open(mode="r", encoding="utf-8") as f:
iss = f.read()
iss = (
iss.replace(
'#define MyAppVersion ""',
f'#define MyAppVersion "{version["main_version"]}"',
)
.replace(
'#define MyAppPath ""', f'#define MyAppPath "{root_path / "AUTO_MAA"}"'
)
.replace('#define OutputDir ""', f'#define OutputDir "{root_path}"')
)
with (root_path / "AUTO_MAA.iss").open(mode="w", encoding="utf-8") as f:
f.write(iss)
os.system(f'ISCC "{root_path / "AUTO_MAA.iss"}"')
(root_path / "AUTO_MAA_Setup").mkdir(parents=True, exist_ok=True)
shutil.move(root_path / "AUTO_MAA-Setup.exe", root_path / "AUTO_MAA_Setup")
shutil.make_archive(
base_name=root_path / f"AUTO_MAA_{version_text(main_version_numb)}",
format="zip",
root_dir=root_path / "AUTO_MAA_Setup",
base_dir=".",
)
print("setup program created !")
(root_path / "AUTO_MAA.iss").unlink(missing_ok=True)
shutil.rmtree(root_path / "AUTO_MAA")
shutil.rmtree(root_path / "AUTO_MAA_Setup")
all_version_info = {}
for v_i in version["version_info"].values():
for key, value in v_i.items():
if key in all_version_info:
all_version_info[key] += value.copy()
else:
all_version_info[key] = value.copy()
(root_path / "version_info.txt").write_text(
f"{version_text(main_version_numb)}\n\n<!--{json.dumps(version["version_info"], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
encoding="utf-8",
)

View File

@@ -1,70 +0,0 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025 AUTO-MAS Team
# This file is part of AUTO-MAS.
# AUTO-MAS is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO-MAS is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
import base64
import win32crypt
def dpapi_encrypt(
note: str, description: None | str = None, entropy: None | bytes = None
) -> str:
"""
使用Windows DPAPI加密数据
:param note: 数据明文
:type note: str
:param description: 描述信息
:type description: str
:param entropy: 随机熵
:type entropy: bytes
:return: 加密后的数据
:rtype: str
"""
if note == "":
return ""
encrypted = win32crypt.CryptProtectData(
note.encode("utf-8"), description, entropy, None, None, 0
)
return base64.b64encode(encrypted).decode("utf-8")
def dpapi_decrypt(note: str, entropy: None | bytes = None) -> str:
"""
使用Windows DPAPI解密数据
:param note: 数据密文
:type note: str
:param entropy: 随机熵
:type entropy: bytes
:return: 解密后的明文
:rtype: str
"""
if note == "":
return ""
decrypted = win32crypt.CryptUnprotectData(
base64.b64decode(note), entropy, None, None, 0
)
return decrypted[1].decode("utf-8")

View File

@@ -1,411 +0,0 @@
# AUTO-MAS 后端任务调度逻辑与WebSocket消息格式说明
## 1. 任务调度架构概览
AUTO-MAS 后端采用基于 AsyncIO 的异步任务调度系统,主要由以下核心组件构成:
### 1.1 核心组件
- **TaskManager**: 任务调度器,负责任务的创建、运行、停止和清理
- **Broadcast**: 消息广播系统,负责在不同组件间传递消息
- **WebSocket**: 与前端的实时通信通道
- **Config**: 配置管理系统,包含脚本配置、队列配置等
### 1.2 任务类型
系统支持三种主要任务模式:
1. **设置脚本** - 直接执行单个脚本配置
2. **自动代理** - 按队列顺序自动执行多个脚本
3. **人工排查** - 手动排查和执行任务
## 2. 任务调度流程
### 2.1 任务创建流程
```
前端请求 → API接口 → TaskManager.add_task() → 任务验证 → 创建异步任务 → 返回任务ID
```
**具体步骤:**
1. **任务验证**: 根据模式和UID验证任务配置是否存在
2. **重复检查**: 确保相同任务未在运行中
3. **任务创建**: 使用`asyncio.create_task()`创建异步任务
4. **回调设置**: 添加任务完成回调用于清理工作
### 2.2 任务执行流程
#### 设置脚本模式
```
获取脚本配置 → 确定脚本类型(MAA/General) → 创建对应Manager → 执行任务
```
#### 自动代理模式
```
获取队列配置 → 构建任务列表 → 逐个执行脚本 → 更新状态 → 发送完成信号
```
### 2.3 任务状态管理
- **等待**: 任务已加入队列但未开始执行
- **运行**: 任务正在执行中
- **跳过**: 任务因重复或其他原因被跳过
- **完成**: 任务执行完毕
## 3. WebSocket 消息系统
### 3.1 消息基础结构
所有WebSocket消息都遵循统一的JSON格式
```json
{
"id": "消息ID或任务ID",
"type": "消息类型",
"data": {
"具体数据": "根据类型而定"
}
}
```
### 3.2 消息类型详解
#### 3.2.1 Update 类型 - 数据更新
**用途**: 通知前端更新界面数据,"user_list"仅给出当前处于`运行`状态的脚本的用户列表值
**常见数据格式:**
```json
{
"id": "task-uuid",
"type": "Update",
"data": {
"user_list": [
{
"name": "用户名",
"status": "运行状态",
"config": "配置信息"
}
]
}
}
```
```json
{
"id": "task-uuid",
"type": "Update",
"data": {
"task_dict": [
{
"script_id": "脚本ID",
"status": "等待/运行/完成/跳过",
"name": "脚本名称",
"user_list": [
{
"name": "用户名",
"status": "运行状态",
"config": "配置信息"
}
]
}
]
}
}
```
```json
{
"id": "task-uuid",
"type": "Update",
"data": {
"task_list": [
{
"script_id": "脚本ID",
"status": "等待/运行/完成/跳过",
"name": "脚本名称"
}
]
}
}
```
```json
{
"id": "task-uuid",
"type": "Update",
"data": {
"log": "任务执行日志内容"
}
}
```
#### 3.2.2 Info 类型 - 信息显示
**用途**: 向前端发送需要显示的信息,包括普通信息、警告和错误
**数据格式:**
```json
{
"id": "task-uuid",
"type": "Info",
"data": {
"Error": "错误信息内容"
}
}
```
```json
{
"id": "task-uuid",
"type": "Info",
"data": {
"Warning": "警告信息内容"
}
}
```
```json
{
"id": "task-uuid",
"type": "Info",
"data": {
"Info": "普通信息内容"
}
}
```
#### 3.2.3 Message 类型 - 对话框请求
**用途**: 请求前端弹出对话框显示重要信息
**数据格式:**
```json
{
"id": "task-uuid",
"type": "Message",
"data": {
"title": "对话框标题",
"content": "对话框内容",
"type": "info/warning/error"
}
}
```
#### 3.2.4 Signal 类型 - 程序信号
**用途**: 发送程序控制信号和状态通知
**常见信号:**
**任务完成信号:**
```json
{
"id": "task-uuid",
"type": "Signal",
"data": {
"Accomplish": "任务完成后调度台显示的日志内容"
}
}
```
**电源操作信号:**
```json
{
"id": "task-uuid",
"type": "Signal",
"data": {
"power": "NoAction/KillSelf/Sleep/Hibernate/Shutdown/ShutdownForce",
}
}
```
**心跳信号:**
```json
{
"id": "Main",
"type": "Signal",
"data": {
"Ping": "无描述"
}
}
```
```json
{
"id": "Main",
"type": "Signal",
"data": {
"Pong": "无描述"
}
}
```
## 4. 任务管理器详细说明
### 4.1 TaskManager 核心方法
#### add_task(mode: str, uid: str)
- **功能**: 添加新任务到调度队列
- **参数**:
- `mode`: 任务模式 ("设置脚本", "自动代理", "人工排查")
- `uid`: 任务唯一标识符
- **返回**: 任务UUID
#### stop_task(task_id: str)
- **功能**: 停止指定任务
- **参数**:
- `task_id`: 任务ID支持 "ALL" 停止所有任务
#### run_task(mode: str, task_id: UUID, actual_id: Optional[UUID])
- **功能**: 执行具体任务逻辑
- **流程**: 根据模式选择相应的执行策略
### 4.2 任务执行器
#### GeneralManager
- **用途**: 处理通用脚本任务
- **特点**: 支持自定义脚本路径和参数
- **配置**: 基于 GeneralConfig 和 GeneralUserConfig
#### MaaManager
- **用途**: 处理MAA (明日方舟助手) 专用任务
- **特点**: 支持模拟器控制、ADB连接、游戏自动化
- **配置**: 基于 MaaConfig 和 MaaUserConfig
## 5. 消息广播系统
### 5.1 Broadcast 机制
- **设计模式**: 发布-订阅模式
- **功能**: 实现组件间解耦的消息传递
- **特点**: 支持多个订阅者同时接收消息
### 5.2 消息流向
```
任务执行器 → Broadcast → WebSocket → 前端界面
其他订阅者
```
## 6. 配置管理系统
### 6.1 配置类型
- **ScriptConfig**: 脚本配置包含MAA和General两种类型
- **QueueConfig**: 队列配置,定义自动代理任务的执行顺序
- **GlobalConfig**: 全局系统配置
### 6.2 配置操作
- **锁机制**: 防止配置在使用时被修改
- **实时更新**: 支持动态加载配置变更
- **类型验证**: 确保配置数据的正确性
## 7. API 接口说明
### 7.1 任务控制接口
**创建任务**
- **端点**: `POST /api/dispatch/start`
- **请求体**:
```json
{
"mode": "自动代理|人工排查|设置脚本",
"taskId": "目标任务ID"
}
```
- **响应**:
```json
{
"code": 200,
"status": "success",
"message": "操作成功",
"websocketId": "新任务ID"
}
```
**停止任务**
- **端点**: `POST /api/dispatch/stop`
- **请求体**:
```json
{
"taskId": "要停止的任务ID"
}
```
**电源操作**
- **端点**: `POST /api/dispatch/power`
- **请求体**:
```json
{
"signal": "NoAction|Shutdown|ShutdownForce|Hibernate|Sleep|KillSelf"
}
```
### 7.2 WebSocket 连接
**端点**: `WS /api/core/ws`
**连接特性**:
- 同时只允许一个WebSocket连接
- 自动心跳检测 (15秒超时)
- 连接断开时自动清理资源
## 8. 错误处理机制
### 8.1 异常类型
- **ValueError**: 配置验证失败
- **RuntimeError**: 任务状态冲突
- **TimeoutError**: 操作超时
- **ConnectionError**: 连接相关错误
### 8.2 错误响应格式
```json
{
"id": "相关任务ID",
"type": "Info",
"data": {
"Error": "具体错误描述"
}
}
```
## 9. 性能和监控
### 9.1 日志系统
- **分层日志**: 按模块划分日志记录器
- **实时监控**: 支持日志实时推送到前端
- **文件轮转**: 自动管理日志文件大小
### 9.2 资源管理
- **进程管理**: 自动清理子进程
- **内存监控**: 防止内存泄漏
- **连接池**: 复用数据库和网络连接
## 10. 安全考虑
### 10.1 输入验证
- **参数校验**: 使用 Pydantic 模型验证
- **路径安全**: 防止路径遍历攻击
- **命令注入**: 严格控制执行的命令参数
### 10.2 权限控制
- **单一连接**: 限制WebSocket连接数量
- **操作限制**: 防止重复或冲突操作
- **资源保护**: 防止资源滥用
---
*此文档基于 AUTO-MAS v5.0.0 版本编写详细的API文档和配置说明请参考相关配置文件和源代码注释。*

View File

@@ -1,126 +0,0 @@
# TaskManager WebSocket消息处理功能实现
## 功能概述
根据后端TaskManager的WebSocket消息机制实现了前端对ID为"TaskManager"的WebSocket消息的完整处理逻辑。
## 后端TaskManager消息分析
### 消息格式
```json
{
"id": "TaskManager",
"type": "Signal",
"data": {
"newTask": "任务UUID"
}
}
```
### 触发时机
当后端启动时运行的队列开始执行时TaskManager会发送此消息通知前端有新任务被自动创建。
## 前端实现
### 1. WebSocket订阅机制
`useSchedulerLogic.ts`中添加了以下功能:
- **subscribeToTaskManager()**: 订阅ID为"TaskManager"的WebSocket消息
- **handleTaskManagerMessage()**: 处理TaskManager发送的消息
- **createSchedulerTabForTask()**: 根据任务ID自动创建调度台
### 2. 自动调度台创建逻辑
当收到`newTask`信号时,系统会:
1. **检查重复**: 验证是否已存在相同websocketId的调度台
2. **创建调度台**: 自动创建新的调度台标签页
3. **设置状态**: 直接将调度台状态设置为"运行"
4. **建立连接**: 立即订阅该任务的WebSocket消息
5. **用户提示**: 显示成功创建的消息提示
### 3. 调度台特性
自动创建的调度台具有以下特性:
- 标题格式:`自动调度台{编号}`
- 初始状态:`运行`
- 可关闭:`true`(但运行时不可删除)
- 自动订阅:立即开始接收任务消息
### 4. 生命周期管理
- **初始化**: 在组件挂载时调用`initialize()`订阅TaskManager消息
- **清理**: 在组件卸载时取消TaskManager订阅
- **任务结束**: 复用现有的任务结束处理逻辑
## 代码修改点
### 1. useSchedulerLogic.ts
```typescript
// 新增TaskManager消息订阅
const subscribeToTaskManager = () => {
ws.subscribe('TaskManager', {
onMessage: (message) => handleTaskManagerMessage(message)
})
}
// 新增TaskManager消息处理
const handleTaskManagerMessage = (wsMessage: any) => {
if (type === 'Signal' && data && data.newTask) {
createSchedulerTabForTask(data.newTask)
}
}
// 新增自动调度台创建
const createSchedulerTabForTask = (taskId: string) => {
// 创建运行状态的调度台并立即订阅
}
```
### 2. index.vue
```typescript
// 生命周期中添加初始化调用
onMounted(() => {
initialize() // 订阅TaskManager消息
loadTaskOptions()
})
```
## 功能特点
### 1. 无缝集成
- 完全复用现有的调度台逻辑和UI组件
- 与手动创建的调度台行为一致
- 支持所有现有功能(日志显示、任务总览、消息处理等)
### 2. 状态同步
- 调度台状态与后端任务状态严格同步
- 支持任务完成后的自动状态更新
- 正确处理WebSocket连接的建立和清理
### 3. 用户体验
- 自动切换到新创建的调度台
- 提供清晰的成功提示
- 防止重复创建相同任务的调度台
### 4. 错误处理
- 检查消息格式的有效性
- 防止重复订阅和创建
- 优雅处理异常情况
## 测试验证
功能实现后需要验证以下场景:
1. **启动时队列**: 后端启动时运行的队列应自动创建调度台
2. **消息接收**: 调度台应正确接收和显示任务消息
3. **状态更新**: 任务状态变化应正确反映在UI上
4. **任务结束**: 任务完成后应正确清理资源
5. **重复处理**: 相同任务不应创建多个调度台
## 兼容性
- 完全向后兼容现有功能
- 不影响手动创建的调度台
- 保持现有的WebSocket消息处理机制
- 复用所有现有的UI组件和样式

View File

@@ -1,427 +0,0 @@
# useWebSocket API 参考文档
## 概述
`useWebSocket()` 组合式函数提供了完整的 WebSocket 通信接口,包含消息订阅、连接管理、状态监控等核心功能。
## 导出函数详解
### 1. subscribe() - 订阅消息
```typescript
const subscribe = (
filter: SubscriptionFilter,
handler: (message: WebSocketBaseMessage) => void
): string
```
#### 参数说明
**filter: SubscriptionFilter**
```typescript
interface SubscriptionFilter {
type?: string // 消息类型过滤器(可选)
id?: string // 消息ID过滤器可选
needCache?: boolean // 是否启用缓存回放(可选)
}
```
**handler: Function**
- 消息处理回调函数
- 参数: `message: WebSocketBaseMessage`
- 无返回值
#### 返回值
- `string`: 唯一的订阅ID用于后续取消订阅
#### 使用示例
```typescript
// 订阅所有消息
const allMsgSub = subscribe({}, (msg) => {
console.log('收到消息:', msg)
})
// 订阅特定类型消息
const taskSub = subscribe(
{ type: 'TaskUpdate', needCache: true },
(msg) => {
console.log('任务更新:', msg.data)
}
)
// 订阅特定ID消息
const specificSub = subscribe(
{ id: 'TaskManager' },
(msg) => {
console.log('任务管理器消息:', msg)
}
)
// 精确订阅同时匹配type和id
const preciseSub = subscribe(
{ type: 'Progress', id: 'task_001', needCache: true },
(msg) => {
console.log('特定任务进度:', msg.data)
}
)
```
#### 特殊功能
- **自动回放**: 如果 `needCache: true`,会立即回放匹配的历史消息
- **过滤优先级**: type + id > type > id > 全部
- **引用计数**: 多个订阅共享缓存,自动管理内存
---
### 2. unsubscribe() - 取消订阅
```typescript
const unsubscribe = (subscriptionId: string): void
```
#### 参数说明
**subscriptionId: string**
-`subscribe()` 返回的订阅ID
- 必须是有效的订阅ID
#### 使用示例
```typescript
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
// 取消订阅
unsubscribe(subId)
// Vue 组件中的最佳实践
import { onUnmounted } from 'vue'
const setupSubscription = () => {
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
onUnmounted(() => {
unsubscribe(subId)
})
}
```
#### 自动清理
- 自动减少缓存引用计数
- 引用计数为0时清理相关缓存
- 不会影响其他订阅者
---
### 3. sendRaw() - 发送消息
```typescript
const sendRaw = (type: string, data?: any, id?: string): void
```
#### 参数说明
**type: string** (必需)
- 消息类型标识
- 后端用于路由消息
**data: any** (可选)
- 消息负载数据
- 可以是任何可序列化的对象
**id: string** (可选)
- 消息标识符
- 用于消息跟踪和响应匹配
#### 使用示例
```typescript
// 发送简单消息
sendRaw('Hello')
// 发送带数据的消息
sendRaw('TaskStart', {
taskId: '12345',
config: { timeout: 30000 }
})
// 发送带ID的消息便于追踪响应
sendRaw('GetTaskStatus', { taskId: '12345' }, 'query_001')
// 发送控制信号
sendRaw('Signal', {
command: 'pause',
reason: '用户手动暂停'
}, 'TaskManager')
```
#### 发送条件
- 仅在 WebSocket 连接为 `OPEN` 状态时发送
- 连接异常时静默失败(不抛出异常)
- 自动JSON序列化
---
### 4. getConnectionInfo() - 获取连接信息
```typescript
const getConnectionInfo = (): ConnectionInfo
```
#### 返回值类型
```typescript
interface ConnectionInfo {
connectionId: string // 连接唯一标识
status: WebSocketStatus // 当前连接状态
subscriberCount: number // 当前订阅者数量
moduleLoadCount: number // 模块加载计数
wsReadyState: number | null // WebSocket原生状态
isConnecting: boolean // 是否正在连接
hasHeartbeat: boolean // 是否启用心跳
hasEverConnected: boolean // 是否曾经连接成功
reconnectAttempts: number // 重连尝试次数
isPersistentMode: boolean // 是否持久化模式
}
```
#### 使用示例
```typescript
const info = getConnectionInfo()
console.log('连接ID:', info.connectionId)
console.log('连接状态:', info.status)
console.log('订阅者数量:', info.subscriberCount)
// 检查连接是否健康
const isHealthy = info.status === '已连接' &&
info.hasHeartbeat &&
info.wsReadyState === WebSocket.OPEN
// 监控重连情况
if (info.reconnectAttempts > 0) {
console.log(`已重连 ${info.reconnectAttempts} 次`)
}
```
#### 调试用途
- 诊断连接问题
- 监控连接质量
- 统计使用情况
---
### 5. status - 连接状态
```typescript
const status: Ref<WebSocketStatus>
```
#### 状态类型
```typescript
type WebSocketStatus = '连接中' | '已连接' | '已断开' | '连接错误'
```
#### 状态说明
| 状态 | 描述 | 触发条件 |
|------|------|----------|
| `'连接中'` | 正在建立连接 | WebSocket.CONNECTING |
| `'已连接'` | 连接成功 | WebSocket.OPEN |
| `'已断开'` | 连接断开 | WebSocket.CLOSED |
| `'连接错误'` | 连接异常 | WebSocket.onerror |
#### 使用示例
```typescript
import { watch } from 'vue'
const { status } = useWebSocket()
// 监听状态变化
watch(status, (newStatus) => {
console.log('连接状态变化:', newStatus)
switch (newStatus) {
case '已连接':
console.log('✅ WebSocket 连接成功')
break
case '已断开':
console.log('❌ WebSocket 连接断开')
break
case '连接错误':
console.log('⚠️ WebSocket 连接错误')
break
case '连接中':
console.log('🔄 WebSocket 连接中...')
break
}
})
// 在模板中显示状态
// <div>连接状态: {{ status }}</div>
```
#### 响应式特性
- Vue 响应式 Ref 对象
- 自动更新 UI
- 可用于计算属性和监听器
---
### 6. backendStatus - 后端状态
```typescript
const backendStatus: Ref<BackendStatus>
```
#### 状态类型
```typescript
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
```
#### 状态说明
| 状态 | 描述 | 含义 |
|------|------|------|
| `'unknown'` | 未知状态 | 初始状态,尚未检测 |
| `'starting'` | 启动中 | 后端服务正在启动 |
| `'running'` | 运行中 | 后端服务正常运行 |
| `'stopped'` | 已停止 | 后端服务已停止 |
| `'error'` | 错误状态 | 后端服务异常 |
#### 使用示例
```typescript
const { backendStatus, restartBackend } = useWebSocket()
// 监听后端状态
watch(backendStatus, (newStatus) => {
console.log('后端状态:', newStatus)
switch (newStatus) {
case 'running':
console.log('✅ 后端服务运行正常')
break
case 'stopped':
console.log('⏹️ 后端服务已停止')
break
case 'error':
console.log('❌ 后端服务异常')
// 可以提示用户或自动重启
break
case 'starting':
console.log('🚀 后端服务启动中...')
break
}
})
// 根据状态显示不同UI
const statusColor = computed(() => {
switch (backendStatus.value) {
case 'running': return 'green'
case 'error': return 'red'
case 'starting': return 'orange'
default: return 'gray'
}
})
```
#### 自动管理
- 每3秒自动检测一次
- 异常时自动尝试重启最多3次
- 集成 Electron 进程管理
---
## 完整使用示例
```typescript
import { onMounted, onUnmounted, watch } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
export default {
setup() {
const {
subscribe,
unsubscribe,
sendRaw,
getConnectionInfo,
status,
backendStatus
} = useWebSocket()
let taskSubscription: string
let systemSubscription: string
onMounted(() => {
// 订阅任务消息
taskSubscription = subscribe(
{ type: 'TaskUpdate', needCache: true },
(message) => {
console.log('任务更新:', message.data)
}
)
// 订阅系统消息
systemSubscription = subscribe(
{ id: 'System' },
(message) => {
console.log('系统消息:', message)
}
)
// 发送初始化消息
sendRaw('ClientReady', {
timestamp: Date.now()
}, 'System')
})
onUnmounted(() => {
// 清理订阅
if (taskSubscription) unsubscribe(taskSubscription)
if (systemSubscription) unsubscribe(systemSubscription)
})
// 监听连接状态
watch([status, backendStatus], ([wsStatus, beStatus]) => {
console.log(`WS: ${wsStatus}, Backend: ${beStatus}`)
})
// 获取连接信息
const connectionInfo = getConnectionInfo()
return {
status,
backendStatus,
connectionInfo,
sendMessage: (type: string, data: any) => sendRaw(type, data)
}
}
}
```
## 最佳实践
### 1. 订阅管理
- 总是在组件卸载时取消订阅
- 使用 `needCache: true` 确保不丢失消息
- 避免重复订阅相同的消息类型
### 2. 错误处理
- 监听连接状态变化
- 根据后端状态调整UI显示
- 实现重连提示和手动重启
### 3. 性能优化
- 精确的过滤条件减少不必要的处理
- 合理使用缓存避免消息丢失
- 及时取消不需要的订阅
### 4. 调试技巧
- 使用 `getConnectionInfo()` 诊断问题
- 开发环境下查看控制台日志
- 监控订阅者数量避免内存泄漏

View File

@@ -1,336 +0,0 @@
## 核心架构设计
### 1. 全局持久化存储
```typescript
const WS_STORAGE_KEY = Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')
```
- 使用 `Symbol.for()` 确保全局唯一性
- 存储在 `window` 对象上,实现跨组件共享
- 避免多次实例化,确保连接唯一性
### 2. 状态管理结构
```typescript
interface GlobalWSStorage {
wsRef: WebSocket | null // WebSocket 实例
status: Ref<WebSocketStatus> // 连接状态
subscriptions: Ref<Map<string, WebSocketSubscription>> // 订阅管理
cacheMarkers: Ref<Map<string, CacheMarker>> // 缓存标记
cachedMessages: Ref<Array<CachedMessage>> // 消息缓存
// ... 其他状态
}
```
## 核心功能模块
### 1. 配置管理
```typescript
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
const HEARTBEAT_INTERVAL = 15000 // 心跳间隔
const HEARTBEAT_TIMEOUT = 5000 // 心跳超时
const BACKEND_CHECK_INTERVAL = 3000 // 后端检查间隔
const MAX_RESTART_ATTEMPTS = 3 // 最大重启尝试次数
const RESTART_DELAY = 2000 // 重启延迟
const MAX_QUEUE_SIZE = 50 // 最大队列大小
const MESSAGE_TTL = 60000 // 消息过期时间
```
**要点**:
- 所有时间配置使用毫秒为单位
- 可根据网络环境调整超时时间
- 队列大小限制防止内存泄漏
### 2. 消息订阅系统
#### 订阅过滤器
```typescript
interface SubscriptionFilter {
type?: string // 消息类型过滤
id?: string // 消息ID过滤
needCache?: boolean // 是否需要缓存
}
```
#### 订阅机制
```typescript
export const subscribe = (
filter: SubscriptionFilter,
handler: (message: WebSocketBaseMessage) => void
): string => {
// 1. 生成唯一订阅ID
// 2. 创建订阅记录
// 3. 添加缓存标记
// 4. 回放匹配的缓存消息
}
```
**要点**:
- 支持按 `type``id` 的组合过滤
- 自动回放缓存消息,确保不丢失历史数据
- 返回订阅ID用于后续取消订阅
### 3. 智能缓存系统
#### 缓存标记机制
```typescript
interface CacheMarker {
type?: string
id?: string
refCount: number // 引用计数
}
```
#### 缓存策略
- **引用计数**: 订阅时 +1取消订阅时 -1
- **自动清理**: 引用计数为 0 时删除标记
- **TTL机制**: 消息超过 60 秒自动过期
- **大小限制**: 每个队列最多保留 50 条消息
### 4. 心跳检测机制
```typescript
const startGlobalHeartbeat = (ws: WebSocket) => {
global.heartbeatTimer = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
const pingTime = Date.now()
global.lastPingTime = pingTime
ws.send(JSON.stringify({
type: 'Signal',
data: { Ping: pingTime, connectionId: global.connectionId }
}))
}
}, HEARTBEAT_INTERVAL)
}
```
### 5. 后端服务监控
#### 状态检测
```typescript
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
```
#### 自动重启逻辑
```typescript
const restartBackend = async (): Promise<boolean> => {
// 1. 防重入检查
// 2. 递增重启计数
// 3. 调用 Electron API 启动后端
// 4. 更新状态
}
```
### 6. 连接控制机制
#### 连接权限控制
```typescript
const allowedConnectionReasons = ['后端启动后连接', '后端重启后重连']
const checkConnectionPermission = () => getGlobalStorage().allowNewConnection
```
#### 连接锁机制
```typescript
let isGlobalConnectingLock = false
const acquireConnectionLock = () => {
if (isGlobalConnectingLock) return false
isGlobalConnectingLock = true
return true
}
```
**AI 开发要点**:
- 防止并发连接导致的竞态条件
- 只允许特定原因的连接请求
- 确保全局唯一连接
## 消息流处理
### 1. 消息匹配算法
```typescript
const messageMatchesFilter = (message: WebSocketBaseMessage, filter: SubscriptionFilter): boolean => {
// 如果都不指定,匹配所有消息
if (!filter.type && !filter.id) return true
// 如果只指定type
if (filter.type && !filter.id) return message.type === filter.type
// 如果只指定id
if (!filter.type && filter.id) return message.id === filter.id
// 如果同时指定type和id必须都匹配
return message.type === filter.type && message.id === filter.id
}
```
### 2. 消息分发流程
```
WebSocket 接收消息
JSON 解析
遍历所有订阅者
匹配过滤条件
调用处理器函数
检查是否需要缓存
添加到缓存队列
```
## 外部接口设计
### 1. 主要导出函数
```typescript
export function useWebSocket() {
return {
subscribe, // 订阅消息
unsubscribe, // 取消订阅
sendRaw, // 发送消息
getConnectionInfo, // 获取连接信息
status, // 连接状态
backendStatus, // 后端状态
restartBackend, // 重启后端
getBackendStatus, // 获取后端状态
}
}
```
### 2. 特殊接口
```typescript
export const connectAfterBackendStart = async (): Promise<boolean>
```
- 后端启动后的连接入口
- 启动后端监控
- 设置连接权限
## 错误处理策略
### 1. 连接错误处理
- WebSocket 连接失败时设置状态为 '连接错误'
- 通过后端监控检测服务状态
- 自动重启后端服务
### 2. 消息处理错误
- 订阅处理器异常时记录警告但不中断其他订阅者
- JSON 解析失败时静默忽略
- 发送消息失败时静默处理
### 3. 后端故障处理
```typescript
const handleBackendFailure = async () => {
if (global.backendRestartAttempts >= MAX_RESTART_ATTEMPTS) {
// 显示错误对话框,提示重启应用
Modal.error({
title: '后端服务异常',
content: '后端服务多次重启失败,请重启整个应用程序。'
})
return
}
// 自动重启逻辑
}
```
## 调试和监控
### 1. 调试模式
```typescript
const DEBUG = process.env.NODE_ENV === 'development'
const log = (...args: any[]) => {
if (DEBUG) console.log('[WebSocket]', ...args)
}
```
### 2. 连接信息监控
```typescript
const getConnectionInfo = () => ({
connectionId: global.connectionId,
status: global.status.value,
subscriberCount: global.subscriptions.value.size,
moduleLoadCount: global.moduleLoadCount,
wsReadyState: global.wsRef ? global.wsRef.readyState : null,
isConnecting: global.isConnecting,
hasHeartbeat: !!global.heartbeatTimer,
hasEverConnected: global.hasEverConnected,
reconnectAttempts: global.reconnectAttempts,
isPersistentMode: true,
})
```
## AI 开发建议
### 1. 使用模式
```typescript
// 在 Vue 组件中使用
const { subscribe, unsubscribe, sendRaw, status } = useWebSocket()
// 订阅特定类型消息
const subId = subscribe(
{ type: 'TaskUpdate', needCache: true },
(message) => {
console.log('收到任务更新:', message.data)
}
)
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribe(subId)
})
```
### 2. 扩展建议
- 添加消息重试机制
- 实现消息优先级队列
- 支持消息压缩
- 添加连接质量监控
### 3. 性能优化点
- 使用 `Object.freeze()` 冻结配置对象
- 考虑使用 Web Worker 处理大量消息
- 实现消息批处理机制
- 添加消息去重功能
### 4. 安全考虑
- 验证消息来源
- 实现消息签名机制
- 添加连接认证
- 防止消息注入攻击
## 依赖关系
### 1. 外部依赖
- `vue`: 响应式系统和组合式API
- `ant-design-vue`: UI组件库Modal
- `schedulerHandlers`: 默认消息处理器
### 2. 运行时依赖
- `window.electronAPI`: Electron主进程通信
- WebSocket API: 浏览器原生支持
## 总结
这个 WebSocket 组合式函数是一个功能完整、设计精良的实时通信解决方案。它不仅解决了基本的 WebSocket 连接问题,还提供了高级功能如智能缓存、自动重连、后端监控等。
**核心优势**:
1. 全局持久化连接,避免重复建立
2. 智能订阅系统,支持精确过滤
3. 自动缓存回放,确保数据完整性
4. 完善的错误处理和自动恢复
5. 详细的调试和监控信息
**适用场景**:
- 实时数据展示
- 任务状态监控
- 系统通知推送
- 双向通信应用

View File

@@ -1,2 +0,0 @@
VITE_APP_ENV='prod'
VITE_APP_VERSION='1.0.1'

View File

@@ -1,2 +0,0 @@
VITE_APP_ENV='dev'
VITE_APP_VERSION='0.9.0'

28
frontend/.gitignore vendored
View File

@@ -1,28 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.kiro
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
dist-electron
.yarn
.yarnrc.yml

View File

@@ -1,3 +0,0 @@
dist
node_modules
dist-electron

View File

@@ -1,9 +0,0 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

View File

@@ -1,60 +0,0 @@
# AUTO-MAS Frontend
基于 Vue 3 + TypeScript + Ant Design Vue + Electron 的桌面应用程序。
## 功能特性
- 🎨 使用 Ant Design Vue 组件库
- 🌙 支持深色模式(跟随系统/深色/浅色)
- 🎨 支持多种主题色切换
- 📱 响应式侧边栏布局
- 🔧 内置开发者工具
- ⚡ 基于 Vite 的快速开发体验
## 项目结构
```
src/
├── components/ # 组件
│ └── AppLayout.vue # 主布局组件
├── views/ # 页面
│ ├── Home.vue # 主页
│ ├── Scripts.vue # 脚本管理
│ ├── Plans.vue # 计划管理
│ ├── Queue.vue # 调度队列
│ ├── Scheduler.vue # 调度中心
│ ├── History.vue # 历史记录
│ └── Settings.vue # 设置页面
├── router/ # 路由配置
├── composables/ # 组合式函数
│ └── useTheme.ts # 主题管理
└── main.ts # 应用入口
```
## 开发
### 安装依赖
```bash
yarn install
```
### 开发模式
直接打开electron窗口
```bash
yarn dev
```
### 构建
```bash
yarn build
```
## 技术栈
- **前端框架**: Vue 3 + TypeScript
- **UI 组件库**: Ant Design Vue 4.x
- **图标**: @ant-design/icons-vue
- **路由**: Vue Router 4
- **构建工具**: Vite
- **桌面端**: Electron
- **包管理**: Yarn

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron'
window.addEventListener('DOMContentLoaded', () => {
console.log('预加载脚本已加载')
})
// 暴露安全的 API 给渲染进程
contextBridge.exposeInMainWorld('electronAPI', {
openDevTools: () => ipcRenderer.invoke('open-dev-tools'),
selectFolder: () => ipcRenderer.invoke('select-folder'),
selectFile: (filters?: any[]) => ipcRenderer.invoke('select-file', filters),
openUrl: (url: string) => ipcRenderer.invoke('open-url', url),
// 窗口控制
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
appQuit: () => ipcRenderer.invoke('app-quit'),
// 进程管理
getRelatedProcesses: () => ipcRenderer.invoke('get-related-processes'),
killAllProcesses: () => ipcRenderer.invoke('kill-all-processes'),
forceExit: () => ipcRenderer.invoke('force-exit'),
// 初始化相关API
checkEnvironment: () => ipcRenderer.invoke('check-environment'),
checkCriticalFiles: () => ipcRenderer.invoke('check-critical-files'),
downloadPython: (mirror?: string) => ipcRenderer.invoke('download-python', mirror),
installPip: () => ipcRenderer.invoke('install-pip'),
downloadGit: () => ipcRenderer.invoke('download-git'),
checkGitUpdate: () => ipcRenderer.invoke('check-git-update'),
installDependencies: (mirror?: string) => ipcRenderer.invoke('install-dependencies', mirror),
cloneBackend: (repoUrl?: string) => ipcRenderer.invoke('clone-backend', repoUrl),
updateBackend: (repoUrl?: string) => ipcRenderer.invoke('update-backend', repoUrl),
startBackend: () => ipcRenderer.invoke('start-backend'),
stopBackend: () => ipcRenderer.invoke('stop-backend'),
// 管理员权限相关
checkAdmin: () => ipcRenderer.invoke('check-admin'),
restartAsAdmin: () => ipcRenderer.invoke('restart-as-admin'),
// 配置文件操作
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
loadConfig: () => ipcRenderer.invoke('load-config'),
resetConfig: () => ipcRenderer.invoke('reset-config'),
// 托盘设置实时更新
updateTraySettings: (uiSettings: any) => ipcRenderer.invoke('update-tray-settings', uiSettings),
// 日志文件操作
getLogPath: () => ipcRenderer.invoke('get-log-path'),
getLogFiles: () => ipcRenderer.invoke('get-log-files'),
getLogs: (lines?: number, fileName?: string) => ipcRenderer.invoke('get-logs', lines, fileName),
clearLogs: (fileName?: string) => ipcRenderer.invoke('clear-logs', fileName),
cleanOldLogs: (daysToKeep?: number) => ipcRenderer.invoke('clean-old-logs', daysToKeep),
// 保留原有方法以兼容现有代码
saveLogsToFile: (logs: string) => ipcRenderer.invoke('save-logs-to-file', logs),
loadLogsFromFile: () => ipcRenderer.invoke('load-logs-from-file'),
// 文件系统操作
openFile: (filePath: string) => ipcRenderer.invoke('open-file', filePath),
showItemInFolder: (filePath: string) => ipcRenderer.invoke('show-item-in-folder', filePath),
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('download-progress', (_, progress) => callback(progress))
},
removeDownloadProgressListener: () => {
ipcRenderer.removeAllListeners('download-progress')
},
})

View File

@@ -1,62 +0,0 @@
import * as https from 'https'
import * as fs from 'fs'
import { BrowserWindow } from 'electron'
import * as http from 'http'
let mainWindow: BrowserWindow | null = null
export function setMainWindow(window: BrowserWindow) {
mainWindow = window
}
export function downloadFile(url: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`开始下载文件: ${url}`)
console.log(`保存路径: ${outputPath}`)
const file = fs.createWriteStream(outputPath)
// 创建HTTP客户端兼容https和http
const client = url.startsWith('https') ? https : http
client
.get(url, response => {
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
let downloadedSize = 0
console.log(`文件大小: ${totalSize} bytes`)
response.pipe(file)
response.on('data', chunk => {
downloadedSize += chunk.length
const progress = totalSize ? Math.round((downloadedSize / totalSize) * 100) : 0
console.log(`下载进度: ${progress}% (${downloadedSize}/${totalSize})`)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
progress,
status: 'downloading',
message: `下载中... ${progress}%`,
})
}
})
file.on('finish', () => {
file.close()
console.log(`文件下载完成: ${outputPath}`)
resolve()
})
file.on('error', err => {
console.error(`文件写入错误: ${err.message}`)
fs.unlink(outputPath, () => {}) // 删除不完整的文件
reject(err)
})
})
.on('error', err => {
console.error(`下载错误: ${err.message}`)
reject(err)
})
})
}

View File

@@ -1,34 +0,0 @@
import * as path from 'path'
import * as fs from 'fs'
import { app } from 'electron'
// 获取应用根目录
export function getAppRoot(): string {
return process.env.NODE_ENV === 'development' ? process.cwd() : path.dirname(app.getPath('exe'))
}
// 检查环境
export function checkEnvironment(appRoot: string) {
const environmentPath = path.join(appRoot, 'environment')
const pythonPath = path.join(environmentPath, 'python')
const gitPath = path.join(environmentPath, 'git')
const backendPath = path.join(appRoot, 'backend')
const requirementsPath = path.join(backendPath, 'requirements.txt')
const pythonExists = fs.existsSync(pythonPath)
const gitExists = fs.existsSync(gitPath)
const backendExists = fs.existsSync(backendPath)
// 检查依赖是否已安装简单检查是否存在site-packages目录
const sitePackagesPath = path.join(pythonPath, 'Lib', 'site-packages')
const dependenciesInstalled =
fs.existsSync(sitePackagesPath) && fs.readdirSync(sitePackagesPath).length > 10
return {
pythonExists,
gitExists,
backendExists,
dependenciesInstalled,
isInitialized: pythonExists && gitExists && backendExists && dependenciesInstalled,
}
}

View File

@@ -1,334 +0,0 @@
import * as path from 'path'
import * as fs from 'fs'
import { spawn } from 'child_process'
import { BrowserWindow } from 'electron'
import AdmZip from 'adm-zip'
import { downloadFile } from './downloadService'
let mainWindow: BrowserWindow | null = null
export function setMainWindow(window: BrowserWindow) {
mainWindow = window
}
const gitDownloadUrl = 'https://download.auto-mas.top/d/AUTO_MAS/git.zip'
// 递归复制目录,包括文件和隐藏文件
function copyDirSync(src: string, dest: string) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true })
}
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath)
} else {
// 直接覆盖写,不需要先删除
fs.copyFileSync(srcPath, destPath)
}
}
}
// 获取Git环境变量配置
function getGitEnvironment(appRoot: string) {
const gitDir = path.join(appRoot, 'environment', 'git')
const binPath = path.join(gitDir, 'bin')
const mingw64BinPath = path.join(gitDir, 'mingw64', 'bin')
const gitCorePath = path.join(gitDir, 'mingw64', 'libexec', 'git-core')
return {
...process.env,
// 修复remote-https问题的关键确保所有Git相关路径都在PATH中
PATH: `${binPath};${mingw64BinPath};${gitCorePath};${process.env.PATH}`,
GIT_EXEC_PATH: gitCorePath,
GIT_TEMPLATE_DIR: path.join(gitDir, 'mingw64', 'share', 'git-core', 'templates'),
HOME: process.env.USERPROFILE || process.env.HOME,
// // SSL证书路径
// GIT_SSL_CAINFO: path.join(gitDir, 'mingw64', 'ssl', 'certs', 'ca-bundle.crt'),
// 禁用系统Git配置
GIT_CONFIG_NOSYSTEM: '1',
// 禁用交互式认证
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
// // 修复remote-https问题的关键环境变量
// CURL_CA_BUNDLE: path.join(gitDir, 'mingw64', 'ssl', 'certs', 'ca-bundle.crt'),
// 确保Git能找到所有必要的程序
GIT_HTTP_LOW_SPEED_LIMIT: '0',
GIT_HTTP_LOW_SPEED_TIME: '0',
}
}
// 检查是否为Git仓库
function isGitRepository(dirPath: string): boolean {
const gitDir = path.join(dirPath, '.git')
return fs.existsSync(gitDir)
}
// 下载Git
export async function downloadGit(appRoot: string): Promise<{ success: boolean; error?: string }> {
try {
const environmentPath = path.join(appRoot, 'environment')
const gitPath = path.join(environmentPath, 'git')
if (!fs.existsSync(environmentPath)) {
fs.mkdirSync(environmentPath, { recursive: true })
}
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'git',
progress: 0,
status: 'downloading',
message: '开始下载Git...',
})
}
// 使用自定义Git压缩包
const zipPath = path.join(environmentPath, 'git.zip')
await downloadFile(gitDownloadUrl, zipPath)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'git',
progress: 100,
status: 'extracting',
message: '正在解压Git...',
})
}
// 解压Git到临时目录然后移动到正确位置
console.log(`开始解压Git到: ${gitPath}`)
// 创建临时解压目录
const tempExtractPath = path.join(environmentPath, 'git_temp')
if (!fs.existsSync(tempExtractPath)) {
fs.mkdirSync(tempExtractPath, { recursive: true })
console.log(`创建临时解压目录: ${tempExtractPath}`)
}
// 解压到临时目录
const zip = new AdmZip(zipPath)
zip.extractAllTo(tempExtractPath, true)
console.log(`Git解压到临时目录: ${tempExtractPath}`)
// 检查解压后的目录结构
const tempContents = fs.readdirSync(tempExtractPath)
console.log(`临时目录内容:`, tempContents)
// 如果解压后有git子目录则从git子目录移动内容
let sourceDir = tempExtractPath
if (tempContents.length === 1 && tempContents[0] === 'git') {
sourceDir = path.join(tempExtractPath, 'git')
console.log(`检测到git子目录使用源目录: ${sourceDir}`)
}
// 确保目标Git目录存在
if (!fs.existsSync(gitPath)) {
fs.mkdirSync(gitPath, { recursive: true })
console.log(`创建Git目录: ${gitPath}`)
}
// 移动文件到最终目录
const sourceContents = fs.readdirSync(sourceDir)
for (const item of sourceContents) {
const sourcePath = path.join(sourceDir, item)
const targetPath = path.join(gitPath, item)
// 如果目标已存在,先删除
if (fs.existsSync(targetPath)) {
if (fs.statSync(targetPath).isDirectory()) {
fs.rmSync(targetPath, { recursive: true, force: true })
} else {
fs.unlinkSync(targetPath)
}
}
// 移动文件或目录
fs.renameSync(sourcePath, targetPath)
console.log(`移动: ${sourcePath} -> ${targetPath}`)
}
// 清理临时目录
fs.rmSync(tempExtractPath, { recursive: true, force: true })
console.log(`清理临时目录: ${tempExtractPath}`)
console.log(`Git解压完成到: ${gitPath}`)
// 删除zip文件
fs.unlinkSync(zipPath)
console.log(`删除临时文件: ${zipPath}`)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'git',
progress: 100,
status: 'completed',
message: 'Git安装完成',
})
}
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'git',
progress: 0,
status: 'error',
message: `Git下载失败: ${errorMessage}`,
})
}
return { success: false, error: errorMessage }
}
}
// 克隆后端代码(替换原有核心逻辑)
export async function cloneBackend(
appRoot: string,
repoUrl = 'https://github.com/AUTO-MAS-Project/AUTO-MAS.git'
): Promise<{
success: boolean
error?: string
}> {
try {
const backendPath = appRoot
const gitPath = path.join(appRoot, 'environment', 'git', 'bin', 'git.exe')
if (!fs.existsSync(gitPath)) throw new Error(`Git可执行文件不存在: ${gitPath}`)
const gitEnv = getGitEnvironment(appRoot)
// 检查 git 是否可用
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['--version'], { env: gitEnv })
proc.on('close', code => (code === 0 ? resolve() : reject(new Error('git 无法正常运行'))))
proc.on('error', reject)
})
// ==== 下面是关键逻辑 ====
if (isGitRepository(backendPath)) {
// 已是 git 仓库先更新远程URL为镜像站然后 pull
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 0,
status: 'downloading',
message: '正在更新后端代码...',
})
}
// 更新远程URL为镜像站URL避免直接访问GitHub
console.log(`更新远程URL为镜像站: ${repoUrl}`)
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['remote', 'set-url', 'origin', repoUrl], {
stdio: 'pipe',
env: gitEnv,
cwd: backendPath
})
proc.stdout?.on('data', d => console.log('git remote set-url:', d.toString()))
proc.stderr?.on('data', d => console.log('git remote set-url err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git remote set-url失败退出码: ${code}`))
)
proc.on('error', reject)
})
// 执行pull操作
await new Promise<void>((resolve, reject) => {
const proc = spawn(gitPath, ['pull'], { stdio: 'pipe', env: gitEnv, cwd: backendPath })
proc.stdout?.on('data', d => console.log('git pull:', d.toString()))
proc.stderr?.on('data', d => console.log('git pull err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git pull失败退出码: ${code}`))
)
proc.on('error', reject)
})
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 100,
status: 'completed',
message: '后端代码更新完成',
})
}
} else {
// 不是 git 仓库clone 到 tmp再拷贝出来
const tmpDir = path.join(appRoot, 'git_tmp')
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true })
fs.mkdirSync(tmpDir, { recursive: true })
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 0,
status: 'downloading',
message: '正在克隆后端代码...',
})
}
await new Promise<void>((resolve, reject) => {
const proc = spawn(
gitPath,
[
'clone',
'--progress',
'--verbose',
'--single-branch',
'--depth',
'1',
'--branch',
'feature/refactor',
repoUrl,
tmpDir,
],
{
stdio: 'pipe',
env: gitEnv,
cwd: appRoot,
}
)
proc.stdout?.on('data', d => console.log('git clone:', d.toString()))
proc.stderr?.on('data', d => console.log('git clone err:', d.toString()))
proc.on('close', code =>
code === 0 ? resolve() : reject(new Error(`git clone失败退出码: ${code}`))
)
proc.on('error', reject)
})
// 复制所有文件到 backendPathappRoot包含 .git
const tmpFiles = fs.readdirSync(tmpDir)
for (const file of tmpFiles) {
const src = path.join(tmpDir, file)
const dst = path.join(backendPath, file)
if (fs.existsSync(dst)) {
if (fs.statSync(dst).isDirectory()) fs.rmSync(dst, { recursive: true, force: true })
else fs.unlinkSync(dst)
}
if (fs.statSync(src).isDirectory()) copyDirSync(src, dst)
else fs.copyFileSync(src, dst)
}
fs.rmSync(tmpDir, { recursive: true, force: true })
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 100,
status: 'completed',
message: '后端代码克隆完成',
})
}
}
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('获取后端代码失败:', errorMessage)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'backend',
progress: 0,
status: 'error',
message: `后端代码获取失败: ${errorMessage}`,
})
}
return { success: false, error: errorMessage }
}
}

View File

@@ -1,168 +0,0 @@
import log from 'electron-log'
import * as path from 'path'
import { getAppRoot } from './environmentService'
// 移除ANSI颜色转义字符的函数
function stripAnsiColors(text: string): string {
// 匹配ANSI转义序列的正则表达式 - 更完整的模式
const ansiRegex = /\x1b\[[0-9;]*[mGKHF]|\x1b\[[\d;]*[A-Za-z]/g
return text.replace(ansiRegex, '')
}
// 获取应用安装目录下的日志路径
function getLogDirectory(): string {
const appRoot = getAppRoot()
return path.join(appRoot, 'logs')
}
// 获取当前日期的日志文件名 - 使用ISO 8601格式
function getTodayLogFileName(): string {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `frontendlog-${year}-${month}-${day}.log`
}
// 配置日志系统
export function setupLogger() {
// 设置日志文件路径到软件安装目录
const logPath = getLogDirectory()
// 确保日志目录存在
const fs = require('fs')
if (!fs.existsSync(logPath)) {
fs.mkdirSync(logPath, { recursive: true })
}
// 配置日志格式
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
// 设置主进程日志文件路径和名称 - 按日期分文件
log.transports.file.resolvePathFn = () => {
const fileName = getTodayLogFileName()
return path.join(logPath, fileName)
}
// 设置日志级别
log.transports.file.level = 'debug'
log.transports.console.level = 'debug'
// 设置文件大小限制 (50MB因为按日期分文件可以设置更大)
log.transports.file.maxSize = 50 * 1024 * 1024
// 禁用自动归档,因为我们按日期分文件
log.transports.file.archiveLog = () => {
/* do nothing */
};
// 捕获未处理的异常和Promise拒绝
log.catchErrors({
showDialog: false,
onError: (options: any) => {
log.error('未处理的错误:', options.error)
log.error('版本信息:', options.versions)
log.error('进程类型:', options.processType)
},
})
// 重写console方法将所有控制台输出重定向到日志
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
}
console.log = (...args) => {
log.info(...args)
originalConsole.log(...args)
}
console.error = (...args) => {
log.error(...args)
originalConsole.error(...args)
}
console.warn = (...args) => {
log.warn(...args)
originalConsole.warn(...args)
}
console.info = (...args) => {
log.info(...args)
originalConsole.info(...args)
}
console.debug = (...args) => {
log.debug(...args)
originalConsole.debug(...args)
}
log.info('日志系统初始化完成')
log.info(`日志文件路径: ${path.join(logPath, getTodayLogFileName())}`)
return log
}
// 导出日志实例和工具函数
export { log, stripAnsiColors }
// 获取当前日志文件路径
export function getLogPath(): string {
return path.join(getLogDirectory(), getTodayLogFileName())
}
// 获取所有日志文件列表
export function getLogFiles(): string[] {
const fs = require('fs')
const logDir = getLogDirectory()
if (!fs.existsSync(logDir)) {
return []
}
const files = fs.readdirSync(logDir)
return files
.filter((file: string) => file.match(/^frontendlog-\d{4}-\d{2}-\d{2}\.log$/))
.sort()
.reverse() // 最新的在前面
}
// 清理旧日志文件
export function cleanOldLogs(daysToKeep: number = 7) {
const fs = require('fs')
const logDir = getLogDirectory()
if (!fs.existsSync(logDir)) {
return
}
const files = fs.readdirSync(logDir)
const now = new Date()
const cutoffDate = new Date(now.getTime() - daysToKeep * 24 * 60 * 60 * 1000)
// 格式化截止日期为YYYY-MM-DD
const cutoffDateStr = cutoffDate.getFullYear() + '-' +
String(cutoffDate.getMonth() + 1).padStart(2, '0') + '-' +
String(cutoffDate.getDate()).padStart(2, '0')
files.forEach((file: string) => {
// 匹配日志文件名格式 frontendlog-YYYY-MM-DD.log
const match = file.match(/^frontendlog-(\d{4}-\d{2}-\d{2})\.log$/)
if (match) {
const fileDateStr = match[1]
if (fileDateStr < cutoffDateStr) {
const filePath = path.join(logDir, file)
try {
fs.unlinkSync(filePath)
log.info(`已删除旧日志文件: ${file}`)
} catch (error) {
log.error(`删除旧日志文件失败: ${file}`, error)
}
}
}
})
}

View File

@@ -1,642 +0,0 @@
import * as path from 'path'
import * as fs from 'fs'
import { spawn } from 'child_process'
import { BrowserWindow } from 'electron'
import AdmZip from 'adm-zip'
import { downloadFile } from './downloadService'
import { ChildProcessWithoutNullStreams } from 'node:child_process'
import { log, stripAnsiColors } from './logService'
let mainWindow: BrowserWindow | null = null
export function setMainWindow(window: BrowserWindow) {
mainWindow = window
}
// Python镜像源URL映射
const pythonMirrorUrls = {
official: 'https://www.python.org/ftp/python/3.12.0/python-3.12.0-embed-amd64.zip',
tsinghua: 'https://mirrors.tuna.tsinghua.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip',
ustc: 'https://mirrors.ustc.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip',
huawei:
'https://mirrors.huaweicloud.com/repository/toolkit/python/3.12.0/python-3.12.0-embed-amd64.zip',
aliyun: 'https://mirrors.aliyun.com/python-release/windows/python-3.12.0-embed-amd64.zip',
}
// 检查pip是否已安装
function isPipInstalled(pythonPath: string): boolean {
const scriptsPath = path.join(pythonPath, 'Scripts')
const pipExePath = path.join(scriptsPath, 'pip.exe')
const pip3ExePath = path.join(scriptsPath, 'pip3.exe')
console.log(`检查pip安装状态:`)
console.log(`Scripts目录: ${scriptsPath}`)
console.log(`pip.exe路径: ${pipExePath}`)
console.log(`pip3.exe路径: ${pip3ExePath}`)
const scriptsExists = fs.existsSync(scriptsPath)
const pipExists = fs.existsSync(pipExePath)
const pip3Exists = fs.existsSync(pip3ExePath)
console.log(`Scripts目录存在: ${scriptsExists}`)
console.log(`pip.exe存在: ${pipExists}`)
console.log(`pip3.exe存在: ${pip3Exists}`)
return scriptsExists && (pipExists || pip3Exists)
}
// 安装pip
async function installPip(pythonPath: string, appRoot: string): Promise<void> {
console.log('开始检查pip安装状态...')
const pythonExe = path.join(pythonPath, 'python.exe')
// 检查Python可执行文件是否存在
if (!fs.existsSync(pythonExe)) {
throw new Error(`Python可执行文件不存在: ${pythonExe}`)
}
// 检查pip是否已安装
if (isPipInstalled(pythonPath)) {
console.log('pip已经安装跳过安装步骤')
console.log('检测到pip.exe文件存在认为pip安装成功')
console.log('pip检查完成')
return
}
console.log('pip未安装开始安装...')
const getPipPath = path.join(pythonPath, 'get-pip.py')
const getPipUrl = 'https://download.auto-mas.top/d/AUTO_MAS/get-pip.py'
console.log(`Python可执行文件路径: ${pythonExe}`)
console.log(`get-pip.py下载URL: ${getPipUrl}`)
console.log(`get-pip.py保存路径: ${getPipPath}`)
// 下载get-pip.py
console.log('开始下载get-pip.py...')
try {
await downloadFile(getPipUrl, getPipPath)
console.log('get-pip.py下载完成')
// 检查下载的文件大小
const stats = fs.statSync(getPipPath)
console.log(`get-pip.py文件大小: ${stats.size} bytes`)
if (stats.size < 10000) {
// 如果文件小于10KB可能是无效文件
throw new Error(`get-pip.py文件大小异常: ${stats.size} bytes可能下载失败`)
}
} catch (error) {
console.error('下载get-pip.py失败:', error)
throw new Error(`下载get-pip.py失败: ${error}`)
}
// 执行pip安装
await new Promise<void>((resolve, reject) => {
console.log('执行pip安装命令...')
const process = spawn(pythonExe, [getPipPath], {
cwd: pythonPath,
stdio: 'pipe',
})
process.stdout?.on('data', data => {
const output = stripAnsiColors(data.toString())
log.info('pip安装输出:', output)
})
process.stderr?.on('data', data => {
const errorOutput = stripAnsiColors(data.toString())
log.warn('pip安装错误输出:', errorOutput)
})
process.on('close', code => {
console.log(`pip安装完成退出码: ${code}`)
if (code === 0) {
console.log('pip安装成功')
resolve()
} else {
reject(new Error(`pip安装失败退出码: ${code}`))
}
})
process.on('error', error => {
console.error('pip安装进程错误:', error)
reject(error)
})
})
// 验证pip是否安装成功
console.log('验证pip安装...')
await new Promise<void>((resolve, reject) => {
const verifyProcess = spawn(pythonExe, ['-m', 'pip', '--version'], {
cwd: pythonPath,
stdio: 'pipe',
})
verifyProcess.stdout?.on('data', data => {
const output = stripAnsiColors(data.toString())
log.info('pip版本信息:', output)
})
verifyProcess.stderr?.on('data', data => {
const errorOutput = stripAnsiColors(data.toString())
log.warn('pip版本检查错误:', errorOutput)
})
verifyProcess.on('close', code => {
if (code === 0) {
console.log('pip验证成功')
resolve()
} else {
reject(new Error(`pip验证失败退出码: ${code}`))
}
})
verifyProcess.on('error', error => {
console.error('pip验证进程错误:', error)
reject(error)
})
})
// 清理临时文件
console.log('清理临时文件...')
try {
if (fs.existsSync(getPipPath)) {
fs.unlinkSync(getPipPath)
console.log('get-pip.py临时文件已删除')
}
} catch (error) {
console.warn('清理get-pip.py文件时出错:', error)
}
console.log('pip安装和验证完成')
}
// 下载Python
export async function downloadPython(
appRoot: string,
mirror = 'ustc'
): Promise<{ success: boolean; error?: string }> {
try {
const environmentPath = path.join(appRoot, 'environment')
const pythonPath = path.join(environmentPath, 'python')
// 确保environment目录存在
if (!fs.existsSync(environmentPath)) {
fs.mkdirSync(environmentPath, { recursive: true })
}
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'python',
progress: 0,
status: 'downloading',
message: '开始下载Python...',
})
}
// 根据选择的镜像源获取下载链接
const pythonUrl =
pythonMirrorUrls[mirror as keyof typeof pythonMirrorUrls] || pythonMirrorUrls.ustc
const zipPath = path.join(environmentPath, 'python.zip')
await downloadFile(pythonUrl, zipPath)
// 检查下载的Python文件大小
const stats = fs.statSync(zipPath)
console.log(
`Python压缩包大小: ${stats.size} bytes (${(stats.size / 1024 / 1024).toFixed(2)} MB)`
)
// Python 3.12.0嵌入式版本应该大约30MB如果小于5MB可能是无效文件
if (stats.size < 5 * 1024 * 1024) {
// 5MB
fs.unlinkSync(zipPath) // 删除无效文件
throw new Error(
`Python下载文件大小异常: ${stats.size} bytes (${(stats.size / 1024).toFixed(2)} KB)。可能是对应镜像站不可用。请选择任意一个其他镜像源进行下载!`
)
}
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'python',
progress: 100,
status: 'extracting',
message: '正在解压Python...',
})
}
// 解压Python到指定目录
console.log(`开始解压Python到: ${pythonPath}`)
// 确保Python目录存在
if (!fs.existsSync(pythonPath)) {
fs.mkdirSync(pythonPath, { recursive: true })
console.log(`创建Python目录: ${pythonPath}`)
}
const zip = new AdmZip(zipPath)
zip.extractAllTo(pythonPath, true)
console.log(`Python解压完成到: ${pythonPath}`)
// 删除zip文件
fs.unlinkSync(zipPath)
console.log(`删除临时文件: ${zipPath}`)
// 启用 site-packages 支持
const pthFile = path.join(pythonPath, 'python312._pth')
if (fs.existsSync(pthFile)) {
let content = fs.readFileSync(pthFile, 'utf-8')
content = content.replace(/^#import site/m, 'import site')
fs.writeFileSync(pthFile, content, 'utf-8')
console.log('已启用 site-packages 支持')
}
// 安装pip
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'python',
progress: 80,
status: 'installing',
message: '正在安装pip...',
})
}
await installPip(pythonPath, appRoot)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'python',
progress: 100,
status: 'completed',
message: 'Python和pip安装完成',
})
}
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'python',
progress: 0,
status: 'error',
message: `Python下载失败: ${errorMessage}`,
})
}
return { success: false, error: errorMessage }
}
}
// pip镜像源URL映射
const pipMirrorUrls = {
official: 'https://pypi.org/simple/',
tsinghua: 'https://pypi.tuna.tsinghua.edu.cn/simple/',
ustc: 'https://pypi.mirrors.ustc.edu.cn/simple/',
aliyun: 'https://mirrors.aliyun.com/pypi/simple/',
douban: 'https://pypi.douban.com/simple/',
}
// 安装Python依赖
export async function installDependencies(
appRoot: string,
mirror = 'tsinghua'
): Promise<{
success: boolean
error?: string
}> {
try {
const pythonPath = path.join(appRoot, 'environment', 'python', 'python.exe')
const backendPath = path.join(appRoot)
const requirementsPath = path.join(appRoot, 'requirements.txt')
// 检查文件是否存在
if (!fs.existsSync(pythonPath)) {
throw new Error('Python可执行文件不存在')
}
if (!fs.existsSync(requirementsPath)) {
throw new Error('requirements.txt文件不存在')
}
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'dependencies',
progress: 0,
status: 'downloading',
message: '正在安装Python依赖包...',
})
}
// 获取pip镜像源URL
const pipMirrorUrl =
pipMirrorUrls[mirror as keyof typeof pipMirrorUrls] || pipMirrorUrls.tsinghua
// 使用Scripts文件夹中的pip.exe
const pythonDir = path.join(appRoot, 'environment', 'python')
const pipExePath = path.join(pythonDir, 'Scripts', 'pip.exe')
console.log(`开始安装Python依赖`)
console.log(`Python目录: ${pythonDir}`)
console.log(`pip.exe路径: ${pipExePath}`)
console.log(`requirements.txt路径: ${requirementsPath}`)
console.log(`pip镜像源: ${pipMirrorUrl}`)
// 检查pip.exe是否存在
if (!fs.existsSync(pipExePath)) {
throw new Error(`pip.exe不存在: ${pipExePath}`)
}
// 安装依赖 - 直接使用pip.exe而不是python -m pip
await new Promise<void>((resolve, reject) => {
const process = spawn(
pipExePath,
[
'install',
'-r',
requirementsPath,
'-i',
pipMirrorUrl,
'--trusted-host',
new URL(pipMirrorUrl).hostname,
],
{
cwd: backendPath,
stdio: 'pipe',
}
)
process.stdout?.on('data', data => {
const output = stripAnsiColors(data.toString())
log.info('Pip output:', output)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'dependencies',
progress: 50,
status: 'downloading',
message: '正在安装依赖包...',
})
}
})
process.stderr?.on('data', data => {
const errorOutput = stripAnsiColors(data.toString())
log.error('Pip error:', errorOutput)
})
process.on('close', code => {
console.log(`pip安装完成退出码: ${code}`)
if (code === 0) {
resolve()
} else {
reject(new Error(`依赖安装失败,退出码: ${code}`))
}
})
process.on('error', error => {
console.error('pip进程错误:', error)
reject(error)
})
})
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'dependencies',
progress: 100,
status: 'completed',
message: 'Python依赖安装完成',
})
}
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'dependencies',
progress: 0,
status: 'error',
message: `依赖安装失败: ${errorMessage}`,
})
}
return { success: false, error: errorMessage }
}
}
// 导出pip安装函数
export async function installPipPackage(
appRoot: string
): Promise<{ success: boolean; error?: string }> {
try {
const pythonPath = path.join(appRoot, 'environment', 'python')
if (!fs.existsSync(pythonPath)) {
throw new Error('Python环境不存在请先安装Python')
}
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'pip',
progress: 0,
status: 'installing',
message: '正在安装pip...',
})
}
await installPip(pythonPath, appRoot)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'pip',
progress: 100,
status: 'completed',
message: 'pip安装完成',
})
}
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
type: 'pip',
progress: 0,
status: 'error',
message: `pip安装失败: ${errorMessage}`,
})
}
return { success: false, error: errorMessage }
}
}
// 启动后端
let backendProc: ChildProcessWithoutNullStreams | null = null
/**
* 启动后端
* @param appRoot 项目根目录
* @param timeoutMs 等待启动超时(默认 30 秒)
*/
export async function startBackend(appRoot: string, timeoutMs = 30_000) {
try {
// 如果已经在运行,直接返回
if (backendProc && !backendProc.killed && backendProc.exitCode == null) {
console.log('[Backend] 已在运行, PID =', backendProc.pid)
return { success: true }
}
const pythonExe = path.join(appRoot, 'environment', 'python', 'python.exe')
const mainPy = path.join(appRoot, 'main.py')
if (!fs.existsSync(pythonExe)) {
throw new Error(`Python可执行文件不存在: ${pythonExe}`)
}
if (!fs.existsSync(mainPy)) {
throw new Error(`后端主文件不存在: ${mainPy}`)
}
console.log(`[Backend] spawn "${pythonExe}" "${mainPy}" (cwd=${appRoot})`)
backendProc = spawn(pythonExe, [mainPy], {
cwd: appRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
})
backendProc.stdout.setEncoding('utf8')
backendProc.stderr.setEncoding('utf8')
backendProc.stdout.on('data', d => {
const line = stripAnsiColors(d.toString().trim())
if (line) log.info('[Backend]', line)
})
backendProc.stderr.on('data', d => {
const line = stripAnsiColors(d.toString().trim())
if (line) log.info('[Backend]', line)
})
backendProc.once('exit', (code, signal) => {
console.log('[Backend] 退出', { code, signal })
backendProc = null
})
backendProc.once('error', e => {
console.error('[Backend] 进程错误:', e)
})
// 等待启动成功(匹配 Uvicorn 的输出)
await new Promise<void>((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
reject(new Error('后端启动超时'))
}
}, timeoutMs)
const checkReady = (buf: Buffer | string) => {
if (settled) return
const s = buf.toString()
if (/Uvicorn running|http:\/\/0\.0\.0\.0:\d+/.test(s)) {
settled = true
clearTimeout(timer)
resolve()
}
}
backendProc!.stdout.on('data', checkReady)
backendProc!.stderr.on('data', checkReady)
backendProc!.once('exit', (code, sig) => {
if (!settled) {
settled = true
clearTimeout(timer)
reject(new Error(`后端提前退出: code=${code}, signal=${sig ?? ''}`))
}
})
backendProc!.once('error', err => {
if (!settled) {
settled = true
clearTimeout(timer)
reject(err)
}
})
})
console.log('[Backend] 启动成功, PID =', backendProc.pid)
return { success: true }
} catch (e) {
console.error('[Backend] 启动失败:', e)
return { success: false, error: e instanceof Error ? e.message : String(e) }
}
}
/** 停止后端进程(如果没启动就直接返回成功) */
export async function stopBackend() {
if (!backendProc || backendProc.killed) {
console.log('[Backend] 未运行,无需停止')
return { success: true }
}
const pid = backendProc.pid
console.log('[Backend] 正在停止后端服务, PID =', pid)
return new Promise<{ success: boolean; error?: string }>(resolve => {
// 设置超时,确保不会无限等待
const timeout = setTimeout(() => {
console.warn('[Backend] 停止超时,强制结束进程')
try {
if (backendProc && !backendProc.killed) {
// 在 Windows 上使用 taskkill 强制结束进程树
if (process.platform === 'win32') {
const { exec } = require('child_process')
exec(`taskkill /f /t /pid ${pid}`, (error: any) => {
if (error) {
console.error('[Backend] taskkill 失败:', error)
} else {
console.log('[Backend] 进程树已强制结束')
}
})
} else {
backendProc.kill('SIGKILL')
}
}
} catch (e) {
console.error('[Backend] 强制结束失败:', e)
}
backendProc = null
resolve({ success: true })
}, 2000) // 2秒超时
// 清监听,避免重复日志
backendProc?.stdout?.removeAllListeners('data')
backendProc?.stderr?.removeAllListeners('data')
backendProc!.once('exit', (code, signal) => {
clearTimeout(timeout)
console.log('[Backend] 已退出', { code, signal })
backendProc = null
resolve({ success: true })
})
backendProc!.once('error', err => {
clearTimeout(timeout)
console.error('[Backend] 停止时出错:', err)
backendProc = null
resolve({ success: false, error: err instanceof Error ? err.message : String(err) })
})
try {
// 首先尝试优雅关闭
backendProc!.kill('SIGTERM')
console.log('[Backend] 已发送 SIGTERM 信号')
} catch (e) {
clearTimeout(timeout)
console.error('[Backend] kill 调用失败:', e)
backendProc = null
resolve({ success: false, error: e instanceof Error ? e.message : String(e) })
}
})
}

View File

@@ -1,125 +0,0 @@
import { exec } from 'child_process'
import * as path from 'path'
import { getAppRoot } from '../services/environmentService'
export interface ProcessInfo {
pid: number
name: string
commandLine: string
}
/**
* 获取所有相关的进程信息
*/
export async function getRelatedProcesses(): Promise<ProcessInfo[]> {
return new Promise((resolve) => {
if (process.platform !== 'win32') {
resolve([])
return
}
const appRoot = getAppRoot()
const pythonExePath = path.join(appRoot, 'environment', 'python', 'python.exe')
// 使用 wmic 获取详细的进程信息
const cmd = `wmic process where "Name='python.exe' or Name='AUTO-MAS.exe' or CommandLine like '%main.py%'" get ProcessId,Name,CommandLine /format:csv`
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error('获取进程信息失败:', error)
resolve([])
return
}
const processes: ProcessInfo[] = []
const lines = stdout.split('\n').filter(line => line.trim() && !line.startsWith('Node'))
for (const line of lines) {
const parts = line.split(',')
if (parts.length >= 4) {
const commandLine = parts[1] || ''
const name = parts[2] || ''
const pid = parseInt(parts[3]) || 0
if (pid > 0 && (
commandLine.includes(pythonExePath) ||
commandLine.includes('main.py') ||
name === 'AUTO-MAS.exe'
)) {
processes.push({ pid, name, commandLine })
}
}
}
resolve(processes)
})
})
}
/**
* 强制结束指定的进程
*/
export async function killProcess(pid: number): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32') {
resolve(false)
return
}
exec(`taskkill /f /t /pid ${pid}`, (error) => {
if (error) {
console.error(`结束进程 ${pid} 失败:`, error.message)
resolve(false)
} else {
console.log(`进程 ${pid} 已结束`)
resolve(true)
}
})
})
}
/**
* 强制结束所有相关进程
*/
export async function killAllRelatedProcesses(): Promise<void> {
console.log('开始清理所有相关进程...')
const processes = await getRelatedProcesses()
console.log(`找到 ${processes.length} 个相关进程:`)
for (const proc of processes) {
console.log(`- PID: ${proc.pid}, Name: ${proc.name}, CMD: ${proc.commandLine.substring(0, 100)}...`)
}
// 并行结束所有进程
const killPromises = processes.map(proc => killProcess(proc.pid))
await Promise.all(killPromises)
console.log('进程清理完成')
}
/**
* 等待进程结束
*/
export async function waitForProcessExit(pid: number, timeoutMs: number = 5000): Promise<boolean> {
return new Promise((resolve) => {
const startTime = Date.now()
const checkProcess = () => {
if (Date.now() - startTime > timeoutMs) {
resolve(false)
return
}
exec(`tasklist /fi "PID eq ${pid}"`, (error, stdout) => {
if (error || !stdout.includes(pid.toString())) {
resolve(true)
} else {
setTimeout(checkProcess, 100)
}
})
}
checkProcess()
})
}

View File

@@ -1,34 +0,0 @@
const vue = require('eslint-plugin-vue');
const ts = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');
const prettier = require('eslint-plugin-prettier');
module.exports = [
// 推荐的 vue3 配置
vue.configs['vue3-recommended'],
// 推荐的 ts 配置
ts.configs.recommended,
// 推荐的 prettier 配置
prettier.configs.recommended,
// 自定义规则和文件范围
{
files: ['**/*.js', '**/*.ts', '**/*.vue'],
ignores: ['dist/**', 'node_modules/**'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2021,
sourceType: 'module',
},
plugins: {
vue,
'@typescript-eslint': ts,
prettier,
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
// 如果你希望 prettier 报错,取消注释下面一行
// 'prettier/prettier': 'error',
},
},
];

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AUTO-MAS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,116 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "5.0.0-alpha.2",
"main": "dist-electron/main.js",
"scripts": {
"dev": "concurrently \"vite\" \"yarn watch:main\" \"yarn electron-dev\"",
"watch:main": "tsc -p tsconfig.electron.json --watch",
"electron-dev": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .",
"build:main": "tsc -p tsconfig.electron.json",
"build": "vite build && yarn build:main && electron-builder",
"web": "vite",
"release": "vite build && yarn build:main && electron-builder --win --publish always"
},
"build": {
"asar": true,
"asarUnpack": [],
"extraMetadata": {
"env": "prod"
},
"appId": "top.auto-mas.frontend",
"productName": "AUTO-MAS",
"files": [
"dist/**",
"dist-electron/**",
"public/**",
"!src/**",
"!**/*.map"
],
"publish": [
{
"provider": "github",
"owner": "AUTO-MAS-Project",
"repo": "AUTO-MAS"
}
],
"extraResources": [
{
"from": "src/assets",
"to": "assets",
"filter": [
"**/*"
]
}
],
"win": {
"requestedExecutionLevel": "requireAdministrator",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "zip",
"arch": [
"x64"
]
}
],
"icon": "public/AUTO-MAS.ico",
"artifactName": "AUTO-MAS-Setup-${version}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"shortcutName": "AUTO-MAS",
"differentialPackage": true
}
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@types/adm-zip": "^0.5.7",
"@types/markdown-it": "^14.1.2",
"adm-zip": "^0.5.16",
"ant-design-vue": "4.x",
"axios": "^1.11.0",
"dayjs": "^1.11.13",
"electron-log": "^5.4.3",
"form-data": "^4.0.4",
"markdown-it": "^14.1.0",
"vue": "^3.5.17",
"vue-router": "4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/node": "22.17.1",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"concurrently": "^9.2.0",
"cross-env": "^10.0.0",
"electron": "^37.2.5",
"electron-builder": "^26.0.12",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-vue": "^10.4.0",
"openapi-typescript-codegen": "^0.29.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vite": "^7.0.4",
"vite-plugin-eslint": "^1.8.1",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^2.2.12",
"wait-on": "^8.0.4"
},
"resolutions": {
"@types/node": "22.17.1"
},
"packageManager": "yarn@4.9.1"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

View File

@@ -1,98 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ConfigProvider } from 'ant-design-vue'
import { useTheme } from './composables/useTheme.ts'
import { useUpdateModal } from './composables/useUpdateChecker.ts'
import AppLayout from './components/AppLayout.vue'
import TitleBar from './components/TitleBar.vue'
import UpdateModal from './components/UpdateModal.vue'
import DevDebugPanel from './components/DevDebugPanel.vue'
import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue'
import WebSocketMessageListener from './components/WebSocketMessageListener.vue'
import WebSocketDebugPanel from './components/WebSocketDebugPanel.vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { logger } from '@/utils/logger'
const route = useRoute()
const { antdTheme, initTheme } = useTheme()
const { updateVisible, updateData, onUpdateConfirmed } = useUpdateModal()
// 判断是否为初始化页面
const isInitializationPage = computed(() => route.name === 'Initialization')
onMounted(() => {
logger.info('App组件已挂载')
initTheme()
logger.info('主题初始化完成')
})
</script>
<template>
<ConfigProvider :theme="antdTheme" :locale="zhCN">
<!-- 初始化页面使用带标题栏的全屏布局 -->
<div v-if="isInitializationPage" class="initialization-container">
<TitleBar />
<div class="initialization-content">
<router-view />
</div>
</div>
<!-- 其他页面使用带标题栏的应用布局 -->
<div v-else class="app-container">
<TitleBar />
<AppLayout />
</div>
<!-- 全局更新模态框 -->
<UpdateModal
v-model:visible="updateVisible"
:update-data="updateData"
@confirmed="onUpdateConfirmed"
/>
<!-- 开发环境调试面板 -->
<DevDebugPanel />
<!-- 全局电源倒计时弹窗 -->
<GlobalPowerCountdown />
<!-- WebSocket 消息监听组件 -->
<WebSocketMessageListener />
<!-- WebSocket 调试面板 (仅开发环境) -->
<WebSocketDebugPanel v-if="$route.query.debug === 'true'" />
</ConfigProvider>
</template>
<style>
* {
box-sizing: border-box;
}
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.initialization-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.initialization-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
/* 隐藏滚动条但保留滚动功能 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
/* 隐藏 Webkit 浏览器的滚动条 */
.initialization-content::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -1,25 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: any;
public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message);
this.name = 'ApiError';
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}

View File

@@ -1,17 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string;
readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
};

View File

@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
};

View File

@@ -1,131 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
onCancel: OnCancel
) => void
) {
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
}

View File

@@ -1,32 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>;
export type OpenAPIConfig = {
BASE: string;
VERSION: string;
WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined;
};
export const OpenAPI: OpenAPIConfig = {
BASE: '',
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};

View File

@@ -1,323 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
import FormData from 'form-data';
import { ApiError } from './ApiError';
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
};
export const isString = (value: any): value is string => {
return typeof value === 'string';
};
export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== '';
};
export const isBlob = (value: any): value is Blob => {
return (
typeof value === 'object' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag])
);
};
export const isFormData = (value: any): value is FormData => {
return value instanceof FormData;
};
export const isSuccess = (status: number): boolean => {
return status >= 200 && status < 300;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString('base64');
}
};
export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = [];
const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const process = (key: string, value: any) => {
if (isDefined(value)) {
if (Array.isArray(value)) {
value.forEach(v => {
process(key, v);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v);
});
} else {
append(key, value);
}
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = `${config.BASE}${path}`;
if (options.query) {
return `${url}${getQueryString(options.query)}`;
}
return url;
};
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => {
const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN),
resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
const headers = Object.entries({
Accept: 'application/json',
...additionalHeaders,
...options.headers,
...formHeaders,
})
.filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({
...headers,
[key]: String(value),
}), {} as Record<string, string>);
if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return headers;
};
export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body) {
return options.body;
}
return undefined;
};
export const sendRequest = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
url: string,
body: any,
formData: FormData | undefined,
headers: Record<string, string>,
onCancel: OnCancel,
axiosClient: AxiosInstance
): Promise<AxiosResponse<T>> => {
const source = axios.CancelToken.source();
const requestConfig: AxiosRequestConfig = {
url,
headers,
data: body ?? formData,
method: options.method,
withCredentials: config.WITH_CREDENTIALS,
withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
cancelToken: source.token,
};
onCancel(() => source.cancel('The user aborted a request.'));
try {
return await axiosClient.request(requestConfig);
} catch (error) {
const axiosError = error as AxiosError<T>;
if (axiosError.response) {
return axiosError.response;
}
throw error;
}
};
export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => {
if (responseHeader) {
const content = response.headers[responseHeader];
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = (response: AxiosResponse<any>): any => {
if (response.status !== 204) {
return response.data;
}
return undefined;
};
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
...options.errors,
}
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(options, result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @param axiosClient The axios client instance to use
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options, formData);
if (!onCancel.isCancelled) {
const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
const responseBody = getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = {
url,
ok: isSuccess(response.status),
status: response.status,
statusText: response.statusText,
body: responseHeader ?? responseBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};

View File

@@ -1,120 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export { ApiError } from './core/ApiError';
export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { ComboBoxItem } from './models/ComboBoxItem';
export type { ComboBoxOut } from './models/ComboBoxOut';
export type { CustomWebhook } from './models/CustomWebhook';
export type { DispatchIn } from './models/DispatchIn';
export type { GeneralConfig } from './models/GeneralConfig';
export type { GeneralConfig_Game } from './models/GeneralConfig_Game';
export type { GeneralConfig_Info } from './models/GeneralConfig_Info';
export type { GeneralConfig_Run } from './models/GeneralConfig_Run';
export type { GeneralConfig_Script } from './models/GeneralConfig_Script';
export type { GeneralUserConfig_Data } from './models/GeneralUserConfig_Data';
export type { GeneralUserConfig_Info } from './models/GeneralUserConfig_Info';
export type { GeneralUserConfig_Input } from './models/GeneralUserConfig_Input';
export type { GeneralUserConfig_Output } from './models/GeneralUserConfig_Output';
export { GetStageIn } from './models/GetStageIn';
export type { GlobalConfig_Function } from './models/GlobalConfig_Function';
export type { GlobalConfig_Input } from './models/GlobalConfig_Input';
export type { GlobalConfig_Notify } from './models/GlobalConfig_Notify';
export type { GlobalConfig_Output } from './models/GlobalConfig_Output';
export type { GlobalConfig_Start } from './models/GlobalConfig_Start';
export type { GlobalConfig_UI } from './models/GlobalConfig_UI';
export type { GlobalConfig_Update } from './models/GlobalConfig_Update';
export type { GlobalConfig_Voice } from './models/GlobalConfig_Voice';
export type { HistoryData } from './models/HistoryData';
export type { HistoryDataGetIn } from './models/HistoryDataGetIn';
export type { HistoryDataGetOut } from './models/HistoryDataGetOut';
export { HistoryIndexItem } from './models/HistoryIndexItem';
export { HistorySearchIn } from './models/HistorySearchIn';
export type { HistorySearchOut } from './models/HistorySearchOut';
export type { HTTPValidationError } from './models/HTTPValidationError';
export type { InfoOut } from './models/InfoOut';
export type { MaaConfig } from './models/MaaConfig';
export type { MaaConfig_Info } from './models/MaaConfig_Info';
export type { MaaConfig_Run } from './models/MaaConfig_Run';
export type { MaaPlanConfig } from './models/MaaPlanConfig';
export type { MaaPlanConfig_Info } from './models/MaaPlanConfig_Info';
export type { MaaPlanConfig_Item } from './models/MaaPlanConfig_Item';
export type { MaaUserConfig_Data } from './models/MaaUserConfig_Data';
export type { MaaUserConfig_Info } from './models/MaaUserConfig_Info';
export type { MaaUserConfig_Input } from './models/MaaUserConfig_Input';
export type { MaaUserConfig_Output } from './models/MaaUserConfig_Output';
export type { MaaUserConfig_Task } from './models/MaaUserConfig_Task';
export type { NoticeOut } from './models/NoticeOut';
export type { OutBase } from './models/OutBase';
export type { PlanCreateIn } from './models/PlanCreateIn';
export type { PlanCreateOut } from './models/PlanCreateOut';
export type { PlanDeleteIn } from './models/PlanDeleteIn';
export type { PlanGetIn } from './models/PlanGetIn';
export type { PlanGetOut } from './models/PlanGetOut';
export type { PlanIndexItem } from './models/PlanIndexItem';
export type { PlanReorderIn } from './models/PlanReorderIn';
export type { PlanUpdateIn } from './models/PlanUpdateIn';
export { PowerIn } from './models/PowerIn';
export type { QueueConfig } from './models/QueueConfig';
export type { QueueConfig_Info } from './models/QueueConfig_Info';
export type { QueueCreateOut } from './models/QueueCreateOut';
export type { QueueDeleteIn } from './models/QueueDeleteIn';
export type { QueueGetIn } from './models/QueueGetIn';
export type { QueueGetOut } from './models/QueueGetOut';
export type { QueueIndexItem } from './models/QueueIndexItem';
export type { QueueItem } from './models/QueueItem';
export type { QueueItem_Info } from './models/QueueItem_Info';
export type { QueueItemCreateOut } from './models/QueueItemCreateOut';
export type { QueueItemDeleteIn } from './models/QueueItemDeleteIn';
export type { QueueItemGetIn } from './models/QueueItemGetIn';
export type { QueueItemGetOut } from './models/QueueItemGetOut';
export type { QueueItemIndexItem } from './models/QueueItemIndexItem';
export type { QueueItemReorderIn } from './models/QueueItemReorderIn';
export type { QueueItemUpdateIn } from './models/QueueItemUpdateIn';
export type { QueueReorderIn } from './models/QueueReorderIn';
export type { QueueSetInBase } from './models/QueueSetInBase';
export type { QueueUpdateIn } from './models/QueueUpdateIn';
export { ScriptCreateIn } from './models/ScriptCreateIn';
export type { ScriptCreateOut } from './models/ScriptCreateOut';
export type { ScriptDeleteIn } from './models/ScriptDeleteIn';
export type { ScriptFileIn } from './models/ScriptFileIn';
export type { ScriptGetIn } from './models/ScriptGetIn';
export type { ScriptGetOut } from './models/ScriptGetOut';
export { ScriptIndexItem } from './models/ScriptIndexItem';
export type { ScriptReorderIn } from './models/ScriptReorderIn';
export type { ScriptUpdateIn } from './models/ScriptUpdateIn';
export type { ScriptUploadIn } from './models/ScriptUploadIn';
export type { ScriptUrlIn } from './models/ScriptUrlIn';
export type { SettingGetOut } from './models/SettingGetOut';
export type { SettingUpdateIn } from './models/SettingUpdateIn';
export { TaskCreateIn } from './models/TaskCreateIn';
export type { TaskCreateOut } from './models/TaskCreateOut';
export type { TimeSet } from './models/TimeSet';
export type { TimeSet_Info } from './models/TimeSet_Info';
export type { TimeSetCreateOut } from './models/TimeSetCreateOut';
export type { TimeSetDeleteIn } from './models/TimeSetDeleteIn';
export type { TimeSetGetIn } from './models/TimeSetGetIn';
export type { TimeSetGetOut } from './models/TimeSetGetOut';
export type { TimeSetIndexItem } from './models/TimeSetIndexItem';
export type { TimeSetReorderIn } from './models/TimeSetReorderIn';
export type { TimeSetUpdateIn } from './models/TimeSetUpdateIn';
export type { UpdateCheckIn } from './models/UpdateCheckIn';
export type { UpdateCheckOut } from './models/UpdateCheckOut';
export type { UserConfig_Notify } from './models/UserConfig_Notify';
export type { UserCreateOut } from './models/UserCreateOut';
export type { UserDeleteIn } from './models/UserDeleteIn';
export type { UserGetIn } from './models/UserGetIn';
export type { UserGetOut } from './models/UserGetOut';
export type { UserInBase } from './models/UserInBase';
export { UserIndexItem } from './models/UserIndexItem';
export type { UserReorderIn } from './models/UserReorderIn';
export type { UserSetIn } from './models/UserSetIn';
export type { UserUpdateIn } from './models/UserUpdateIn';
export type { ValidationError } from './models/ValidationError';
export type { VersionOut } from './models/VersionOut';
export { Service } from './services/Service';

View File

@@ -1,100 +0,0 @@
/**
* 镜像源 API 接口
* 用于从后端获取最新的镜像源配置
*/
import { OpenAPI } from '@/api'
import type { MirrorConfig } from '@/config/mirrors'
export interface MirrorApiResponse {
git?: MirrorConfig[]
python?: MirrorConfig[]
pip?: MirrorConfig[]
apiEndpoints?: {
local?: string
production?: string
proxy?: string
}
downloadLinks?: {
[category: string]: {
[key: string]: string
}
}
}
/**
* 获取镜像源配置
*/
export async function fetchMirrorConfig(): Promise<MirrorApiResponse> {
try {
const response = await fetch(`${OpenAPI.BASE}/api/mirrors`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
console.warn('获取镜像源配置失败,使用默认配置:', error)
throw error
}
}
/**
* 测试镜像源连通性
*/
export async function testMirrorConnectivity(url: string, timeout: number = 5000): Promise<{
success: boolean
speed: number
error?: string
}> {
try {
const startTime = Date.now()
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
cache: 'no-cache',
mode: 'no-cors' // 避免 CORS 问题
})
clearTimeout(timeoutId)
const speed = Date.now() - startTime
return {
success: true,
speed
}
} catch (error) {
return {
success: false,
speed: 9999,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* 批量测试镜像源
*/
export async function batchTestMirrors(mirrors: MirrorConfig[]): Promise<MirrorConfig[]> {
const promises = mirrors.map(async (mirror) => {
const result = await testMirrorConnectivity(mirror.url)
return {
...mirror,
speed: result.speed
}
})
const results = await Promise.all(promises)
// 按速度排序
return results.sort((a, b) => (a.speed || 9999) - (b.speed || 9999))
}

View File

@@ -1,15 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ComboBoxItem = {
/**
* 展示值
*/
label: string;
/**
* 实际值
*/
value: (string | null);
};

View File

@@ -1,24 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ComboBoxItem } from './ComboBoxItem';
export type ComboBoxOut = {
/**
* 状态码
*/
code?: number;
/**
* 操作状态
*/
status?: string;
/**
* 操作消息
*/
message?: string;
/**
* 下拉框选项
*/
data: Array<ComboBoxItem>;
};

View File

@@ -1,35 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CustomWebhook = {
/**
* Webhook唯一标识
*/
id: string;
/**
* Webhook名称
*/
name: string;
/**
* Webhook URL
*/
url: string;
/**
* 消息模板
*/
template: string;
/**
* 是否启用
*/
enabled?: boolean;
/**
* 自定义请求头
*/
headers?: (Record<string, string> | null);
/**
* 请求方法
*/
method?: ('POST' | 'GET' | null);
};

View File

@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DispatchIn = {
/**
* 目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID
*/
taskId: string;
};

Some files were not shown because too many files have changed in this diff Show More