Compare commits

...

673 Commits

Author SHA1 Message Date
DLmaster361
33ee5c05a7 fix: 修复无法切换git分支问题 2025-10-02 22:43:07 +08:00
DLmaster361
7952e88885 fix: 强制进入后端时,仍尝试建立ws连接 2025-10-02 15:51:32 +08:00
DLmaster361
c1f7ea6922 fix(maa): 适配前端对话框ws反馈格式 2025-10-02 14:39:47 +08:00
MoeSnowyFox
196aa9873b fix(electron): 调整对话框窗口高度- 将对话框窗口的高度从140增加到145像素
(这个高度刚刚好解决html位置问题)
2025-10-02 14:06:43 +08:00
MoeSnowyFox
334c17c055 feat(dialog): 实现对话框主题与拖拽功能 2025-10-02 02:30:05 +08:00
MoeSnowyFox
0a20ee299d feat(dialog): 实现对话框主题与拖拽功能 2025-10-02 02:29:49 +08:00
7d1ceda958 Revert "feat(gitService): 根据app版本切换git拉取的分支"
This reverts commit 2f07c72025.
2025-10-02 01:31:17 +08:00
2f07c72025 feat(gitService): 根据app版本切换git拉取的分支 2025-10-02 01:08:44 +08:00
MoeSnowyFox
5da96242aa fix(user):修复用户编辑页面的WebSocket连接问题 2025-10-02 00:39:03 +08:00
MoeSnowyFox
5069a65559 Merge remote-tracking branch 'origin/v5.0.0-alpha.3' into feature/refactor
# Conflicts:
#	frontend/src/components/WebSocketMessageListener.vue
2025-10-01 23:42:38 +08:00
21d1874602 Merge branch 'feature/refactor' into v5.0.0-alpha.3 2025-10-01 23:41:04 +08:00
MoeSnowyFox
1d91204842 feat(electron): 添加系统级对话框功能
- 在 ElectronAPI 中新增对话框相关方法:showQuestionDialog、dialogResponse、resizeDialogWindow
- 实现独立的对话框窗口 dialog.html,支持自定义标题、消息和选项- 添加对话框窗口的键盘导航和焦点管理功能
- 在主进程中实现对话框窗口的创建、显示和响应处理
- 更新 WebSocket 消息监听器组件,使用系统级对话框替代应用内弹窗- 优化进程清理逻辑,增加多种清理方法并行执行
- 重构日志工具类,改进 Electron API 调用方式
- 调整 Git 更新检查逻辑,避免直接访问 GitHub
- 移除冗余的类型定义文件,统一 Electron API 接口定义
2025-10-01 23:24:39 +08:00
MoeSnowyFox
3d204980a2 docs(websocket): 添加useWebSocket参考文档 2025-10-01 23:24:38 +08:00
MoeSnowyFox
1eae8c80e5 feat(websocket): 实现WebSocket消息监听和调试功能
- 添加WebSocketMessageListener组件用于全局消息监听- 新增WebSocketDebugPanel调试面板(仅开发环境显示)
- 在多个组件中实现基于subscriptionId的WebSocket连接管理
-为MAA和通用配置添加更可靠的连接生命周期控制
- 引入消息测试页面用于调试WebSocket消息处理- 更新调度器逻辑以支持新的WebSocket订阅机制
- 优化WebSocket连接的创建、维护和断开流程
- 添加开发环境下的调度中心调试工具导入- 重构WebSocket相关组件的导入和注册方式
- 移除冗余的电源倒计时相关状态和方法
2025-10-01 23:24:38 +08:00
DLmaster361
a72ce489bd fix: 修正后端webhook测试逻辑 2025-10-01 20:56:32 +08:00
MoeSnowyFox
efa9f28aad docs(websocket): 添加useWebSocket参考文档 2025-10-01 18:06:24 +08:00
MoeSnowyFox
4beed5048a feat(websocket): 实现WebSocket消息监听和调试功能
- 添加WebSocketMessageListener组件用于全局消息监听- 新增WebSocketDebugPanel调试面板(仅开发环境显示)
- 在多个组件中实现基于subscriptionId的WebSocket连接管理
-为MAA和通用配置添加更可靠的连接生命周期控制
- 引入消息测试页面用于调试WebSocket消息处理- 更新调度器逻辑以支持新的WebSocket订阅机制
- 优化WebSocket连接的创建、维护和断开流程
- 添加开发环境下的调度中心调试工具导入- 重构WebSocket相关组件的导入和注册方式
- 移除冗余的电源倒计时相关状态和方法
2025-10-01 17:13:31 +08:00
DLmaster361
e286fc8d55 feat: 初步完成后端自定义webhook适配;重构配置项管理体系 2025-10-01 11:05:50 +08:00
Alirea
68b1ed4238 fix(api): 删除一个logger使用 2025-09-30 08:17:44 +08:00
Alirea
e5b43a9c45 fix(notif): 修复潜在的KeyError:index问题 2025-09-29 19:28:45 +08:00
Alirea
98a2b0f176 fix(notif): 修复一处TypeError参数错误 2025-09-29 13:26:34 +08:00
DLmaster361
06770eb3cc fix: 主要核心后端添加报错捕获机制 2025-09-28 20:38:54 +08:00
Alirea
dfc403733f feat(ui): 优先尝试使用用户选择的镜像源进行后端更新,最后使用github 2025-09-28 13:54:52 +08:00
Alirea
f423f3b577 fix(notif): 修复一处get错误和else位置 2025-09-28 13:54:52 +08:00
fcd8f042b7 refactor(package): update version to 5.0.0-alpha.2 2025-09-28 01:21:18 +08:00
Alirea
3140c29832 feat(ui): 新增初始化流程自动重试 2025-09-27 20:10:10 +08:00
Alirea
1c97228bc0 fix(notif): 修复通用调度脚本邮件信息丢失bug 2025-09-27 19:08:40 +08:00
DLmaster361
8ce747a839 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-27 17:16:19 +08:00
DLmaster361
124d3e34d6 fix: 拆分用户通知的类型 2025-09-27 17:16:12 +08:00
837af62a60 refactor(webhook): 添加切换Webhook启用状态功能,优化UI交互 2025-09-27 17:14:47 +08:00
14a8a308c5 refactor(notify): 重构通知设置,支持自定义Webhook管理与模板配置 2025-09-27 16:54:50 +08:00
28ce06c92d refactor(notification): 优化模板解析逻辑,支持JSON和字符串模板处理 2025-09-27 16:48:46 +08:00
2d6dae3fc9 refactor(config): 重构通知配置项并优化Webhook获取逻辑 2025-09-27 16:32:54 +08:00
d2066e9631 feat(notify): 实现自定义Webhook通知功能 2025-09-27 16:22:04 +08:00
13caed7207 Revert "refactor: 添加自定义Webhook推送功能及模板管理"
This reverts commit 57a3505cab.
2025-09-27 15:36:20 +08:00
2c57426051 Revert "refactor: 添加自定义Webhook配置项"
This reverts commit bb602d92cf.
2025-09-27 15:36:16 +08:00
bb602d92cf refactor: 添加自定义Webhook配置项 2025-09-27 15:10:05 +08:00
57a3505cab refactor: 添加自定义Webhook推送功能及模板管理 2025-09-27 14:52:41 +08:00
MoeSnowyFox
2ab402e8ff feat:使用keyboard库 2025-09-27 04:47:54 +08:00
MoeSnowyFox
40d06cce54 Merge branch 'feature/refactor' of ssh://ssh.github.com:443/AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-27 03:34:17 +08:00
MoeSnowyFox
7b0307070e feat: 添加模拟器管理器 2025-09-27 03:32:43 +08:00
Alirea
09038aeb09 fix(notif): 修复理智回满时间不带多久回满的问题 2025-09-27 02:05:01 +08:00
Alirea
f869c19353 fix(notif): 真的修复了手贱删的括号 2025-09-27 01:35:02 +08:00
Alirea
b6fd72f0d8 fix(notif): 修复手贱删的括号 2025-09-27 01:34:15 +08:00
Alirea
f9192ef9eb fix(notif): 修复KeyError保错,兼容过往日志 2025-09-27 01:28:19 +08:00
Alirea
6f77c29e19 refactor(notif): 精简html模板文字标题 2025-09-26 23:39:37 +08:00
Alirea
4e44669695 feat(notif): 通知增加理智剩余量和理智恢复时间 2025-09-26 23:39:37 +08:00
DLmaster361
f04b7bf073 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-26 14:30:09 +08:00
DLmaster361
0ac0225d7d fix: 优化设置页部分显示效果 2025-09-26 14:30:02 +08:00
Alirea
128cf3de4c feat(ui): 日志查看的导出日志改为log格式 2025-09-26 00:39:04 +08:00
DLmaster361
e528936c8d fix: 删除脚本或用户后同步删除对应保存的配置文件 2025-09-25 23:54:20 +08:00
DLmaster361
eed0319098 fix: 修复用户页面的MAA配置按钮与通用脚本配置按钮失效 2025-09-25 23:30:54 +08:00
74ea0af9bc refactor: 后端发送ws,收到后前端会自杀 2025-09-25 22:52:56 +08:00
69fac94058 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-25 22:40:12 +08:00
29bd8a4aff refactor: 更新Git仓库检查逻辑,避免直接访问GitHub并使用镜像站进行pull操作 2025-09-25 22:40:00 +08:00
DLmaster361
54dc1d392d fix: 修复后端升级配置文件时部分字段未替换的问题 2025-09-25 21:44:32 +08:00
DLmaster361
5c55db7067 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-25 19:54:18 +08:00
DLmaster361
bbe5286601 fix: 修复通用调度的配置路径跳变 2025-09-25 19:54:12 +08:00
Alirea
06b3147b53 feat(ui): 历史记录详细日志增加字体大小调整选项 2025-09-25 19:26:02 +08:00
Alirea
dbd1731d7f feat(ui): 历史记录详细日志修复过长被撑开到视界外 2025-09-25 18:12:36 +08:00
Alirea
320d7ab17d feat(ui): 历史记录设置最小高度 2025-09-25 17:24:16 +08:00
Alirea
4ad346ed14 fix(notif): 修改通知底部链接的文档站仓库为文档站 2025-09-25 17:16:05 +08:00
DLmaster361
f9f8b56d18 fix: 修复静默不生效问题 2025-09-25 08:15:27 +08:00
2101ae9908 refactor: 移动电源操作倒计时逻辑至全局组件 GlobalPowerCountdown.vue[待测试] 2025-09-25 00:43:03 +08:00
dbc5784691 refactor: 第一次启动直接进去手动配置模式 2025-09-25 00:06:51 +08:00
MoeSnowyFox
632ad33562 fix(Scripts.vue): 调整类型选项样式以隐藏不必要的分隔符
移除 AntD 在按钮包装器之间注入的小分隔符,并设置类型选项和模式选项的高度为自动以改善布局。
2025-09-24 23:39:03 +08:00
MoeSnowyFox
d3639d44e1 fix(plan): header-actions和header-left固定于同一行 2025-09-24 23:35:26 +08:00
af28869d2a refactor: 添加进程管理功能,支持强制清理相关进程和获取进程信息 2025-09-24 23:33:45 +08:00
DLmaster361
34df37c040 fix: 调度中心ws订阅的相关方法在初始化时暴露 2025-09-24 21:43:54 +08:00
bd58a512c9 refactor: 重构WebSocket消息处理逻辑,使用全局处理函数替代订阅方式 2025-09-24 19:15:19 +08:00
DLmaster361
cf4c8cfe17 fix: 修复定时任务的调度台创建方法 2025-09-24 18:11:48 +08:00
Alirea
ff8f501b98 feat(ui): 调试工具添加打开前端控制台快捷方式 2025-09-24 15:58:03 +08:00
Alirea
59de0ec510 fix(ui): 修复调度中心切换路由后信息丢失的问题 2025-09-24 15:52:52 +08:00
DLmaster361
cda50ea134 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-24 01:01:58 +08:00
DLmaster361
97ec518e43 fix: ws消息允许接收多条遗留消息 2025-09-24 01:01:48 +08:00
d338210426 refactor: 添加电源操作显示更新逻辑,支持从后端接收状态并更新本地显示 2025-09-24 00:47:34 +08:00
b3fe49b8d1 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-24 00:25:22 +08:00
96c022c2ec refactor: 重构电源操作倒计时逻辑,改为使用全屏弹窗并由后端控制 2025-09-24 00:25:13 +08:00
DLmaster361
0f1cbfc82d fix: 修复后端电源逻辑错误 2025-09-24 00:16:54 +08:00
MoeSnowyFox
53d91fb3f8 refactor(plan):优化计划类型标签显示逻辑并简化 API 调用
移除 `shouldShowPlanTypeTag` 方法中不必要的 `plan` 参数,因为其判断逻辑仅依赖于计划列表长度。同时简化 `usePlanApi.ts` 中的请求返回逻辑,避免多余的变量赋值。此外,统一了 API 模块的导入路径为 `@/api`,以提高路径一致性与可维护性。
2025-09-23 23:45:18 +08:00
MoeSnowyFox
7e74af93a0 refactor(plan): 统一使用 MaaPlanConfig 作为默认计划类型 2025-09-23 23:45:18 +08:00
2331d7e38b refactor: 注释掉杀死后端的函数,改为由后端自杀 2025-09-23 23:39:17 +08:00
6fd46087bb refactor: 添加关闭后端程序和取消电源任务的API接口 2025-09-23 23:27:49 +08:00
DLmaster361
76f330e4d3 feat: 后端添加电源操作逻辑 2025-09-23 16:00:13 +08:00
DLmaster361
1a541cbc63 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-23 14:15:50 +08:00
DLmaster361
133ab0e78c feat: 后端添加关闭接口 2025-09-23 14:15:44 +08:00
MoeSnowyFox
35aa3cc42d feat(plan): 优化计划页面结构与加载逻辑
- 调整 template 结构,将加载状态与主要内容分离,提升可读性
- 引入 optionsLoaded 控制选项加载时机,避免初始渲染阻塞
- 增加 onMounted 和 watch逻辑确保异步数据正确处理- 统一计划类型为 MaaPlanConfig,兼容旧类型 MaaPlan- 优化 PlanHeader 新建计划按钮交互,支持动态显示按钮文本- 改进 PlanSelector 中计划类型标签显示逻辑,仅在必要时展示
2025-09-23 00:02:38 +08:00
MoeSnowyFox
4114e4ffc0 fix(plan):优化计划列表映射和保存逻辑
移除 planList 映射中未使用的 index 参数,简化代码逻辑。
调整 watch 回调执行时机,使用 flush: 'post' 确保在 DOM 更新后执行,并移除不必要的 nextTick 等待,直接调用防抖保存函数。
2025-09-22 22:41:39 +08:00
DLmaster361
22ebe7e51b fix: 后端添加定时任务防止重复启动机制 2025-09-22 15:52:11 +08:00
0437778c14 doc: 移动一下文档位置 2025-09-22 01:06:20 +08:00
70fc623f54 feat(scheduler): 实现TaskManager WebSocket消息自动创建调度台 2025-09-22 01:05:31 +08:00
MoeSnowyFox
32df65fb65 feat(plan):优化计划保存与切换逻辑,提升性能与用户体验- 在计划组件中引入防抖机制,避免频繁保存操作
- 实现异步保存队列,确保计划切换时数据不丢失
- 优化计划切换逻辑,支持后台保存并提升响应速度
- 在组件卸载前确保所有 pending 保存操作完成
- 修复 MaaPlanTable 中响应式丢失问题,优化选项缓存逻辑
- 为 PlanSelector 添加点击防抖,防止重复触发计划切换- 重构数据同步逻辑,提高表格与计划数据的同步效率
2025-09-21 23:56:27 +08:00
MoeSnowyFox
4f1e49ce85 fix(plan): 优化计划表格样式与交互 2025-09-21 23:37:27 +08:00
MoeSnowyFox
db0ac79e44 feat(plan):重构计划管理页面并新增 MAA 计划表组件
将原 Plans.vue 页面重构为 plan/index.vue
2025-09-21 23:12:18 +08:00
DLmaster361
d5331b728d fix: 后端ws不再raise无连接错误;修复定时逻辑 2025-09-21 18:09:01 +08:00
1c640c3df8 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-21 15:18:22 +08:00
de45255feb fix: 修复任务总览的状态不能自动更新的问题 2025-09-21 15:17:40 +08:00
DLmaster361
1f89639463 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-21 15:11:20 +08:00
DLmaster361
cb741c7b2e feat: 后端添加定时任务与启动时任务 2025-09-21 15:11:14 +08:00
6897f35d1e refactor: 重构调度中心的任务总览部分 2025-09-21 15:05:02 +08:00
7c34b3ca94 feat: 添加音频播放组件 2025-09-21 01:45:08 +08:00
DLmaster361
74a72961f7 fix: 重命名音效资源文件 2025-09-21 01:26:46 +08:00
DLmaster361
981c3a8624 feat: 后端挂载音频资源文件/api/res/sounds 2025-09-21 01:16:12 +08:00
DLmaster361
a16f9b2da4 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-21 01:08:41 +08:00
DLmaster361
1f85360c92 fix: 调度中心界面小范围优化 2025-09-21 01:08:33 +08:00
MoeSnowyFox
5f7cdd6a57 fix(ScriptTable):在脚本顺序未更改的情况下不触发脚本排序保存 2025-09-21 00:46:18 +08:00
Alirea
887f10ef3f fix(ui): 又给你加回来加载界面,但让他不会nodata了 2025-09-21 00:29:37 +08:00
MoeSnowyFox
14c61815c6 fix(Scripts.vue): 优化WebSocket消息处理逻辑
修复语法错误(但愿逻辑不要炸)
2025-09-21 00:13:26 +08:00
MoeSnowyFox
4abce146a9 fix(Scripts.vue): 移除冗余的加载状态组件 2025-09-21 00:09:38 +08:00
MoeSnowyFox
0e8094e52f fix(dev): 更新调试面板组件导入路径 2025-09-21 00:06:35 +08:00
MoeSnowyFox
f3680a8274 refactor(devtools): 重构调试面板为模块化组件 2025-09-20 23:58:03 +08:00
MoeSnowyFox
cd706640d9 feat(Scripts.vue): 添加MAA配置遮罩层并优化样式布局 2025-09-20 23:43:17 +08:00
MoeSnowyFox
e0ba88d7b0 feat(layout): 实现路由锁定功能并优化侧边栏样式
- 新增 `useRouteLock` 组合式函数,用于在表单编辑等场景下锁定路由切换- 在 `AppLayout.vue` 中集成路由锁定逻辑,防止用户误操作离开当前页面
- 优化侧边栏菜单样式,增强可读性与维护性
- 调整代码格式,提升模板和样式部分的可读性
2025-09-20 23:43:17 +08:00
MoeSnowyFox
4cc1d1186e feat(DevDebugPanel): 优化调试面板的拖拽和展开收起交互 2025-09-20 23:43:17 +08:00
DLmaster361
5eab1b0986 fix: 重新修正ws消息分发逻辑 2025-09-20 23:32:06 +08:00
DLmaster361
60e8ac0ce9 fix: 添加ws消息队列避免消息漏收 2025-09-20 18:41:25 +08:00
DLmaster361
a5e09bc489 fix: 重整调度中心的UI 2025-09-20 17:02:51 +08:00
MoeSnowyFox
199907eb26 feat(useSchedulerLogic):修复新建队列循环调用导致卡死问题 2025-09-19 23:58:59 +08:00
MoeSnowyFox
969e223fb4 feat(components): 添加开发环境调试面板组件 2025-09-19 23:53:15 +08:00
DLmaster361
e62b9b3943 fix: 初步完成调度中心样式优化 2025-09-19 21:36:09 +08:00
DLmaster361
a9769c6397 fix: 调整调度队列的空状态 2025-09-19 12:23:53 +08:00
DLmaster361
d21d2f9a1d Revert "feat(QueueItemManager, TimeSetManager): 增加定时列表和任务列表的拖拽功能"
This reverts commit e569930287.
2025-09-19 10:34:36 +08:00
DLmaster361
bd6d7e8189 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-19 10:21:54 +08:00
DLmaster361
c0af9d7de0 fix: 移除任务列表骨架屏 2025-09-19 10:20:54 +08:00
e569930287 feat(QueueItemManager, TimeSetManager): 增加定时列表和任务列表的拖拽功能 2025-09-19 01:45:40 +08:00
5cd0ca29a4 refactor(index.vue): 格式化一下css 2025-09-19 01:29:49 +08:00
b3bb43dcdf refactor(TabFunction): 整理设置项,整合一下 2025-09-19 01:26:43 +08:00
01ee70f3fb refactor(ScriptTable): 添加脚本和脚本用户的拖拽功能 2025-09-19 01:06:17 +08:00
DLmaster361
ed6b52451b fix: 后端移除队列项冗余校验 2025-09-19 00:34:06 +08:00
DLmaster361
6150142d62 fix: 后端修正脚本下拉框未选择值 2025-09-19 00:15:43 +08:00
DLmaster361
bf99e41cee Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-19 00:13:02 +08:00
DLmaster361
4db49e0bb9 feat: 初步优化调度队列样式 2025-09-19 00:12:55 +08:00
MoeSnowyFox
f559a71de2 refactor(Scripts): 优化脚本页面代码格式和逻辑
- 删除多余的空行
- 添加必要的逗号
- 修复用户状态更新逻辑
2025-09-18 23:57:44 +08:00
DLmaster361
b5b208c611 fix: 配置分享站换链 2025-09-18 12:15:41 +08:00
7da7a8b4f7 refactor: 添加加载状态提示,删掉白色背景 2025-09-18 00:39:28 +08:00
MoeSnowyFox
010d99ce78 feat(router): 添加路由跳转工具函数并优化计划页面跳转逻辑 2025-09-18 00:08:11 +08:00
Alirea
9946a8376a fix(ui): 删除加载界面,让他不会瞎闪了 2025-09-17 23:33:10 +08:00
Alirea
b11550c500 fix(ui): 修复历史记录nodata图片不加载的问题 2025-09-17 01:48:30 +08:00
Alirea
1e8ada6c36 feat(typescript): 新增一个asset类型声明 2025-09-17 01:47:22 +08:00
Alirea
aacf32ac61 feat(ui): 重布局历史记录,使日志拥有更大的横向空间 2025-09-17 01:16:23 +08:00
Alirea
308bb83bec feat: 简化活动活动剩余时间显示 2025-09-17 00:31:16 +08:00
DLmaster361
4dff160b82 fix(task): 修复后端在零用户情况下index缺失引发的故障 2025-09-17 00:13:05 +08:00
DLmaster361
cd1d114cac Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-17 00:00:22 +08:00
DLmaster361
f36d647594 fix(ui): 修复计划表深色模式异常 2025-09-17 00:00:16 +08:00
MoeSnowyFox
edadc6f7ce feat(router): 更新路由配置并添加调度中心页面
- 更新路由配置,将 Scheduler 组件路径修改为 ../views/scheduler/index.vue
2025-09-16 23:59:08 +08:00
DLmaster361
1585ab564d feat: 设置脚本时不再检测脚本运行状态,由用户手动保存 2025-09-16 21:04:36 +08:00
DLmaster361
6c52d5a2f0 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-16 20:36:23 +08:00
DLmaster361
63fd3d590b feat: 后端添加严格的下拉表数据校验逻辑,添加新ws消息类型task_dict 2025-09-16 20:36:16 +08:00
Alirea
139fd0bcc3 feat: 让活动剩余时间过少时的倒计时更加炫酷 2025-09-16 18:37:03 +08:00
Alirea
481a204a19 feat: 默认使用自动代理 2025-09-16 17:53:48 +08:00
DLmaster361
62877257e4 feat: 后端优化部分表的相互交互逻辑,自动移除失效的表单 2025-09-16 15:44:17 +08:00
2a493bd62a refactor: 优化标题栏样式,增强更新提示的视觉效果和交互体验 2025-09-16 00:23:21 +08:00
249e8c1a08 refactor: 清理冗余样式,优化组件结构和响应式设计 2025-09-16 00:11:09 +08:00
2d44389587 feat: 添加上传脚本配置功能,支持用户分享配置到云端 2025-09-15 23:54:58 +08:00
84ec172871 refactor: 拆分脚本编辑页面 2025-09-15 23:23:57 +08:00
ac9418f787 refactor: 重构主窗口创建逻辑,优化显示器适配和最小尺寸计算 2025-09-15 22:51:16 +08:00
Alirea
e79830565e style: 修改默认窗口大小与最小窗口大小 2025-09-15 14:02:46 +08:00
Alirea
4298961311 style: 增加资源关卡最大显示数到5,在1080P下达到最佳效果,并修改响应式布局 2025-09-15 14:02:46 +08:00
Alirea
cec8944192 feat: 优化初始化逻辑,自动模式下代码没更新不执行依赖安装 2025-09-15 14:02:46 +08:00
DLmaster361
6b78162784 fix: 初步适配日志栏 2025-09-15 10:50:25 +08:00
Alirea
7ca0dcc918 feat: 优化初始化逻辑,仅在自动模式下出现环境缺失页面 2025-09-15 10:17:27 +08:00
DLmaster361
804c3ed62c fix: 移除调试代码 2025-09-15 00:15:55 +08:00
DLmaster361
aaee9aa1ba fix: 尝试修复ws消息不显示的问题
- 但是失败了
2025-09-15 00:10:08 +08:00
660b82da7a feat: 拆分MAAUserEdit 2025-09-15 00:09:01 +08:00
MoeSnowyFox
d0d5d76f10 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor
# Conflicts:
#	frontend/src/views/MAAUserEdit.vue
2025-09-14 23:34:12 +08:00
MoeSnowyFox
4026b35636 🚸 脚本界面计划表悬浮显示信息 2025-09-14 23:31:37 +08:00
2558cfca75 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-14 23:24:53 +08:00
4b4aa8ba84 fix: 修复Git更新检查逻辑,添加仓库状态判断和更新提示 2025-09-14 23:24:05 +08:00
DLmaster361
1ae2a9bb87 fix: 再次修复上一条 2025-09-14 22:39:32 +08:00
DLmaster361
3b9d27383b fix: 修复MAA运行时错误获取user_id 2025-09-14 22:34:06 +08:00
MoeSnowyFox
17d7e3bad0 🚸 统一管理时间, 修复计划表按时间显示的错误 2025-09-14 21:57:03 +08:00
DLmaster361
8ea6c9565e Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-14 21:44:13 +08:00
DLmaster361
67b327e192 feat: 初步完成调度台适配 2025-09-14 21:42:56 +08:00
MoeSnowyFox
f98a749efa 🚸 计划表弹窗'已切换到计划模式'显示name而非uid 2025-09-14 20:01:37 +08:00
MoeSnowyFox
9d067f1c6a 🐛 通知测试使用标准api 2025-09-14 17:54:16 +08:00
DLmaster361
d1fdd5f672 feat: 后端添加自动代理队列的电源ws消息 2025-09-14 16:05:31 +08:00
DLmaster361
76fcc13d4a feat: 把历史记录调到下方 2025-09-14 15:54:12 +08:00
DLmaster361
76b32ad2c1 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-14 15:47:00 +08:00
DLmaster361
ad56904e56 feat: 初步完成计划表样式更新 2025-09-14 15:46:54 +08:00
5f12ddf332 refactor: 日志和镜像测试页面加入返回按钮;镜像站配置移动到高级设置中 2025-09-14 14:13:40 +08:00
4d8d71f294 refactor: 重构环境检查逻辑,添加环境不完整提示页面 2025-09-14 02:04:31 +08:00
a15b2bd8ce refactor: 优化镜像源加载逻辑,使用云端数据替代静态配置 2025-09-14 01:24:31 +08:00
820649225d refactor: 修复深色模式下背景看不清的问题 2025-09-14 01:16:51 +08:00
DLmaster361
d0651e2104 feat: 再次移除UpdateType字段 2025-09-14 00:51:54 +08:00
MoeSnowyFox
8910e09493 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor
# Conflicts:
#	frontend/src/views/Settings.vue
2025-09-14 00:42:54 +08:00
MoeSnowyFox
eafd6eb808 🎨 拆分settings.vue至/setting/...中 2025-09-14 00:41:54 +08:00
DLmaster361
e07c1a844a Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-14 00:37:49 +08:00
DLmaster361
6846415899 feat: 移除更新类型字段 2025-09-14 00:37:43 +08:00
MoeSnowyFox
4e2419a5f1 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-09-14 00:01:32 +08:00
MoeSnowyFox
1866495379 发送通知测试 2025-09-13 23:57:56 +08:00
DLmaster361
c4d1ed3184 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-13 23:32:33 +08:00
DLmaster361
aa6836d26d feat: 修整计划表样式 2025-09-13 23:32:26 +08:00
e49d67f501 refactor: 修改按钮文案并移除未使用的更新状态按钮 2025-09-13 17:48:13 +08:00
DLmaster361
1ab865df7b Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-13 17:43:41 +08:00
DLmaster361
b36fb89566 feat: 自动更新按钮生效 2025-09-13 17:43:36 +08:00
94dea445cf feat: 支持自定义镜像源添加、删除及重试机制 2025-09-13 17:26:39 +08:00
c8380ddb90 feat: 添加镜像配置管理功能及测试页面 2025-09-13 17:06:57 +08:00
DLmaster361
6c7a0226fd feat: 前端更新改为定时拉取 2025-09-13 14:54:11 +08:00
DLmaster361
79bd982383 feat: 修改检查更新按钮位置 2025-09-13 11:15:22 +08:00
DLmaster361
e097e40826 fix: 版本信息转临时存储;允许强制请求版本信息 2025-09-13 11:01:26 +08:00
DLmaster361
642b34eca3 feat: 添加版本信息缓存机制 2025-09-13 10:35:36 +08:00
fbd7fa6a00 refactor: 更新检查更新按钮UI,调整部分文案 2025-09-13 00:10:00 +08:00
MoeSnowyFox
19537698eb 🗑️ 迁移github地址 2025-09-12 23:34:57 +08:00
805783c85b fix: 测试时十分钟改为一分钟忘记改回去了~ 2025-09-12 20:52:43 +08:00
fd12a6359d Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-12 00:27:36 +08:00
04158a40bc feat: 添加测试通知API接口 2025-09-12 00:27:15 +08:00
DLmaster361
a10c927253 Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-12 00:10:43 +08:00
DLmaster361
b16bf0fe3f fix: 使用虚拟环境的git 2025-09-12 00:10:38 +08:00
MoeSnowyFox
47e7291885 🗑️ 修复警告 2025-09-11 23:35:42 +08:00
MoeSnowyFox
a034f10a19 🗑️ 移除ws旧版本相关代码 2025-09-11 23:25:08 +08:00
DLmaster361
80eb0bc49d fix: 更新检查后端限制4h请求一次 2025-09-11 23:06:23 +08:00
DLmaster361
90b8020c0a fix: 修正ws超时参数 2025-09-11 22:10:20 +08:00
DLmaster361
a33e2675dd fix: 修复ws相关逻辑 2025-09-11 21:57:24 +08:00
DLmaster361
ed81cc65ce Merge branch 'feature/refactor' of github.com:AUTO-MAS-Project/AUTO-MAS into feature/refactor 2025-09-10 23:07:59 +08:00
DLmaster361
fd8f602da2 fix: 完成AUTO-MAS改名 2025-09-10 23:07:52 +08:00
4b4be6fa0f feat: 添加版本检查功能及更新提示模态框 2025-09-10 22:20:39 +08:00
Alirea
ab48b91f26 test:push test 2025-09-10 21:03:10 +08:00
Alirea
8ae2e82d18 fix: 修改历史记录标题颜色大小等,统一效果 2025-09-10 20:51:17 +08:00
Alirea
d43d96e8eb fix: 删除部分组件的最大宽度限制,统一效果 2025-09-10 20:51:17 +08:00
DLmaster361
d207c65df7 feat: 添加通知测试接口 2025-09-10 20:33:05 +08:00
6138fc47b1 feat: 新增更新弹窗,以实现更新的下载 2025-09-10 20:18:11 +08:00
d9aa6305da docs: update README 2025-09-10 19:52:52 +08:00
DLmaster361
80aa2873f7 test: push test 2025-09-10 19:49:16 +08:00
MoeSnowyFox
3b7b3b8f74 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-09-10 00:11:39 +08:00
MoeSnowyFox
b4e4c97cbf 🚧 上传时同时打包exe和zip 2025-09-10 00:10:34 +08:00
DLmaster361
e3c0add012 fix: 矫正后端需要更新的逻辑 2025-09-09 23:14:29 +08:00
7237523237 fix: 逻辑写反了,修复一下 2025-09-09 21:40:46 +08:00
81d9d3d66c Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-09 21:28:23 +08:00
86f8884e45 feat: 添加后端版本检查功能及更新提示 2025-09-09 21:28:14 +08:00
MoeSnowyFox
88bc883fe1 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-09-09 21:21:22 +08:00
482077c862 feat: 添加获取后端git版本信息接口及相关类型定义 2025-09-09 21:16:26 +08:00
MoeSnowyFox
5c4fd4024b 💄 router-view增大间距至32px 2025-09-09 21:11:39 +08:00
75ff858f68 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-09 21:10:49 +08:00
64fb077d65 feat: 添加炫彩更新提示~ 2025-09-09 21:10:41 +08:00
DLmaster361
d2e45038fd Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-09 21:00:17 +08:00
DLmaster361
e3c1143c0c feat: 添加后端版本号接口 2025-09-09 21:00:11 +08:00
fb4dc7fa83 feat: 导入更新检查和下载接口 2025-09-09 20:45:35 +08:00
MoeSnowyFox
3490eff76e Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-09-09 20:20:27 +08:00
MoeSnowyFox
54cdef691a 💄 更改侧边栏ui 2025-09-09 20:20:03 +08:00
DLmaster361
4f8c12292b feat: 添加更新接口 2025-09-09 20:00:24 +08:00
AoXuan
1fd357a49c Merge pull request #74 from Zrief/z
配色修改
2025-09-09 08:22:30 +08:00
MoeSnowyFox
e144d62cdb 💄 更改侧边栏ui, 拉大字体, 增加响应式设计 2025-09-09 00:11:29 +08:00
Zrief
a4472b4f9c Merge branch 'feature/refactor' into z 2025-09-09 00:06:31 +08:00
Zrief
8de3405aa5 Update tag color and remove background style
修改碳关卡的颜色,适配浅色主题
删除指定的css配色,适配深色主题
2025-09-08 23:59:47 +08:00
Zrief
afd450eee6 Fix tag color and resolve merge conflict in Scheduler.vue
Changed the tag color for '碳-5' from 'none' to 'default' in Plans.vue. Also resolved a merge conflict in Scheduler.vue related to the .plan-header class styling.
2025-09-08 23:39:53 +08:00
DLmaster361
6798d896bc Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-08 23:06:53 +08:00
DLmaster361
b38c81b08c fix: 修复通用用户自定义脚本路径配置项初值 2025-09-08 23:06:47 +08:00
364e7a273f feat: 移除electron-updater依赖 2025-09-08 22:55:13 +08:00
a0c5334f57 feat: 添加发布脚本,添加electron-updater依赖 2025-09-08 22:42:56 +08:00
DLmaster361
41bb159542 fix: 后端对用户名进行文件夹合法性校验 2025-09-08 19:30:28 +08:00
DLmaster361
706bb8584d fix: 添加默认值校验机制 2025-09-08 15:42:45 +08:00
DLmaster361
a43b12bc17 fix: 任务的脚本列表补充完成状态 2025-09-08 14:57:40 +08:00
MoeSnowyFox
cbba670eb2 💄 更改顶边ui和侧边栏ui 2025-09-08 00:03:52 +08:00
MoeSnowyFox
ac182a7c77 🐛 修复调度中心ws逻辑 2025-09-07 23:17:52 +08:00
MoeSnowyFox
9c4e8d256a 🐛 ws统一链接 2025-09-07 19:20:06 +08:00
MoeSnowyFox
4a8fa68632 🐛 ws统一链接 2025-09-07 19:19:12 +08:00
DLmaster361
6bf30b4dc6 feat: 后端添加version上报 2025-09-07 03:21:38 +08:00
2636858fc0 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-06 02:14:59 +08:00
250cccc0b9 feat: 添加自定义关卡功能,支持动态输入和选择 2025-09-06 02:14:50 +08:00
DLmaster361
f15ed86852 feat: 同步用户与脚本配置的样式 2025-09-06 02:14:45 +08:00
AoXuan
33a2cee5ef Merge pull request #73 from Zrief/z
为 “计划” 页面初步添加简化视图模式
2025-09-06 01:38:25 +08:00
DLmaster361
bd94a26a15 fix: 移除历史记录多余的图标 2025-09-06 01:36:56 +08:00
Zrief
02788d3bfc 为 “计划” 页面初步添加简化视图模式
为 Plans.vue 页面引入 “简化视图”(类mower)模式,支持用户在计划管理时,在配置视图与简化视图之间进行切换。
添加相关 UI 控件,实现阶段(stages)批量启用 / 禁用的逻辑,以及配套样式。
更新 .gitignore 文件,将 .yarn/install-state.gz 文件纳入忽略列表(即不纳入 Git 版本控制)。
2025-09-06 01:32:50 +08:00
fc8afe6624 refactor: 彻底删除UserEdit 2025-09-06 01:17:39 +08:00
DLmaster361
93a61232cf feat: 优化历史记录样式 2025-09-06 01:06:12 +08:00
DLmaster361
c4cd53277f fix: 通用脚本用户添加额外展示项 2025-09-05 17:41:50 +08:00
DLmaster361
f3e5a03a0f feat: 初始化界面添加标题栏 2025-09-05 16:03:27 +08:00
DLmaster361
bf62c25c93 fix: 完善全局设置UI 2025-09-05 14:01:37 +08:00
b9a7b6a889 feat: 完成记忆窗口位置,最小化到托盘和托盘区图标 2025-09-05 00:27:37 +08:00
DLmaster361
1ffd8c1a5a fix: 修复活动关卡时间判断 2025-09-04 19:28:47 +08:00
DLmaster361
58bd4a1765 fix: 优化调度队列显示效果 2025-09-04 17:01:58 +08:00
DLmaster361
278800c077 fix: 修整通用用户额外脚本样式 2025-09-04 15:53:06 +08:00
DLmaster361
8142c0398f feat: 通用脚本添加路径设置同步机制 2025-09-04 14:16:21 +08:00
DLmaster
5e2224dfdb Merge pull request #70 from Alirea10/feature/refactor
fix: 修复活动结束后的显示
2025-09-04 10:26:53 +08:00
Alirea
a206bb9e5c Merge remote-tracking branch 'fork/feature/refactor' into feature/refactor 2025-09-04 10:23:49 +08:00
Alirea
79feb73b27 fix: 修复活动结束后的显示 2025-09-04 10:17:59 +08:00
DLmaster361
97678368e1 fix: 修正ws消息类型 2025-09-04 00:34:24 +08:00
DLmaster361
678e09e536 feat: 启用新icon 2025-09-04 00:13:35 +08:00
DLmaster361
377e5250f9 fix: 调整脚本根目录 2025-09-03 22:54:38 +08:00
003f150a74 refactor: 增加TitleBar组件,微调页面布局 2025-09-03 22:44:16 +08:00
DLmaster361
54e289ce56 fix: 修复通用脚本配置文件路径选择无法切换 2025-09-03 22:06:50 +08:00
f26d336195 feat: UserEdit拆分成General和MAA两个文件,方便后期维护 2025-09-03 19:12:25 +08:00
DLmaster361
4fa7313b4f fix: 修整关卡号选择展示效果 2025-09-03 17:33:48 +08:00
DLmaster361
a0a2998fcb fix: 调整主页的空状态 2025-09-03 17:13:35 +08:00
DLmaster361
5007939d1c fix: 调整关卡号开放信息缩进 2025-09-03 16:57:41 +08:00
DLmaster361
c10d39ba83 fix: 修整通用脚本文案 2025-09-03 15:42:11 +08:00
DLmaster361
d0e0af9ed7 fix: ws连接强制为单例 2025-09-03 11:58:44 +08:00
DLmaster361
1a4a6fd5a3 fix: ws连接添加应用层ping-pong机制 2025-09-03 11:53:37 +08:00
DLmaster361
f1c3818249 feat: 修整无数据状态的显示效果 2025-09-02 23:13:01 +08:00
DLmaster361
eabd8b21a1 Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-02 21:52:50 +08:00
DLmaster361
09a294b38f feat: 修整用户空白页 2025-09-02 21:52:43 +08:00
949044fcdb fix: 修复通用脚本创建时,内容没有被正确获取;修复通用脚本用户添加时报错 2025-09-02 20:46:44 +08:00
272fd9b425 refactor: update RemainedDay default value to -1 2025-09-02 20:23:27 +08:00
6787b015c5 refactor: 调整setting样式。更新gitignore 2025-09-02 20:21:39 +08:00
DLmaster361
8a21b3da9b fix: 账号ID为非必填项 2025-09-02 20:12:04 +08:00
1473fa64ed refactor: update package manager to yarn@4.9.1 in package.json 2025-09-02 20:06:27 +08:00
8eb27555f5 refactor: 脚本下的用户全部展示(之前是三个) 2025-09-02 19:54:25 +08:00
05c90689e5 refactor: 修复一下创建用户时,id传入失败 2025-09-02 19:52:01 +08:00
07adbfd68c Revert "refactor: 表单的用户名不是必须"
This reverts commit 375cf3286a.
2025-09-02 19:39:51 +08:00
375cf3286a refactor: 表单的用户名不是必须 2025-09-02 19:33:13 +08:00
2fec029cb7 Merge remote-tracking branch 'upstream/feature/refactor' into feature/refactor 2025-09-02 19:28:31 +08:00
0f4ea4786f refactor: 微调一下日志查看功能的样式 2025-09-02 19:28:09 +08:00
DLmaster361
d74e42c281 Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-02 19:26:47 +08:00
DLmaster361
b926880ae9 Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-02 19:26:41 +08:00
DLmaster361
ffd13e8362 fix: 修整MAA脚本配置文案 2025-09-02 19:26:05 +08:00
baf8a642b8 refactor: 日志查看功能&优化日志功能 2025-09-02 19:26:04 +08:00
6b00c8a6be refactor: 添加回log功能~这次好用了~ 2025-09-02 18:20:20 +08:00
f4fe4ec019 refactor: 添加后端停止功能,优化后端启动逻辑 2025-09-02 17:17:37 +08:00
17cc1fa8a3 refactor: 移除logger。全部改为console输出 2025-09-02 16:58:53 +08:00
c91cd55586 refactor(.gitignore): add dist-electron to ignore list 2025-09-02 16:23:34 +08:00
24b9dc98d0 refactor(package): 打个补丁~修复一下不能自动编译的问题 2025-09-02 16:19:49 +08:00
909af77649 refactor(services): 补一下js 2025-09-02 16:09:43 +08:00
c8b1bf6d08 refactor(mirrors): 修改git推荐镜像站,移除无效镜像站 2025-09-02 15:09:11 +08:00
7f622de857 refactor(components): 修复代码错误,重新格式化代码 2025-09-02 14:50:24 +08:00
b586e311be refactor(steps): 调整一下样式,默认窗口高度调整为1000 2025-09-02 13:33:27 +08:00
11e3c2281e refactor(steps): 优化初始化页面的布局。让IDE能够正确识别@ 2025-09-02 13:26:06 +08:00
a40fa37bfd refactor(Queue, Scripts): 修改脚本页面的空状态 2025-09-02 12:45:44 +08:00
0162e1a9b7 refactor(ScriptTable): 备注最大长度调整为10汉字/字母 2025-09-02 01:41:53 +08:00
68df1d5332 refactor(package.json): remove unnecessary packageManager entry 2025-09-02 01:27:26 +08:00
DLmaster361
bd8a26a5ae Merge branch 'feature/refactor' of github.com:DLmaster361/AUTO_MAA into feature/refactor 2025-09-02 01:22:07 +08:00
DLmaster361
4125c93267 fix: 调整常驻开放关位置 2025-09-02 01:22:02 +08:00
b4ebf2ccc1 refactor(History, Settings): 去掉搜索前面的冒号;去掉不应该存在的设置项 2025-09-02 01:17:22 +08:00
6941850d90 refactor(ScriptEdit): 修改日志路径选择功能,移除无用样式 2025-09-02 01:01:05 +08:00
329ebd8633 refactor(Scripts, ScriptTable, UserEdit): 清理一下无用css,调整部分按钮位置 2025-09-02 00:54:30 +08:00
MoeSnowyFox
2ac631a18d Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-09-02 00:28:57 +08:00
MoeSnowyFox
0e47461c3c 🍻 ws链接修改为统一端口(ai生成, 未检查) 2025-09-02 00:28:36 +08:00
1d02894957 Revert "fix: 复原疑似被误处理的文件"
This reverts commit 42710ecf60.
2025-09-02 00:02:43 +08:00
76aff5d627 refactor(Home): 主页图片走后端获取,修改git clone的地址 2025-09-02 00:00:51 +08:00
DLmaster361
42710ecf60 fix: 复原疑似被误处理的文件 2025-09-01 23:50:35 +08:00
DLmaster361
e98ebe3e84 Merge branch 'feature/refactor-backend' into feature/refactor 2025-09-01 23:46:52 +08:00
DLmaster361
716d864343 fix: 关卡号报错 2025-09-01 23:39:08 +08:00
432ec7b9b1 refactor(Scripts, UserEdit): 处理一下IDE的类型报错,然后给脚本管理页面加了个loading~ 2025-09-01 23:27:18 +08:00
7b74098a09 refactor(UserEdit): 修改基建导入部分 2025-09-01 22:58:02 +08:00
DLmaster361
39c8db0846 fix: 添加基建配置文件路径实际逻辑 2025-09-01 22:48:48 +08:00
DLmaster361
919f273970 fix: 初步将基建文件改为只保存路径 2025-09-01 22:36:42 +08:00
DLmaster361
dd0fc19f5d feat: 添加常规关卡开放日信息 2025-09-01 22:31:36 +08:00
ed3edd5eff refactor(UserEdit): 统一关卡选项加载逻辑 2025-09-01 22:28:42 +08:00
DLmaster361
f45aff61fd fix: 图片可直接通过url访问 2025-09-01 22:17:20 +08:00
e4cbbbcdea refactor(UserEdit): 优化关卡配置模式选择,动态加载选项 2025-09-01 22:10:10 +08:00
DLmaster361
88006ec5f4 feat: 添加掉落物的url查询API 2025-09-01 21:26:40 +08:00
49821abc12 refactor(Home): 修改PR关卡展示图标 2025-09-01 19:16:59 +08:00
DLmaster361
ee8075e4b8 fix: 修整掉落物信息 2025-09-01 16:45:06 +08:00
f41f066abc feat(Scripts): 添加通用脚本创建方式选择和模板导入功能 2025-09-01 01:06:18 +08:00
7575909bad fix(main): 修复一个类型错误 2025-09-01 00:04:15 +08:00
8d044e684a refactor(UserEdit): 优化文件选择逻辑,为空不显示选择成功 2025-08-31 23:55:52 +08:00
88374e5da4 feat(UserEdit): 添加基建配置文件选择和导入功能 2025-08-31 23:47:30 +08:00
c057c2ae21 fix(Plans): 修复连战次数为0时应该是不选择 2025-08-31 23:19:34 +08:00
c5fd0c1253 feat: 添加公告功能,支持查看公告和在系统浏览器中打开链接 2025-08-31 23:11:24 +08:00
fe35e37371 feat: 添加公告系统,支持动态显示和确认功能 2025-08-31 22:50:28 +08:00
fab4645132 refactor(Home.vue): 增强显示效果,活动剩余时间小于两天时红色毫秒显示 2025-08-31 22:23:47 +08:00
5d87a86a33 refactor(Home.vue): 适配新图片形式,调整倒计时组件 2025-08-31 22:16:49 +08:00
f281ceb5ef refactor: 更新后端接口api 2025-08-31 21:59:56 +08:00
2a77e33206 feat: 添加掉落物图片 2025-08-31 21:56:45 +08:00
DLmaster361
fe3fe6398d feat: 添加掉落物资源 2025-08-31 01:46:00 +08:00
f6c1e591f0 refactor(queue): 增加完成后操作配置功能,优化队列管理交互 2025-08-31 00:49:36 +08:00
f28b4cbad0 refactor(queue): 优化队列管理页面的布局和交互,增加开关配置功能 2025-08-30 20:59:51 +08:00
DLmaster361
f62740f20b fix: 修整所有日志与报错为中文 2025-08-30 20:30:43 +08:00
c15c74895b refactor(plans): 重构一下计划管理页面 2025-08-30 17:03:22 +08:00
7db6fcbcc8 refactor(config): 调整配置项的数值范围并增加服务器选项 2025-08-30 15:42:03 +08:00
7fe2ae2acc refactor(views): 优化空状态展示效果
- 在 Plans、Queue 和 Scripts 页面中添加统一的占位符样式
- 用 "placeholder" 替代 "empty state" 的概念,提升用户体验
- 增加新建按钮,方便用户快速创建计划、队列或脚本
- 优化文字描述,更加友好地引导用户进行操作
2025-08-30 15:04:32 +08:00
ab7afb3d3b feat(首页): 新增资源收集关卡功能
- 在首页添加资源收集关卡卡片,展示资源收集相关数据
- 优化活动关卡卡片,增加结束时间和剩余时间显示
- 调整页面布局,使活动关卡和资源收集关卡卡片更加清晰
- 优化图片加载逻辑,增加错误处理
2025-08-30 14:25:44 +08:00
DLmaster361
389e06cf9e fix: 调整芯片掉落物信息 2025-08-29 20:12:57 +08:00
DLmaster361
05abc0e678 fix: 常规关卡掉落物名修正 2025-08-29 14:38:10 +08:00
DLmaster361
1fa9cbeb7a fix: 关卡号变量由ss统一改为ActivityStage 2025-08-29 12:33:58 +08:00
DLmaster361
928746fe88 feat: 下载站改用域名访问 2025-08-29 12:30:30 +08:00
96ef72d300 feat(initialization): 优化镜像源选择界面和逻辑,修改后端端口为36163
- 将镜像源按类型分组展示,增加官方源和推荐标签
- 实现按速度和推荐程度排序镜像源的功能
- 添加镜像源描述信息,提高用户体验
- 优化后端服务和 WebSocket 连接端口
2025-08-29 01:25:14 +08:00
9f6c86dbbc refactor(initialization): 调整镜像源 2025-08-29 00:58:17 +08:00
d70112216f build(deps): 更新依赖并添加新的包 2025-08-28 23:17:31 +08:00
DLmaster361
e26ca8e81a fix: ws消息统一具有id字段 2025-08-27 23:31:19 +08:00
MoeSnowyFox
b5df09cfd2 🐛 保证应用单例运行 2025-08-27 20:29:39 +08:00
MoeSnowyFox
163cb78fc9 📝 更新打包方法 优化启动速度(目前没什么区别其实) 2025-08-27 20:29:08 +08:00
DLmaster361
bce9777eae fix: 修复ws断开时后端无法正常关闭 2025-08-27 18:35:50 +08:00
DLmaster361
af402330b2 fix: 修复处理数据文件升级时MAA默认配置文件缺失 2025-08-27 18:16:53 +08:00
DLmaster361
fbcc149849 refactor: 初步完成单一ws重构 2025-08-27 17:50:56 +08:00
DLmaster361
4c2a6407a1 feat: overview额外返回资源关信息 2025-08-27 15:37:56 +08:00
DLmaster361
331e0b55ee fix: 同步配置项数值范围上限为9999 2025-08-27 00:03:01 +08:00
DLmaster361
da0b379b69 feat: 添加更新器程序 2025-08-26 22:29:40 +08:00
DLmaster361
18a145b69b Merge branches 'feature/refactor-backend' and 'feature/refactor-backend' of github.com:DLmaster361/AUTO_MAA into feature/refactor-backend 2025-08-26 20:25:12 +08:00
464758073b refactor(initialization): 更新 ManualMode 组件的标题样式 2025-08-26 20:24:00 +08:00
DLmaster361
dea5f3db9a fix: 修复通用脚本根目录index 2025-08-26 20:17:14 +08:00
fdb724ae8b feat(scripts): 添加脚本配置功能 2025-08-26 20:08:43 +08:00
f7ac9daaac feat(core): 更新 MATERIALS_MAP 2025-08-26 18:32:53 +08:00
183e35ac97 refactor(initialization): 移除pip安装步骤 2025-08-26 16:45:49 +08:00
2f87d79713 feat(Scripts): 实现用户状态切换功能 2025-08-26 14:58:40 +08:00
3e0a9f3a94 refactor(Scripts): 重构脚本管理页面 2025-08-26 02:15:51 +08:00
DLmaster361
52fbd9225d fix: 初始化数据库文件夹 2025-08-25 15:27:52 +08:00
dc27127322 refactor(script): 重构脚本列表获取逻辑,优化用户数据加载方式 2025-08-25 13:25:58 +08:00
DLmaster361
92a4ed9529 fix: 总览中移除 SSReopen 关卡 2025-08-25 13:18:47 +08:00
DLmaster361
275e9e6bcc fix: 适配 MAA 剿灭关卡任务出错日志 2025-08-20 00:06:38 +08:00
DLmaster361
94ce37a948 feat: 添加配置文件自动升级v1.9过程 2025-08-18 17:29:00 +08:00
DLmaster361
998aaffd70 feat: 添加通用配置分享相关接口 2025-08-17 23:41:21 +08:00
DLmaster361
515367f61f feat: 添加基建配置文件设置接口 2025-08-17 15:54:54 +08:00
DLmaster361
8e2c6bb642 feat: 添加电源接口,适配小功能函数 2025-08-17 10:53:00 +08:00
08daef4dcd refactor(History): 重构历史记录页面布局和功能
- 新增左侧日期列表和右侧详情区域的布局结构
- 实现用户和记录选择功能
- 优化统计数据展示
- 添加详细日志刷新功能
- 调整样式和响应式设计
2025-08-16 00:22:37 +08:00
DLmaster361
1fdac22bea feat: 添加计划表下拉框接口 2025-08-15 23:07:18 +08:00
2f182519a5 refactor(queue): 重构队列数据获取逻辑
- 使用专门的定时项和队列项 API 获取数据,提高数据获取效率和准确性
- 优化数据处理逻辑,移除不必要的中间步骤
- 提高代码可读性和可维护性
2025-08-15 21:40:39 +08:00
b30073cb80 feat(router): 启动时强制访问初始化页面
- 新增 needInitLanding 标志位,用于控制是否需要跳转到初始化页面
- 修改路由守卫逻辑,确保应用启动时至少访问一次初始化页面
- 优化了开发环境下的路由处理流程
2025-08-15 20:32:11 +08:00
b3a15de00b feat(首页): 添加代理状态功能模块
- 新增代理状态卡片,展示代理用户信息和统计数据
- 实现代理数据的获取和格式化显示
- 添加用户代理次数、错误次数等统计信息
- 优化页面布局和样式,提升用户体验
2025-08-15 19:57:04 +08:00
DLmaster361
22a8cdb8d8 feat: 公告相关接口适配 2025-08-15 17:56:16 +08:00
DLmaster361
9acec97257 fix: 修正历史记录时间文本格式 2025-08-15 16:43:52 +08:00
32ddeef6f0 feat(History): 初步实现历史记录页面功能 2025-08-15 16:35:39 +08:00
a646578128 refactor(Scheduler): 重构调度台界面和逻辑 2025-08-15 16:08:56 +08:00
bf9b911cb2 refactor(Scheduler): 重构调度台界面和逻辑 2025-08-15 15:32:10 +08:00
DLmaster361
7ece8da1db fix: 将stage信息保存于Config文件中 2025-08-15 15:09:39 +08:00
a040dfc4ef refactor(Scheduler): 移除任务队列 2025-08-15 15:01:57 +08:00
7cc754f8b9 feat(api): 补充一下少提交的接口 2025-08-15 14:35:10 +08:00
71a5966700 feat(api): 新增多个API接口和相关模型 2025-08-15 14:28:22 +08:00
a738f102a6 style:格式化代码 2025-08-15 14:21:16 +08:00
9f849608db feat(scheduler): 实现调度台功能并优化任务执行界面
- 新增调度台标签页功能,支持多个调度台同时运行
- 优化任务执行界面布局,增加任务队列和用户队列显示
- 添加快速开始任务功能,可在当前调度台直接启动任务
- 实现任务完成后自动执行指定操作(如关机、睡眠等)
- 优化任务日志显示样式,增加信息过滤和分类
- 调整任务控制按钮位置,提高操作便利性
2025-08-15 01:24:40 +08:00
DLmaster361
7a253effa6 fix: 修复history目录不存在问题 2025-08-14 23:20:28 +08:00
DLmaster361
3c6c776828 feat: 总览接口添加用户代理情况信息 2025-08-14 17:58:50 +08:00
DLmaster361
2326cfcaa3 feat: 添加历史记录相关端口 2025-08-14 16:31:49 +08:00
DLmaster361
1f5cf3acff feat: 计划表添加端口字段说明 2025-08-14 01:22:51 +08:00
DLmaster361
829cac0f6a feat: 添加脚本相关接口文档,子配置数据获取接口独立 2025-08-14 00:56:38 +08:00
MoeSnowyFox
b18ad0fefa Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-08-14 00:13:48 +08:00
MoeSnowyFox
2391e5b806 🧑‍💻 新增env.dev,用于区分环境类型, dev环境忽略路由守卫 2025-08-14 00:12:42 +08:00
68616fe75a feat(gitService): 优化 Git 克隆命令并设置管理员权限
- 在 Git 克隆命令中添加 --single-branch 和 --depth 参数,以提高克隆速度和效率
- 在 package.json 中设置 Windows 平台的请求执行级别为管理员
2025-08-13 23:55:38 +08:00
MoeSnowyFox
512667b850 Merge remote-tracking branch 'origin/feature/refactor' into feature/refactor 2025-08-13 23:38:42 +08:00
MoeSnowyFox
6765fbb36e 🎨 重写侧边栏 2025-08-13 23:36:54 +08:00
DLmaster361
9732a3e65f feat: 调度队列接口添加字段说明 2025-08-13 21:41:50 +08:00
2237dba3c5 feat(config): 重构镜像源配置管理 2025-08-13 21:02:49 +08:00
95126a85d8 feat: 允许http下载&添加了多个镜像源
- 在 AutoMode 组件中添加了多个 gh-proxy 镜像选项
- 在 BackendStep 组件中增加了新的镜像选择项
- 修改了 downloadService 中的下载逻辑,支持 http 和 https
- 更新了 gitService 和 pythonService 中的下载 URL
- 在 Home 组件中添加了动态问候语
2025-08-13 20:00:28 +08:00
DLmaster361
13e9d248e8 fix: rescourses改名res 2025-08-13 19:34:10 +08:00
9aeda23ade refactor(Scheduler): 优化任务输出和状态更新逻辑
- 改进输出内容的渲染和滚动逻辑
- 优化任务状态和日志的实时更新
- 调整任务对象的处理方式,使用 reactive 数组替换 ref 数组
- 优化 WebSocket 连接的管理
2025-08-13 16:43:12 +08:00
16ede80fd4 fix(Scheduler): 修复添加的弹窗选择器为空的问题 2025-08-13 16:22:51 +08:00
7d728cb3ae feat(api): 添加任务创建和执行功能 2025-08-13 15:54:10 +08:00
DLmaster361
bb84099072 fix: 尝试添加依赖 2025-08-13 00:31:49 +08:00
DLmaster361
bec82534f1 fix: 修改添加任务返回字段的键 2025-08-12 23:32:02 +08:00
DLmaster361
9c73dccd1e feat: 添加任务下拉列表接口 2025-08-12 22:48:19 +08:00
9fb25a2d33 feat(queue): 完成调度队列页面并添加脚本下拉框功能
- 重新设计了队列页面布局,使用表格代替原来的卡片布局
- 添加了脚本下拉框功能,支持选择已有的脚本
- 简化了队列项的展示内容,只显示脚本名称
- 优化了队列项的编辑和删除操作
- 新增了获取脚本下拉框信息的 API 接口
2025-08-12 22:26:15 +08:00
DLmaster361
175efdefde feat: 关卡号相关接口适配下拉框 2025-08-12 22:12:34 +08:00
DLmaster361
ff78f8eb27 feat: 添加脚本下拉框接口 2025-08-12 20:57:48 +08:00
DLmaster361
df7006dc1e feat: 优化全局配置接口 2025-08-12 20:30:20 +08:00
bc6ae5562e feat(i18n): 添加中文本地化支持并优化界面显示
- 在 App.vue 中添加中文本地化配置
- 在 main.ts 中配置 dayjs 中文本地化
- 优化 TimeSetManager 组件的样式和布局
- 调整部分组件属性以适应中文环境
2025-08-12 20:14:38 +08:00
DLmaster361
46ee99c5f2 feat: 关卡号添加本地缓存机制 2025-08-12 01:38:32 +08:00
c4dde028b2 feat(queue): 初步实现调度队列功能
- 添加队列列表获取、队列数据加载和保存功能
- 实现队列名称编辑、状态切换和删除功能
- 添加定时项和队列项的数据刷新和展示
- 优化队列界面样式,包括空状态、队列头部、配置区域等
2025-08-12 01:34:15 +08:00
DLmaster361
b543fedfaa Merge branch 'feature/refactor-backend' of github.com:DLmaster361/AUTO_MAA into feature/refactor-backend 2025-08-11 22:41:17 +08:00
DLmaster361
09371f0a5c feat: 通用调度适配 2025-08-11 22:40:42 +08:00
b4665309b9 feat(首页): 添加当期活动关卡功能
- 实现了当期活动关卡的展示功能,包括活动名称、时间、掉落物品等信息
- 添加了活动数据的异步加载和错误处理逻辑
- 优化了页面样式,增加了响应式布局支持
- 重构了部分代码,提高了可维护性和可读性
2025-08-11 16:28:51 +08:00
218784a8de refactor(config.py):优化侧故事关卡数据处理 2025-08-11 13:50:02 +08:00
16ab7fc176 feat(api): 添加任务调度相关接口和模型
- 新增 DispatchIn、TaskCreateIn 和 TaskCreateOut 模型
- 在 Service 类中添加任务调度相关接口:
- addOverviewApiInfoGetOverviewPost
- addTaskApiDispatchStartPost
- stopTaskApiDispatchStopPost
- 更新 index.ts,导出新增的模型和接口
- 优化导入路径,去除不必要的文件扩展名
2025-08-11 13:33:14 +08:00
85cd830046 feat(info): 添加获取活动关卡信息功能 2025-08-11 02:11:21 +08:00
76a6f8c33e feat(assets): 添加明日方舟材料图片 2025-08-11 01:57:31 +08:00
da9aa71c20 refactor(types): 合并 Electron API 类型定义
- 将 Electron API 类型定义从 vite-env.d.ts 移动到 electron.ts
- 更新 electron.ts 中的类型定义,增加了新的 API 方法
- 删除 vite-env.d.ts 文件中的 Electron API 类型定义
2025-08-11 00:26:37 +08:00
bf12f1f29a feat(initialization): 增加强制重新安装功能并优化环境检查逻辑
- 在 PythonStep、PipStep 和 GitStep 组件中添加强制重新安装功能
-优化环境检查逻辑,增加关键文件存在性检查
- 调整自动模式启动条件,确保关键文件存在
- 修复部分组件引用,增加必要的 ref
2025-08-10 23:19:44 +08:00
DLmaster361
91209ad9e2 feat: MAA任务可使用计划表信息 2025-08-10 22:30:10 +08:00
DLmaster361
6b729be34e feat: MAA完整功能上线 2025-08-10 21:45:35 +08:00
DLmaster361
409a7a2d03 feat: MAA调度初步上线 2025-08-09 22:17:19 +08:00
679c695700 feat(initialization): 实现后端服务自动启动功能
- 新增自动启动后端服务的功能,在第七步时自动尝试启动服务
- 修改手动启动按钮文案为"重新启动服务"
- 优化服务启动逻辑,统一处理自动启动和手动启动的流程
- 增加启动成功后的提示和自动进入主页的功能
2025-08-09 13:17:36 +08:00
DLmaster361
135555e3ea fix: 更正接口命名 2025-08-09 01:55:49 +08:00
DLmaster361
9a87a62353 feat: 没测过的MAA调度方案 2025-08-09 01:50:38 +08:00
1b94986e9a refactor(initialization): 调整自动模式时间和初始化页面样式
- 在 AutoMode.vue 中将自动模式完成时间从 5000000 毫秒调整为 500毫秒
- 在 Initialization.vue 中为初始化页面添加背景色和文本颜色样式
2025-08-09 01:16:26 +08:00
c8d3425293 feat(initialization): 新增管理员权限检查和自动启动功能
- 添加 AdminCheck 组件,用于提示用户需要管理员权限
- 实现 AutoMode 组件,用于自动启动后端服务
- 更新 DependenciesStep组件,移除部分镜像源
- 注释掉路由守卫代码,暂时不启用初始化页面强制跳转
2025-08-08 00:43:34 +08:00
0a49d45160 style(components): 移除速度徽章的背景颜色 2025-08-07 22:46:57 +08:00
0171c3ca4d feat(initialization): 新增配置文件操作和管理员权限检查
- 新增配置文件保存、加载和重置功能
- 添加管理员权限检查和重启为管理员的功能
- 实现 pip 包管理器安装功能
- 优化初始化流程,自动检测并安装依赖
2025-08-07 20:18:53 +08:00
e7f898f357 feat(initialization): 更新 Python 版本并优化初始化流程
-将 Python 版本从 3.13.0 更改为3.12.0
- 添加路由守卫,确保在生产环境中也能正确进入初始化页面
- 在初始化页面中增加服务启动相关逻辑
- 优化环境检查和启动服务的代码结构
- 调整后端服务启动方式,增加调试用的强制启动功能
2025-08-07 16:09:00 +08:00
DLmaster361
ba1fcd1f26 fix: main自动添加sys.path 2025-08-07 15:37:45 +08:00
DLmaster361
7941f5cafd fix: 将app设为包 2025-08-07 14:32:39 +08:00
6aca142696 refactor(initialization): 调整初始化流程并添加新功能
- 在 Git 克隆命令中添加了特定分支参数
- 在初始化界面添加了跳转到最后一部的按钮
- 调整了 Python 后端启动路径
2025-08-07 13:52:35 +08:00
f85a3024ef refactor(electron): 重构 Git 和后端代码获取逻辑
- 优化 Git 安装过程,使用临时目录解压后移动到目标位置
- 重新设计后端代码获取逻辑,支持跳过环境安装
- 添加跳转至首页按钮,便于开发测试
- 修复 Python 依赖安装路径问题
2025-08-07 01:37:52 +08:00
161dc478ae feat(log): 实现日志文件持久化和全局进度条
- 新增日志文件保存和加载功能
- 实现全局进度条组件
- 优化初始化界面布局
- 更新设置界面,增加系统日志查看按钮
2025-08-07 00:51:29 +08:00
ae151a9311 feat(initialization): 添加初始化页面和相关功能
- 新增初始化页面组件和路由
- 实现环境检查、Git下载、后端代码克隆等功能
- 添加下载服务和环境服务模块
- 更新类型定义,增加 Electron API 接口
2025-08-07 00:11:29 +08:00
DLmaster361
8cbd542a75 feat: 添加环境信息 2025-08-06 16:48:43 +08:00
18202045bf feat(Plans): 添加计划加载状态和空状态样式
- 在 Plans.vue 中添加 loading 相关逻辑,用于显示加载状态
- 实现空状态样式,优化无计划数据时的界面显示
- 调整模板结构,增加 loading-box 和空状态的 HTML 结构
- 优化 CSS 样式,为 loading 状态和空状态添加相应样式
2025-08-06 15:30:07 +08:00
89bf0cbad7 refactor(Plans): 重构计划页面布局和样式
- 调整了计划列表为空时的展示内容和样式
- 优化了计划选择器的位置和样式- 统一了计划内容区域的样式
- 移除了部分冗余的背景颜色设置
- 增加了新的空状态样式,提升用户体验
2025-08-06 15:22:49 +08:00
4ff041854c feat(plans): 实现计划管理功能
- 添加计划列表、创建计划、删除计划等功能
- 实现计划数据的加载和保存
- 优化空状态和面包屑样式
- 新增 usePlanApi 和 useUserApi 组合式函数
2025-08-06 15:07:09 +08:00
DLmaster361
b5c118eee5 fix: 修复大小写问题 2025-08-06 01:02:39 +08:00
be2c446906 fix: 修复按钮不能用的问题 2025-08-06 00:14:18 +08:00
5c57841cd9 refactor(electron): 优化应用启动和资源路径
- 更新应用图标路径,使用 src/assets 目录下的图标文件
- 重构应用启动逻辑,确保在不同环境下正确加载 HTML 文件
- 优化文件选择对话框的代码结构
- 在 vite 配置中添加 base 路径设置
2025-08-05 23:55:06 +08:00
40ca642c07 refactor(ScriptEdit): 更新 ScriptEdit 组件的按钮功能和图标
- 将取消按钮的图标从 CloseOutlined 改为 ArrowLeftOutlined
- 移除保存配置按钮
- 修改 Scripts 组件中添加脚本按钮的文案为"新建脚本"
2025-08-05 23:29:07 +08:00
DLmaster361
6898e548a5 feat: 载入各种服务 2025-08-05 22:50:07 +08:00
8c88e4e6a2 feat(ScriptEdit, UserEdit): 添加浮窗保存按钮
- 在 ScriptEdit 和 UserEdit 组件中添加浮窗保存按钮
- 按钮位于页面右侧,点击时触发表单提交
- 优化了用户编辑界面的样式
2025-08-05 22:48:55 +08:00
5ca4c5cc81 feat(UserEdit): 重构用户编辑页面并添加新功能
- 重新组织用户编辑页面的布局和结构
- 添加剩余天数、用户配置模式、剿灭代理等新功能
- 更新密码字段的描述,明确其仅用于储存
- 移除序列号字段
- 更新通知配置,支持多种通知方式
- 添加森空岛配置功能
2025-08-05 21:35:24 +08:00
eb0f2d521e feat(user): 添加用户编辑功能 2025-08-05 20:05:52 +08:00
d68e423768 feat(api): 添加 OpenAPI 客户端支持
- 新增 ApiError、ApiRequestOptions、ApiResult、CancelablePromise 等核心类
- 添加多种模型类型定义,如 PlanCreateIn、QueueGetOut、ScriptCreateOut 等
- 实现请求发送、错误处理、数据解析等核心功能
- 配置 axios 客户端并集成到请求流程中
- 优化路由配置,添加用户相关路由
- 更新脚本编辑界面文案,使用更通用的描述
2025-08-05 20:04:00 +08:00
DLmaster361
4ca7f9053f feat: 支持本地数据保存时加密 2025-08-05 18:56:44 +08:00
DLmaster361
d61b90baa4 feat: 添加信息获取API与主业务定时器 2025-08-05 16:09:48 +08:00
DLmaster361
4a9c9ab1f3 feat: 添加计划表相关API 2025-08-05 02:14:10 +08:00
DLmaster361
6fb4fa7683 feat: 添加排序相关API 2025-08-05 01:08:34 +08:00
DLmaster361
911eb60ae9 feat: 添加队列相关API 2025-08-05 00:46:22 +08:00
c116efd6f4 feat(settings): 新增设置页面和相关功能
- 添加设置页面组件和路由
- 实现设置数据的获取和更新逻辑
- 新增多个设置选项,包括功能设置、通知设置、更新设置等
- 优化设置页面样式和交互
2025-08-04 21:19:24 +08:00
DLmaster361
8e3c62a518 fix: 全局配置key错误 2025-08-04 21:13:57 +08:00
DLmaster361
16bf2c9494 feat: 添加用户相关API 2025-08-04 20:52:20 +08:00
9a9a4dad01 refactor(script): 重构脚本编辑页面并优化脚本配置功能 2025-08-04 19:51:56 +08:00
DLmaster361
07b5f5c00b Merge branch 'feature/refactor-backend' of github.com:DLmaster361/AUTO_MAA into feature/refactor-backend 2025-08-04 19:44:53 +08:00
DLmaster361
b60225cb74 feat: 添加全局配置相关API 2025-08-04 19:44:48 +08:00
MoeSnowyFox
250b2e9509 🐛 修正dev.md 2025-08-04 19:18:00 +08:00
f68c1c95eb refactor(api): 修改api调用url 2025-08-04 16:31:26 +08:00
DLmaster361
e71a518b49 feat: 修改脚本配置API 2025-08-04 16:13:00 +08:00
5531f6e87a feat(script): 添加脚本删除功能并优化脚本编辑界面
- 在 ScriptEdit.vue 中添加删除脚本 API 响应类型
- 在 Scripts.vue 和 ScriptTable.vue 中移除冗余样式
- 在 tsconfig.json 中添加路径别名配置
- 重构 useScriptApi.ts 中的 deleteScript 函数,实现真正的脚本删除
2025-08-04 15:49:00 +08:00
DLmaster361
a8ec29d2ed feat: 适配脚本删除API 2025-08-04 15:23:41 +08:00
19aab99398 refactor(eslint): 重构 ESLint 配置并优化组件样式
- 将 ESLint 配置从 .eslintrc.js 迁移到 eslint.config.js
- 优化 Scripts.vue 和 ScriptTable.vue 组件的样式
- 移除了不必要的模拟数据
- 调整了按钮样式和布局
2025-08-04 15:23:20 +08:00
0b1ed48471 feat(script): 添加脚本管理页面和相关功能
- 实现了脚本管理页面的基本布局和样式
- 添加了脚本列表加载、添加、编辑和删除功能- 集成了文件夹和文件选择对话框
- 优化了主题模式和颜色的动态切换
- 新增了多个脚本相关类型定义
2025-08-04 14:42:31 +08:00
DLmaster361
0b9c6320eb fix: 修正查询方法 2025-08-04 14:09:01 +08:00
DLmaster361
03b20787b3 fix: 移除无用方法 2025-08-04 13:56:09 +08:00
DLmaster361
9f8f411c33 feat: 实现脚本查询 2025-08-04 13:50:31 +08:00
DLmaster361
76438459f7 feat: 接入logger 2025-08-04 10:46:23 +08:00
92ea6026f8 feat(api): 添加跨域资源共享 (CORS) 中间件 2025-08-04 02:22:29 +08:00
DLmaster361
9031ea6b3f feat: 配置基类添加异步 2025-08-04 01:36:26 +08:00
DLmaster361
b576440612 feat: 适配添加脚本功能 2025-08-04 01:12:06 +08:00
DLmaster361
a4891131fc feat(core): 完成配置类初始化任务 2025-08-04 00:01:34 +08:00
DLmaster361
5f57ce54aa feat: 创建配置类模型 2025-08-03 17:42:18 +08:00
MoeSnowyFox
f1859a5877 ♻️重构, 实现基础框架-日志 AUTO_MAA配置 用户配置基类 2025-08-03 03:43:57 +08:00
94d038d563 feat: 修改网页(应用程序)标题为 AUTO_MAA 2025-08-03 02:00:58 +08:00
ffae1e583b refactor(layout): 调整侧边栏宽度并优化开发者工具
- 调整侧边栏展开/折叠宽度,适应更多显示器
- 优化开发者工具,在新窗口中打开,解决部分显示器兼容性问题
- 添加浅色模式下侧边栏和菜单的背景色样式
2025-08-03 01:58:57 +08:00
8916cbd097 feat(layout): 优化侧边栏布局和内容区域滚动
-固定侧边栏高度为100vh,设置为固定定位
- 优化内容区域样式,设置过渡效果和滚动条- 隐藏菜单和内容区域的滚动条
- 调整底部菜单样式,注释掉部分代码
2025-08-03 01:02:14 +08:00
790a75ac87 feat(settings): 重构设置页面
- 重新设计了设置页面布局,增加了标签页导航
- 添加了通知设置、更新设置和高级设置等新功能
- 优化了主题模式和主题色选择功能
2025-08-03 00:23:28 +08:00
d4f8165b87 feat(frontend): 实现 AUTO_MAA 前端基础结构
- 创建 Vue 3 + TypeScript 项目
- 添加 Electron 支持
- 实现基本页面布局和路由- 添加主题切换功能
- 创建设置页面
- 添加开发者工具支持
2025-08-02 23:10:22 +08:00
DLmaster361
8948d0fb18 feat: 发布v4.4.1 2025-08-01 11:02:08 +08:00
DLmaster361
908da0bc47 fix: 修复计划表未能按照鹰历获取关卡号的问题 2025-08-01 09:35:54 +08:00
DLmaster361
aae17208b0 fix: 修正部分文案 2025-08-01 01:09:10 +08:00
DLmaster361
cbd8918c61 feat(core): 启动时支持直接运行复数调度队列 2025-07-31 23:18:36 +08:00
DLmaster361
f2b4f9e8fc ci: fix ci 2025-07-31 22:07:21 +08:00
DLmaster361
a2a11647bb fix: ci 2025-07-31 21:58:53 +08:00
DLmaster361
1d9c275b61 ci: fix 2025-07-31 21:53:44 +08:00
DLmaster361
52468928c6 feat: 新增 Go_Updater 独立更新器 2025-07-31 20:47:49 +08:00
DLmaster361
3d9471779a Merge branch 'ClozyA_GoUpdater_dev' into dev 2025-07-31 20:42:52 +08:00
DLmaster361
155c4b00d5 ci: 适配Go_Updater 2025-07-31 20:42:36 +08:00
DLmaster361
f4b45e9eae feat(general): 通用脚本启动附加命令添加额外的语法以适应UI可执行文件与任务可执行文件不同的情况 2025-07-31 20:01:32 +08:00
DLmaster361
d836b58b2d feat: 新增完成任务后自动复原脚本配置 2025-07-31 17:06:08 +08:00
DLmaster361
4fc747f1c6 feat(maa): 适配 MAA 长期开放剿灭关卡 #58 2025-07-31 16:13:35 +08:00
DLmaster361
b27f2c43ae feat: MAA代理时更新改为强制开启 2025-07-31 13:50:19 +08:00
DLmaster361
c5f947e14a fix: 优化静默进程标记逻辑 2025-07-31 13:39:38 +08:00
DLmaster361
67c41ab3ee chore(core): 优化调度队列配置逻辑 2025-07-31 01:01:30 +08:00
DLmaster
9f330e2245 Merge pull request #62 from Zrief:dev
修复依赖缺失
2025-07-31 00:49:39 +08:00
Zrief
d785262312 依赖相关问题
在 app/core/task_manager.py 文件中,第 31 行使用了 packaging 模块。其并非标准库,因此需要确保它被包含在项目依赖中。
2025-07-31 00:44:07 +08:00
DLmaster361
27faaabcf2 fix: 修复脚本下载器卡初始化 2025-07-27 23:30:34 +08:00
226d68cb1c feat(api): 修复beta版本的更新检查 2025-07-27 14:12:54 +08:00
DLmaster361
e776cc2319 fix: 修复一般更新方案卡初始化 2025-07-26 16:23:22 +08:00
DLmaster361
3cdb1e511d fix: 修正部分格式 2025-07-26 13:11:23 +08:00
a4f867665f feat(power): 添加强制关机功能并优化关机流程 2025-07-26 00:27:41 +08:00
DLmaster361
6b0583b139 fix: 移除部分调试代码 2025-07-25 20:42:50 +08:00
2aad0c65c2 chore: 添加无更新可用时的延迟退出功能 2025-07-22 23:03:23 +08:00
6b646378b6 refactor(updater): 重构 Go 版本更新器
- 更新项目名称为 AUTO_MAA_Go_Updater
- 重构代码结构,优化函数命名和逻辑
- 移除 CDK 相关的冗余代码
- 调整版本号为 git commit hash
- 更新构建配置和脚本
- 优化 API 客户端实现
2025-07-22 21:51:58 +08:00
747ad6387b feat(config): 添加配置管理功能 2025-07-20 18:12:35 +08:00
228e66315c feat(Go_Updater): 添加全新 Go 语言实现的自动更新器
- 新增多个源文件和目录,包括 app.rc、assets、build 脚本等
- 实现了与 MirrorChyan API 交互的客户端逻辑
- 添加了版本检查、更新检测和下载 URL 生成等功能
- 嵌入了配置模板和资源文件系统
- 提供了完整的构建和发布流程
2025-07-20 16:30:14 +08:00
DLmaster361
97c283797a fix: 移除崩溃弹窗机制 2025-07-19 22:23:59 +08:00
DLmaster361
eb1fade6f5 feat: AUTO_MAA 配置分享中心上线 2025-07-19 21:38:38 +08:00
DLmaster361
8d6071f794 fix: 修复 QTimer.singleShot 参数问题 2025-07-19 17:08:57 +08:00
DLmaster361
8a109e34f8 refactor(module): 重构日志读取兜底机制 2025-07-19 10:27:19 +08:00
DLmaster361
fd72d72692 fix(module): 日志读取添加兜底机制 2025-07-19 10:13:48 +08:00
DLmaster361
63ffacff96 fix(ui): SpinBox和TimeEdit组件忽视滚轮事件 2025-07-18 21:59:18 +08:00
DLmaster361
1b4bb6fccc feat(general): 通用脚本支持在选定的时机自动更新配置文件 2025-07-18 21:48:39 +08:00
DLmaster361
9b492b5e0d feat(core): 重构日志记录,载入更多日志记录项 2025-07-18 18:12:47 +08:00
DLmaster361
8427bd9f6b Merge branch 'inno_dev' into dev 2025-07-16 20:13:45 +08:00
DLmaster361
2c915161d5 Merge commit '75b06ca770ebcee68bf05ab95f069c6e8fd0cae8' into dev 2025-07-15 18:15:48 +08:00
DLmaster361
75b06ca770 feat(general): 通用配置模式接入日志系统 2025-07-15 18:15:26 +08:00
DLmaster361
c3468a3387 Merge branch 'generic_dev' into dev 2025-07-15 16:20:26 +08:00
DLmaster361
a2f4adb647 fix: 适配 MAA 任务及基建设施日志翻译 2025-07-15 16:20:09 +08:00
DLmaster361
403f69df8b refactor(ui): 重构历史记录保存与载入逻辑 2025-07-15 16:08:11 +08:00
DLmaster361
12cf10f97a fix((ui): 修复MAA用户仪表盘备选1字段错误 2025-07-14 19:12:59 +08:00
DLmaster361
6084befe2c Merge branch 'dev' into generic_dev 2025-07-13 23:25:02 +08:00
DLmaster361
1aa99ea613 Merge branch 'network_dev' into dev 2025-07-13 23:00:59 +08:00
DLmaster361
d539c0f808 fix(core): 信任系统证书,并添加网络代理地址配置项 #50 2025-07-13 22:50:04 +08:00
DLmaster361
bc509806fb Merge branch 'generic_dev' into dev 2025-07-12 23:11:35 +08:00
DLmaster361
c52820550f feat(ui): 添加导入导出通用配置功能 2025-07-12 21:54:40 +08:00
DLmaster361
98b30f90a1 Merge commit '4efbafc174504bd2fbd5b22075e3b5429406691b' into generic_dev 2025-07-12 19:41:10 +08:00
DLmaster361
4efbafc174 fix(system): 修复开机自启相关功能 2025-07-12 16:08:07 +08:00
DLmaster361
6d3fda50d3 feat(security): 添加重置管理密钥功能 2025-07-12 01:27:27 +08:00
DLmaster361
70b936012f Merge commit '54917fbe6de3ca5ac4a07bc88cdf0178a23f88dc' into dev 2025-07-11 18:42:40 +08:00
DLmaster361
54917fbe6d fix(general): 修复无成功日志时的脚本判定逻辑 2025-07-11 18:38:58 +08:00
DLmaster361
abeb9f054d fix(maa): 适配 MAA 备选关卡字段修改 2025-07-11 14:48:23 +08:00
DLmaster361
c6d6c5fb2a Merge branch 'generic_dev' into dev 2025-07-10 17:35:22 +08:00
DLmaster361
5b0d7f0012 feat(ui): 修改版本号到v4.4
- 进一步适配三月七相关配置项
2025-07-10 17:34:41 +08:00
DLmaster361
d9043aab0a fix(ui): 适配 Mirror 酱 平台下载策略调整 2025-07-10 13:29:17 +08:00
DLmaster361
b9281b68ab feat(utils): 安装包适配管理员权限 2025-07-10 04:53:32 +08:00
DLmaster361
5c6a20be4e Merge branch 'generic_dev' into dev 2025-07-10 02:30:02 +08:00
DLmaster361
1c0a65957d feat(core): 初步完成通用调度模块 2025-07-10 02:29:08 +08:00
DLmaster361
7c315624b1 Merge commit '0572caa528deb72bc97c42e0b169292823b1e8a8' into dev 2025-06-17 19:55:55 +08:00
DLmaster361
0572caa528 fix: 固定certifi版本号 2025-06-17 03:18:55 +08:00
DLmaster361
4233040585 fix: 尝试固定certifi版本号 2025-06-17 02:46:27 +08:00
DLmaster361
c27dc8e380 fix: 移除无用依赖项 2025-06-17 02:00:32 +08:00
DLmaster361
e746756e56 ci: 临时固定Nuitka打包版本号 2025-06-17 01:14:29 +08:00
DLmaster361
1829d1cd0b fix(ui): 修复删除计划表引发的错误 2025-06-17 00:34:07 +08:00
DLmaster361
fb979e5639 docs: 补充代码签名策略 2025-06-16 21:38:25 +08:00
DLmaster361
e7d0a85ad5 ci: 修正project-slug 2025-06-16 21:24:05 +08:00
DLmaster361
a384711327 Merge branch 'dev' 2025-06-16 14:33:49 +08:00
DLmaster361
3fd4778a48 feat(maa): 适配 MAA 无Default配置情况 2025-06-16 14:26:29 +08:00
DLmaster361
4841dc09b3 refactor(res): 更新软件主页用图 2025-06-14 17:20:03 +08:00
DLmaster361
b3aa4fc776 fix(maa): 修复森空岛签到信息特定情况下不弹出通知的问题 2025-06-13 10:02:43 +08:00
DLmaster361
a9b3b8b6f4 Merge branch 'Clozy_dev' into dev 2025-06-11 23:05:59 +08:00
DLmaster361
56ef196695 refactor(models/services): 简化测试用图 2025-06-11 23:05:46 +08:00
242238d341 refactor(models/services): 优化代码结构和可读性
-移除了不必要的变量声明
-简化了图像路径的构建方式
- 统一了代码格式
2025-06-11 22:51:26 +08:00
f66f6d38fe feat(notification): 用户单独通知六星喜报 2025-06-11 22:31:51 +08:00
d58077f58b fix(app): 修复企业微信机器人图片推送异常
- 移除了不必要的变量 final_image_path
-直接使用 image_path 进行图片存在性检查
- 更新了图片 base64 和 md5计算的逻辑
2025-06-11 22:26:44 +08:00
4d4d6dbedf refactor(app): 重构图片压缩功能并优化类型注解
- 使用 Path 对象替换字符串表示文件路径,提高代码可读性和功能
- 优化类型注解,包括函数参数和返回值- 重构 ImageUtils.compress_image_if_needed 方法,简化逻辑并提高性能
- 更新 notification.py 中使用该方法的代码,直接返回压缩后的 Path 对象
2025-06-11 22:14:09 +08:00
f60b276916 refactor(notify): 重构企业微信群机器人图片推送功能
-将图片压缩、存在性检查、Base64编码和MD5计算移至 CompanyWebHookBotPushImage 方法内部
- 优化错误处理和日志记录
- 简化调用接口,提高代码可读性和维护性
2025-06-11 20:54:45 +08:00
87857fd499 feat(notification): 新增图片压缩处理
- 新增 ImageUtils.compress_image_if_needed 方法,用于压缩图片大小
- 在 MAA.py 和 notification.py 中集成图片压缩功能
- 添加对不同图片格式(JPEG、PNG)的压缩支持
- 优化图片路径处理,确保压缩后图片正确发送
- 更新 requirements.txt,添加 pillow 依赖
2025-06-11 19:50:58 +08:00
3c371cd079 feat(notification): 企业微信群机器人支持图片推送
- 新增 ImageUtils 类,提供图像处理相关工具方法
- 在 MAA.py 中集成 ImageUtils,用于获取和处理通知图片
- 在 notification.py 中实现 CompanyWebHookBotPushImage 方法,支持企业微信群机器人推送图片
- 修改测试通知方法,增加图片推送测试
2025-06-11 17:36:11 +08:00
DLmaster361
428b849bcc fix(maa): 静默模式控制时段延长至模拟器完成启动的10s后 2025-06-11 01:13:57 +08:00
DLmaster361
85f3b4f607 Merge branch 'dev' 2025-06-10 18:49:00 +08:00
DLmaster361
916396f855 refactor: 使用 keyboard 模块替代 pyautogui 模块 2025-06-10 18:48:41 +08:00
DLmaster361
211c8d2b04 Merge branch 'dev' 2025-06-10 14:10:01 +08:00
DLmaster361
92e274d3fd ci: 移除pyscreeze 2025-06-10 14:09:40 +08:00
DLmaster361
d511ea48d5 Merge branch 'main' into dev 2025-06-09 23:45:03 +08:00
DLmaster361
1aa4da1adf feat: 支持使用命令行调用 2025-06-09 23:43:36 +08:00
DLmaster361
0e8b6b0b6b feat: 添加用户守则 2025-06-08 23:41:18 +08:00
DLmaster361
1a2c1b976f fix(maa): 更新动作执行后移除相应标记 2025-06-07 15:59:06 +08:00
DLmaster361
1cc242fa51 feat: 优化下载器测速中止条件 2025-06-06 22:38:37 +08:00
DLmaster361
18dfdba15d ci: 测试完整工作流 2025-06-06 00:09:01 +08:00
DLmaster361
b04ac4eec6 ci: 使用预设配置 2025-06-05 23:50:56 +08:00
DLmaster361
c009f0c891 ci: 测试证书注册 2025-06-05 23:39:09 +08:00
DLmaster361
d2dc0bd295 ci: 上传测试之二 2025-06-05 23:28:15 +08:00
DLmaster361
ddbb5b7f19 ci: 名字作出区分 2025-06-05 23:25:48 +08:00
DLmaster361
954c25090b ci: 测试上传工作 2025-06-05 23:25:08 +08:00
DLmaster361
0b6cc59de1 ci: 构建部分测试流程 2025-06-05 22:38:24 +08:00
DLmaster361
2271b5741d ci: 移除不支持的参数 2025-06-05 22:08:41 +08:00
DLmaster361
8a438b041f ci: 修正signpath证书参数 2025-06-05 21:09:03 +08:00
DLmaster361
dd92fcc4d8 ci: 添加证书测试工作流 2025-06-05 20:02:17 +08:00
DLmaster361
8f66ca0e16 Merge branch 'dev' 2025-06-05 19:10:49 +08:00
DLmaster361
895ba1d24a feat(res): 公招喜报模板优化 2025-06-04 11:21:34 +08:00
e49b807bef feat(ui): 完善森空岛签到功能的提示信息 2025-06-02 14:26:25 +08:00
DLmaster361
73c15b5e93 refactor(skland): 森空岛签到功能拆分独立 2025-06-02 14:05:31 +08:00
DLmaster361
e505ea8c51 feat(maa): 森空岛签到功能上线 2025-06-02 02:35:01 +08:00
DLmaster361
21e7df7c3e Merge branch 'dev' 2025-06-01 20:04:16 +08:00
DLmaster361
2d72ca66a4 fix(core): 修复网络模块子线程未及时销毁导致的程序崩溃 2025-06-01 19:52:22 +08:00
DLmaster361
4725a30165 fix(ui): 修复语音包禁忌二重奏 2025-06-01 04:31:29 +08:00
DLmaster361
f3c977f1b3 Merge branch 'main' into dev 2025-06-01 03:18:39 +08:00
DLmaster361
9a0e7265c6 feat(core): 语音功能上线 2025-06-01 03:16:56 +08:00
DLmaster361
3f8e2fbe6b Merge branch 'dev' 2025-05-31 13:37:06 +08:00
DLmaster361
590b13e916 ci: 移除测试打包流程 2025-05-31 13:36:09 +08:00
DLmaster361
0f6aee56e5 ci: 修正测试工作流名称 2025-05-31 11:21:13 +08:00
DLmaster361
daf18e7295 Merge branch 'dev' 2025-05-31 11:20:04 +08:00
DLmaster361
9bcc87f663 ci: 添加引入打包action的测试工作流 2025-05-31 11:19:36 +08:00
DLmaster361
e7205ce0aa fix(services): 非UI组件转为QObject类 2025-05-30 21:06:39 +08:00
DLmaster361
e3c4b2edc8 fix(core): 网络模块支持并发请求 2025-05-30 20:30:23 +08:00
DLmaster361
222a3b35a2 fix(maa): 修复ADB与模拟器相关日志信息报错时不显示 2025-05-29 21:06:09 +08:00
DLmaster361
cd5dfd56b2 Merge branch 'dev' 2025-05-28 23:13:15 +08:00
DLmaster361
7d5c6b8222 docs: 添加DeepWiki徽章 2025-05-28 23:12:56 +08:00
DLmaster361
4dbf4736e4 Merge branch 'dev' 2025-05-28 21:18:24 +08:00
DLmaster361
d50504181e feat(ui): 吐司通知在主窗口隐藏时不再弹出 2025-05-28 21:17:14 +08:00
DLmaster361
c7e94dfcd1 Merge branch 'DLMS_dev' into dev 2025-05-27 18:17:29 +08:00
DLmaster361
a752b67ca1 feat(ui): UI界面添加自动日常代理任务序列设置项 2025-05-26 16:05:46 +08:00
DLmaster361
078736337d fix: 修复雷电模拟器静默模式无法正常识别模拟器是否隐藏相关问题 2025-05-25 23:31:37 +08:00
DLmaster361
de1058a28c feat(core): 计划表功能上线 2025-05-25 21:12:22 +08:00
DLmaster361
740797a689 Merge branch 'dev' into DLMS_dev 2025-05-24 19:56:16 +08:00
DLmaster361
26328920a2 chore(ui): 输入文本框适配文本插入操作 2025-05-24 19:55:49 +08:00
DLmaster361
9c447bbdf9 Merge branch 'ClozyA_dev' into dev 2025-05-24 18:34:03 +08:00
DLmaster361
fac85a889f fix(ui): 简单优化用户通知显示效果 2025-05-24 18:33:46 +08:00
DLmaster361
f5d898c89e fix(maa): 将通知判定过程全部移入push_notification 2025-05-23 22:59:09 +08:00
974a4b634a feat(ui): 优化敏感信息显示逻辑 2025-05-23 15:19:35 +08:00
3127c83603 feat(notification): 增加用户单独通知功能 2025-05-23 15:01:26 +08:00
DLmaster361
8d69e43f72 feat(ui): 初步添加周计划表 2025-05-22 23:20:38 +08:00
DLmaster361
86df9e7a50 chore(ui): 优化二级菜单显示效果 2025-05-21 01:00:38 +08:00
59ff9bf818 refactor(notification): 重构通知模块 2025-05-20 02:11:54 +08:00
DLmaster361
1641e32e3d feat(ui): 完成完整用户通知子菜单设计 2025-05-19 17:31:50 +08:00
DLmaster361
a482087abd feat(ui): 初步完成用户通知相关界面 2025-05-19 16:54:14 +08:00
bc5b15cec2 feat(notification): 优化用户通知服务 2025-05-19 01:10:20 +08:00
3787c25a77 Merge branch 'dev' into ClozyA_dev 2025-05-19 00:18:37 +08:00
DLmaster361
0b06b499e4 fix(models): 修复雷电ADB端口号相关问题 2025-05-18 23:35:35 +08:00
DLmaster361
04079dd57b Merge branch 'dev' of github.com:DLmaster361/AUTO_MAA into dev 2025-05-18 13:54:19 +08:00
DLmaster361
34ac0c5ab3 fix: 主动关闭剩余理智选项 2025-05-18 13:54:14 +08:00
0d904b229e feat(core): 初步完成新增用户单独通知功能 2025-05-18 01:57:48 +08:00
c0f887ff9d refactor(notification): 更新任务报告标题格式,增加日期前缀 2025-05-18 00:17:06 +08:00
DLmaster361
cf95075d01 fix(ui): 修复窗口最大化功能异常 2025-05-17 22:26:41 +08:00
DLmaster361
d78a764d87 fix: 修正搜索ADB时的等待方法 2025-05-17 00:07:34 +08:00
DLmaster
a368f4b722 feat(ui): 用户仪表盘支持直接控制用户状态
优化仪表盘排版
2025-05-16 23:27:53 +08:00
DLmaster361
803fe4568f chore(ui): 优化实现方法 2025-05-16 23:23:02 +08:00
DLmaster361
1162d5dcc1 feat(ui): 添加对剩余天数为0的展示 2025-05-16 22:50:54 +08:00
Zrief
aa83058e39 Update member_manager.py 2025-05-16 21:56:20 +08:00
Zrief
061f205224 Merge branch 'DLmaster361:dev' into dev 2025-05-16 21:55:16 +08:00
Zrief
5d966f98df 修改逻辑不完备的地方 2025-05-16 21:53:03 +08:00
DLmaster361
0037914db8 Merge branch 'DLMS_dev' into dev 2025-05-16 20:41:04 +08:00
DLmaster361
13d0115475 feat(core): 添加ADB端口号宽幅适配能力 2025-05-16 20:09:29 +08:00
Zrief
5bdb5ad2bf 优化脚本仪表盘排版
可以直接在仪表盘里面开关用户状态了
2025-05-15 21:20:30 +08:00
a5d733c8df feat(notification): 为自动代理统计报告添加日期前缀 2025-05-15 16:39:56 +08:00
DLmaster361
0b038e2997 Merge branch 'main' into dev 2025-05-12 14:54:17 +08:00
DLmaster361
5e46040db6 feat: 日志分析忽略MAA超时提示 2025-05-12 14:53:57 +08:00
DLmaster361
f2b04dd0f6 Merge branch 'dev' 2025-05-11 23:28:18 +08:00
DLmaster361
2177c1b40e fix(models): 适配MAAv5.16.3的ADB报错信息更改 2025-05-11 23:26:42 +08:00
DLmaster361
d1f4cffe8f feat(ui): 主调度台添加仅一次电源任务,UI样式优化 2025-05-10 23:15:54 +08:00
DLmaster361
74ce441b90 feat(core): 电源相关选项改为所有任务完成后生效 2025-05-09 21:52:18 +08:00
DLmaster361
5893aa2426 feat(ui): 自定义基建显示配置名称 2025-05-09 19:54:07 +08:00
475 changed files with 62523 additions and 12065 deletions

View File

@@ -1,129 +0,0 @@
# 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 3.12
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
choco install innosetup
echo "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- 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: Package
id: package
run: |
copy app\utils\package.py .\
python package.py
- name: Read version
id: read_version
run: |
$MAIN_VERSION=(Get-Content -Path "version_info.txt" -TotalCount 1).Trim()
"AUTO_MAA_version=$MAIN_VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append
$UPDATER_VERSION=(Get-Content -Path "version_info.txt" -TotalCount 2 | Select-Object -Index 1).Trim()
"updater_version=$UPDATER_VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA_${{ env.AUTO_MAA_version }}
path: AUTO_MAA_${{ env.AUTO_MAA_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_TAIL="\`\`\`本release通过GitHub Actions自动构建\`\`\`"
NOTES="$NOTES_MAIN<br><br>$NOTES_TAIL"
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 }}

View File

@@ -1,21 +0,0 @@
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

@@ -1,19 +0,0 @@
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 }}

29
.gitignore vendored
View File

@@ -1,8 +1,31 @@
# 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/
resources/notice.json
resources/theme_image.json
resources/images/Home/BannerTheme.jpg
script/
res/notice.json
res/theme_image.json
res/images/Home/BannerTheme.jpg

104
README.md
View File

@@ -1,102 +1,2 @@
<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://mirrorchyan.com/zh/projects?rid=AUTO_MAA"><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项目未能解决的部分问题提高代理的稳定性。
### 原理
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
1. **配置:** 根据对应用户的配置信息生成配置文件并将其导入MAA。
2. **监测:** 在MAA开始代理后持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时通过重启MAA使之仍能继续完成任务。
3. **循环:** 重复上述步骤使MAA依次完成各个用户的自动代理任务。
### 优势
- **节省运行开销:** 只需要一份MAA软件与一个模拟器无需多开就能完成多账号代理羸弱的电脑也能代理日常。
- **自定义空间大:** 依靠高级用户配置模式支持MAA几乎所有设置选项自定义支持模拟器多开。
- **调度方法自由:** 通过调度队列功能自由实现MAA多开等多种调度方式。
- **一键代理无忧:** 无须中途手动修改MAA配置将繁琐交给AUTO_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参与项目。避免私信或私发邮件安全性漏洞或敏感问题除外以帮助更多用户。
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准发生冲突的以本细则为准。如有不清楚的部分请发Issues询问。若发生纠纷相关内容也没有在Issues上提及的项目组拥有最终解释权。
**注意**
- 由于本软件有修改其它目录JSON文件等行为使用前请将AUTO_MAA添加入Windows Defender信任区以及防病毒软件的信任区或开发者目录避免被误杀。
---
# 使用方法
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
---
# 关于
## 项目开发情况
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
## 贡献者
感谢以下贡献者对本项目做出的贡献
<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")
感谢 [AoXuan (@ClozyA)](https://github.com/ClozyA) 为本项目提供的下载服务器
## 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")
TEST
TEST

View File

@@ -1,49 +1,34 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主程序包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.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
__all__ = [
"QueueConfig",
"MaaConfig",
"MaaUserConfig",
"Task",
"TaskManager",
"MainTimer",
"MaaManager",
"Notify",
"Crypto",
"System",
"AUTO_MAA",
]
from .api import *
from .core import *
from .models import *
from .services import *
from .utils import *
__all__ = ["api", "core", "models", "services", "utils"]

47
app/api/__init__.py Normal file
View File

@@ -0,0 +1,47 @@
# 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",
]

95
app/api/core.py Normal file
View File

@@ -0,0 +1,95 @@
# 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()

87
app/api/dispatch.py Normal file
View File

@@ -0,0 +1,87 @@
# 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()

88
app/api/history.py Normal file
View File

@@ -0,0 +1,88 @@
# 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)

215
app/api/info.py Normal file
View File

@@ -0,0 +1,215 @@
# 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})

106
app/api/plan.py Normal file
View File

@@ -0,0 +1,106 @@
# 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()

259
app/api/queue.py Normal file
View File

@@ -0,0 +1,259 @@
# 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()

283
app/api/scripts.py Normal file
View File

@@ -0,0 +1,283 @@
# 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()

198
app/api/setting.py Normal file
View File

@@ -0,0 +1,198 @@
# 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 *
from app.models.config import Webhook as WebhookConfig
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/get",
summary="查询 webhook 配置",
response_model=WebhookGetOut,
status_code=200,
)
async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
try:
index, data = await Config.get_webhook(None, None, webhook.webhookId)
index = [WebhookIndexItem(**_) for _ in index]
data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
except Exception as e:
return WebhookGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
index=[],
data={},
)
return WebhookGetOut(index=index, data=data)
@router.post(
"/webhook/add",
summary="添加定时项",
response_model=WebhookCreateOut,
status_code=200,
)
async def add_webhook() -> WebhookCreateOut:
uid, config = await Config.add_webhook(None, None)
data = Webhook(**(await config.toDict()))
return WebhookCreateOut(webhookId=str(uid), data=data)
@router.post(
"/webhook/update", summary="更新定时项", response_model=OutBase, status_code=200
)
async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
try:
await Config.update_webhook(
None, None, webhook.webhookId, webhook.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(
"/webhook/delete", summary="删除定时项", response_model=OutBase, status_code=200
)
async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
try:
await Config.del_webhook(None, None, webhook.webhookId)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/order", summary="重新排序定时项", response_model=OutBase, status_code=200
)
async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
try:
await Config.reorder_webhook(None, None, webhook.indexList)
except Exception as e:
return OutBase(
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
)
return OutBase()
@router.post(
"/webhook/test", summary="测试Webhook配置", response_model=OutBase, status_code=200
)
async def test_webhook(webhook: WebhookTestIn = Body(...)) -> OutBase:
"""测试自定义Webhook"""
try:
webhook_config = WebhookConfig()
await webhook_config.load(webhook.data.model_dump())
await Notify.WebhookPush(
"AUTO-MAS Webhook测试",
"这是一条测试消息如果您收到此消息说明Webhook配置正确",
webhook_config,
)
except Exception as e:
return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")
return OutBase()

82
app/api/update.py Normal file
View File

@@ -0,0 +1,82 @@
# 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,48 +1,41 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA核心组件包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .config import QueueConfig, MaaConfig, MaaUserConfig, Config
from .main_info_bar import MainInfoBar
from .network import Network
from .task_manager import Task, TaskManager
from .broadcast import Broadcast
from .config import Config, MaaConfig, GeneralConfig, MaaUserConfig, GeneralUserConfig
from .timer import MainTimer
from .task_manager import TaskManager
__all__ = [
"Broadcast",
"Config",
"QueueConfig",
"MaaConfig",
"MaaUserConfig",
"MainInfoBar",
"Network",
"Task",
"TaskManager",
"GeneralConfig",
"MainTimer",
"TaskManager",
"MaaUserConfig",
"GeneralUserConfig",
]

52
app/core/broadcast.py Normal file
View File

@@ -0,0 +1,52 @@
# 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):
"""向所有订阅者广播消息"""
for subscriber in self.__subscribers:
await subscriber.put(deepcopy(item))
Broadcast = _Broadcast()

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +0,0 @@
# 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.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import Qt
from qfluentwidgets import InfoBar, InfoBarPosition
from .config import Config
class _MainInfoBar:
"""信息通知栏"""
def push_info_bar(self, mode: str, title: str, content: str, time: int):
"""推送到信息通知栏"""
if Config.main_window is None:
logger.error("信息通知栏未设置父窗口")
return None
# 定义模式到 InfoBar 方法的映射
mode_mapping = {
"success": InfoBar.success,
"warning": InfoBar.warning,
"error": InfoBar.error,
"info": InfoBar.info,
}
# 根据 mode 获取对应的 InfoBar 方法
info_bar_method = mode_mapping.get(mode)
if info_bar_method:
info_bar_method(
title=title,
content=content,
orient=Qt.Horizontal,
isClosable=True,
position=InfoBarPosition.TOP_RIGHT,
duration=time,
parent=Config.main_window,
)
else:
logger.error(f"未知的通知栏模式: {mode}")
MainInfoBar = _MainInfoBar()

View File

@@ -1,120 +0,0 @@
# 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.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtCore import QThread, QEventLoop, QTimer
import time
import requests
from pathlib import Path
class _Network(QThread):
max_retries = 3
timeout = 10
backoff_factor = 0.1
def __init__(self) -> None:
super().__init__()
self.if_running = False
self.mode = None
self.url = None
self.loop = QEventLoop()
self.wait_loop = QEventLoop()
@logger.catch
def run(self) -> None:
"""运行网络请求线程"""
self.if_running = True
if self.mode == "get":
self.get_json(self.url)
elif self.mode == "get_file":
self.get_file(self.url, self.path)
self.if_running = False
def set_info(self, mode: str, url: str, path: Path = None) -> None:
"""设置网络请求信息"""
while self.if_running:
QTimer.singleShot(self.backoff_factor * 1000, self.wait_loop.quit)
self.wait_loop.exec()
self.mode = mode
self.url = url
self.path = path
self.stutus_code = None
self.response_json = None
self.error_message = None
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)
self.stutus_code = response.status_code
self.response_json = response.json()
self.error_message = None
break
except Exception as e:
self.stutus_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)
if response.status_code == 200:
with open(path, "wb") as file:
file.write(response.content)
self.stutus_code = response.status_code
else:
self.stutus_code = response.status_code
self.error_message = "下载失败"
except Exception as e:
self.stutus_code = response.status_code if response else None
self.error_message = str(e)
self.loop.quit()
Network = _Network()

View File

@@ -1,334 +1,384 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA业务调度器
v4.3
作者DLmaster_361
"""
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
import uuid
import asyncio
from functools import partial
from typing import Dict, Optional, Literal
from .config import Config
from .main_info_bar import MainInfoBar
from .network import Network
from app.models import MaaManager
from .config import Config, MaaConfig, GeneralConfig, QueueConfig
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
class Task(QThread):
"""业务线程"""
check_maa_version = Signal(str)
push_info_bar = Signal(str, str, str, int)
question = Signal(str, str)
question_response = Signal(bool)
update_user_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)
def __init__(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
super(Task, self).__init__()
self.mode = mode
self.name = name
self.info = info
self.logs = []
self.question_response.connect(lambda: print("response"))
@logger.catch
def run(self):
if "设置MAA" in self.mode:
logger.info(f"任务开始:设置{self.name}")
self.push_info_bar.emit("info", "设置MAA", self.name, 3000)
self.task = MaaManager(
self.mode,
Config.member_dict[self.name],
(None if "全局" in self.mode else self.info["SetMaaInfo"]["Path"]),
)
self.task.check_maa_version.connect(self.check_maa_version.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
self.task.run()
else:
self.task_list = [
[
(
value
if Config.member_dict[value]["Config"].get(
Config.member_dict[value]["Config"].MaaSet_Name
)
== ""
else f"{value} - {Config.member_dict[value]["Config"].get(Config.member_dict[value]["Config"].MaaSet_Name)}"
),
"等待",
value,
]
for _, value in sorted(
self.info["Queue"].items(), key=lambda x: int(x[0][7:])
)
if value != "禁用"
]
self.create_task_list.emit(self.task_list)
for task in self.task_list:
if self.isInterruptionRequested():
break
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.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_user_info.emit)
self.task.accomplish.connect(
lambda log: self.task_accomplish(task[2], log)
)
self.task.run()
Config.running_list.remove(task[2])
task[1] = "完成"
self.update_task_list.emit(self.task_list)
logger.info(f"任务完成:{task[0]}")
self.push_info_bar.emit("info", "任务完成", task[0], 3000)
self.accomplish.emit(self.logs)
def task_accomplish(self, name: str, log: dict):
"""保存保存任务结果"""
self.logs.append([name, log])
self.task.deleteLater()
logger = get_logger("业务调度")
class _TaskManager(QObject):
class _TaskManager:
"""业务调度器"""
create_gui = Signal(Task)
connect_gui = Signal(Task)
def __init__(self):
super(_TaskManager, self).__init__()
super().__init__()
self.task_dict: Dict[str, Task] = {}
self.task_dict: Dict[uuid.UUID, asyncio.Task] = {}
def add_task(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
"""添加任务"""
async def add_task(
self, mode: Literal["自动代理", "人工排查", "设置脚本"], uid: str
) -> uuid.UUID:
"""
添加任务
if name in Config.running_list or name in self.task_dict:
:param mode: 任务模式
:param uid: 任务UID
"""
logger.warning(f"任务已存在:{name}")
MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000)
return None
actual_id = uuid.UUID(uid)
logger.info(f"任务开始:{name}")
MainInfoBar.push_info_bar("info", "任务开始", name, 3000)
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].update_user_info.connect(Config.change_user_info)
self.task_dict[name].accomplish.connect(
lambda logs: self.remove_task(mode, name, logs)
)
if "新调度台" in mode:
self.create_gui.emit(self.task_dict[name])
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)
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})
if mode == "设置脚本":
if actual_id in Config.ScriptConfig:
task_id = actual_id
actual_id = None
else:
Config.save_history(
name,
{
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"History": "没有任务被执行",
},
for script_id, script in Config.ScriptConfig.items():
if 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
@logger.catch
async def run_task(
self, mode: str, task_id: uuid.UUID, actual_id: Optional[uuid.UUID]
):
logger.info(f"开始运行任务: {task_id}, 模式: {mode}")
if mode == "设置脚本":
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_dict[uid].add_done_callback(partial(self.task_dict.pop, uid))
try:
await self.task_dict[uid]
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()
)
if (
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
)
!= "None"
):
else:
from app.ui import ProgressRingMessageBox
# 初始化任务列表
if task_id in Config.QueueConfig:
mode_book = {
"Shutdown": "关机",
"Hibernate": "休眠",
"Sleep": "睡眠",
"KillSelf": "关闭AUTO_MAA",
}
task_list = []
for queue_item in Config.QueueConfig[task_id].QueueItem.values():
if queue_item.get("Info", "ScriptId") == "-":
continue
script_uid = uuid.UUID(queue_item.get("Info", "ScriptId"))
choice = ProgressRingMessageBox(
Config.main_window,
f"{mode_book[Config.queue_dict[name]["Config"].get(Config.queue_dict[name]["Config"].queueSet_AfterAccomplish)]}倒计时",
)
if choice.exec():
System.set_power(
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
)
task_list.append(
{
"script_id": str(script_uid),
"status": "等待",
"name": Config.ScriptConfig[script_uid].get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in Config.ScriptConfig[
script_uid
].UserData.items()
if config.get("Info", "Status")
and config.get("Info", "RemainedDay") != 0
],
}
)
def check_maa_version(self, v: str):
"""检查MAA版本"""
elif actual_id is not None and actual_id in Config.ScriptConfig:
Network.set_info(
mode="get",
url="https://mirrorchyan.com/api/resources/MAA/latest?user_agent=AutoMaaGui&os=win&arch=x64&channel=stable",
)
Network.start()
Network.loop.exec()
if Network.stutus_code == 200:
maa_info = Network.response_json
task_list = [
{
"script_id": str(actual_id),
"status": "等待",
"name": Config.ScriptConfig[actual_id].get("Info", "Name"),
"user_list": [
{
"user_id": str(user_id),
"status": "等待",
"name": config.get("Info", "Name"),
}
for user_id, config in Config.ScriptConfig[
actual_id
].UserData.items()
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()
)
# 清理用户列表初值
for task in task_list:
task.pop("user_list", None)
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:
logger.warning(f"获取MAA版本信息时出错{Network.error_message}")
MainInfoBar.push_info_bar(
"warning",
"获取MAA版本信息时出错",
f"网络错误:{Network.stutus_code}",
5000,
)
return None
uid = uuid.UUID(task_id)
if uid not in self.task_dict:
raise ValueError("任务未在运行")
self.task_dict[uid].cancel()
if version.parse(maa_info["data"]["version_name"]) > version.parse(v):
async def remove_task(
self, task: asyncio.Task, mode: str, task_id: uuid.UUID
) -> None:
"""
处理任务结束后的收尾工作
logger.info(
f"检测到MAA版本过低{v},最新版本:{maa_info['data']['version_name']}"
)
MainInfoBar.push_info_bar(
"info",
"MAA版本过低",
f"当前版本:{v},最新稳定版:{maa_info['data']['version_name']}",
-1,
)
Parameters
----------
task : asyncio.Task
任务对象
mode : str
任务模式
task_id : uuid.UUID
任务ID
"""
def push_dialog(self, name: str, title: str, content: str):
"""推送对话框"""
logger.info(f"任务结束: {task_id}")
choice = MessageBox(title, content, Config.main_window)
choice.yesButton.setText("")
choice.cancelButton.setText("")
# 从任务字典中移除任务
try:
await task
except asyncio.CancelledError:
logger.info(f"任务 {task_id} 已结束")
self.task_dict.pop(task_id)
self.task_dict[name].question_response.emit(bool(choice.exec()))
await Config.send_json(
WebSocketMessage(
id=str(task_id), type="Signal", data={"Accomplish": "无描述"}
).model_dump()
)
if mode == "自动代理" and task_id in Config.QueueConfig:
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()
)
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()
)
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()
)
logger.success("启动时任务开始运行")
TaskManager = _TaskManager()

View File

@@ -1,117 +1,147 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主业务定时器
v4.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import QTimer
import asyncio
import keyboard
from datetime import datetime
import pyautogui
from .config import Config
from app.services import Matomo, System
from app.utils import get_logger
from app.models.schema import WebSocketMessage
from .config import Config, QueueConfig
from .task_manager import TaskManager
from app.services import System
class _MainTimer(QWidget):
logger = get_logger("主业务定时器")
def __init__(self, parent=None):
super().__init__(parent)
self.if_FailSafeException = False
class _MainTimer:
self.Timer = QTimer()
self.Timer.timeout.connect(self.timed_start)
self.Timer.timeout.connect(self.set_silence)
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_gameid()
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()
def timed_start(self):
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):
"""定时启动代理任务"""
for name, info in Config.queue_dict.items():
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
if not info["Config"].get(info["Config"].queueSet_Enabled):
for uid, queue in Config.QueueConfig.items():
if not queue.get("Info", "TimeEnabled"):
continue
data = info["Config"].toDict()
# 避免重复调起任务
if curtime == queue.get("Data", "LastTimedStart"):
continue
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
):
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()
logger.info(f"定时任务:{name}")
TaskManager.add_task("自动代理_新调度台", name, data)
await Config.send_json(
WebSocketMessage(
id="TaskManager",
type="Signal",
data={"newTask": str(task_id)},
).model_dump()
)
def set_silence(self):
"""设置静默模式"""
@logger.catch()
async def set_silence(self):
"""静默模式通过模拟老板键来隐藏模拟器窗口"""
if (
not Config.if_ignore_silence
and Config.get(Config.function_IfSilence)
and Config.get(Config.function_BossKey) != ""
len(Config.if_ignore_silence) == 0
and Config.get("Function", "IfSilence")
and Config.get("Function", "BossKey") != ""
):
windows = System.get_window_info()
if any(
str(emulator_path) in window
for window in windows
for emulator_path in Config.silence_list
):
windows = await 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}")
try:
pyautogui.hotkey(
*[
keyboard.press_and_release(
"+".join(
_.strip().lower()
for _ in Config.get(Config.function_BossKey).split("+")
]
for _ in Config.get("Function", "BossKey").split("+")
)
)
except pyautogui.FailSafeException as e:
if not self.if_FailSafeException:
logger.warning(f"FailSafeException: {e}")
self.if_FailSafeException = True
logger.info(f"模拟按键: {Config.get('Function', 'BossKey')}")
except Exception as e:
logger.exception(f"模拟按键时出错: {e}")
MainTimer = _MainTimer()

934
app/models/ConfigBase.py Normal file
View File

@@ -0,0 +1,934 @@
# 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 urllib.parse import urlparse
from datetime import datetime
from pathlib import Path
from typing import List, Any, Dict, Union, Optional, TypeVar, Generic, Type
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 __init__(self, tpye: type[dict] | type[list] = dict) -> None:
self.type = tpye
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
try:
data = json.loads(value)
if isinstance(data, self.type):
return True
else:
return False
except json.JSONDecodeError:
return False
def correct(self, value: Any) -> str:
return (
value if self.validate(value) else ("{ }" if self.type == dict 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 URLValidator(ConfigValidator):
"""URL格式验证器"""
def __init__(
self,
schemes: list[str] | None = None,
require_netloc: bool = True,
default: str = "",
):
"""
:param schemes: 允许的协议列表, 若为 None 则允许任意协议
:param require_netloc: 是否要求必须包含网络位置, 如域名或IP
"""
self.schemes = [s.lower() for s in schemes] if schemes else None
self.require_netloc = require_netloc
self.default = default
def validate(self, value: Any) -> bool:
if value == self.default:
return True
if not isinstance(value, str):
return False
try:
parsed = urlparse(value)
except Exception:
return False
# 检查协议
if self.schemes is not None:
if not parsed.scheme or parsed.scheme.lower() not in self.schemes:
return False
else:
# 不限制协议仍要求有 scheme
if not parsed.scheme:
return False
# 检查是否包含网络位置
if self.require_netloc and not parsed.netloc:
return False
return True
def correct(self, value: Any) -> str:
if self.validate(value):
return value
if isinstance(value, str):
# 简单尝试:若看起来像域名,加上 https://
stripped = value.strip()
if stripped and not stripped.startswith(("http://", "https://")):
candidate = f"https://{stripped}"
if self.validate(candidate):
return candidate
return self.default
class ConfigItem:
"""配置项"""
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()
T = TypeVar("T", bound="ConfigBase")
class MultipleConfig(Generic[T]):
"""
多配置项管理类
这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。
允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。
Parameters
----------
sub_config_type: List[type]
子配置项的类型列表, 必须是 ConfigBase 的子类
"""
def __init__(self, sub_config_type: List[Type[T]]):
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: List[Type[T]] = sub_config_type
self.file: Path | None = None
self.order: List[uuid.UUID] = []
self.data: Dict[uuid.UUID, T] = {}
self.is_locked = False
def __getitem__(self, key: uuid.UUID) -> T:
"""允许通过 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, ignore_multi_config: bool = False, if_decrypt: bool = True
) -> 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(ignore_multi_config, if_decrypt)
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[T]) -> tuple[uuid.UUID, T]:
"""
添加一个新的配置项
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,31 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA模组包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .MAA import MaaManager
from .ConfigBase import *
from .config import *
from .schema import *
__all__ = ["MaaManager"]
__all__ = ["ConfigBase", "config", "schema"]

569
app/models/config.py Normal file
View File

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

872
app/models/schema.py Normal file
View File

@@ -0,0 +1,872 @@
# 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 WebhookIndexItem(BaseModel):
uid: str = Field(..., description="唯一标识符")
type: Literal["Webhook"] = Field(..., description="配置类型")
class Webhook_Info(BaseModel):
Name: Optional[str] = Field(default=None, description="Webhook名称")
Enabled: Optional[bool] = Field(default=None, description="是否启用")
class Webhook_Data(BaseModel):
Url: Optional[str] = Field(default=None, description="Webhook URL")
Template: Optional[str] = Field(default=None, description="消息模板")
Headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
Method: Optional[Literal["POST", "GET"]] = Field(
default=None, description="请求方法"
)
class Webhook(BaseModel):
Info: Optional[Webhook_Info] = Field(default=None, description="Webhook基础信息")
Data: Optional[Webhook_Data] = Field(default=None, description="Webhook配置数据")
class GlobalConfig_Function(BaseModel):
HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
None, description="历史记录保留时间, 0表示永久保存"
)
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 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推送密钥")
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")
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 WebhookInBase(BaseModel):
scriptId: Optional[str] = Field(
default=None, description="所属脚本ID, 获取全局设置的Webhook数据时无需携带"
)
userId: Optional[str] = Field(
default=None, description="所属用户ID, 获取全局设置的Webhook数据时无需携带"
)
class WebhookGetIn(WebhookInBase):
webhookId: Optional[str] = Field(
default=None, description="Webhook ID, 未携带时表示获取所有Webhook数据"
)
class WebhookGetOut(OutBase):
index: List[WebhookIndexItem] = Field(..., description="Webhook索引列表")
data: Dict[str, Webhook] = Field(
..., description="Webhook数据字典, key来自于index列表的uid"
)
class WebhookCreateOut(OutBase):
webhookId: str = Field(..., description="新创建的Webhook ID")
data: Webhook = Field(..., description="Webhook配置数据")
class WebhookUpdateIn(WebhookInBase):
webhookId: str = Field(..., description="Webhook ID")
data: Webhook = Field(..., description="Webhook更新数据")
class WebhookDeleteIn(WebhookInBase):
webhookId: str = Field(..., description="Webhook ID")
class WebhookReorderIn(WebhookInBase):
indexList: List[str] = Field(..., description="Webhook ID列表, 按新顺序排列")
class WebhookTestIn(WebhookInBase):
data: Webhook = Field(..., description="Webhook配置数据")
class PlanCreateIn(BaseModel):
type: Literal["MaaPlan"]
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,36 +1,31 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA服务包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.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
__all__ = ["Notify", "Crypto", "System"]
__all__ = ["Matomo", "Notify", "System", "Updater"]

125
app/services/matomo.py Normal file
View File

@@ -0,0 +1,125 @@
# 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,316 +1,431 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA通知服务
v4.3
作者DLmaster_361
"""
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import Signal
import requests
import time
from loguru import logger
from plyer import notification
import re
import json
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import requests
from datetime import datetime
from plyer import notification
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
from typing import Literal
from app.core import Config
from app.services.security import Crypto
from app.models.config import Webhook
from app.utils import get_logger, ImageUtils
logger = get_logger("通知服务")
class Notification(QWidget):
class Notification:
push_info_bar = Signal(str, str, str, int)
async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> None:
"""
推送系统通知
def __init__(self, parent=None):
super().__init__(parent)
Parameters
----------
title: str
通知标题
message: str
通知内容
ticker: str
通知横幅
t: int
通知持续时间
"""
def push_plyer(self, title, message, ticker, t):
"""推送系统通知"""
if not Config.get("Notify", "IfPushPlyer"):
return
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_MAA",
app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"),
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 未正确导入, 无法推送系统通知")
return True
async def send_mail(
self, mode: Literal["文本", "网页"], title: str, content: str, to_address: str
) -> None:
"""
推送邮件通知
def send_mail(self, mode, title, content) -> None:
"""推送邮件通知"""
Parameters
----------
mode: Literal["文本", "网页"]
邮件内容模式, 支持 "文本""网页"
title: str
邮件标题
content: str
邮件内容
to_address: str
收件人地址
"""
if Config.get(Config.notify_IfSendMail):
if Config.get("Notify", "SMTPServerAddress") == "":
raise ValueError("邮件通知的SMTP服务器地址不能为空")
if Config.get("Notify", "AuthorizationCode") == "":
raise ValueError("邮件通知的授权码不能为空")
if not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get("Notify", "FromAddress"),
)
):
raise ValueError("邮件通知的发送邮箱格式错误或为空")
if not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
to_address,
)
):
raise ValueError("邮件通知的接收邮箱格式错误或为空")
if (
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(Config.notify_FromAddress),
)
)
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get(Config.notify_ToAddress),
)
)
):
logger.error(
"请正确设置邮件通知的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"),
)
) # 发件人显示的名字
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: str, content: str, send_key: str) -> None:
"""
使用Server酱推送通知
Parameters
----------
title: str
通知标题
content: str
通知内容
send_key: str
Server酱的SendKey
"""
if 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 WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
"""
Webhook 推送通知
Parameters
----------
title: str
通知标题
content: str
通知内容
webhook: Webhook
Webhook配置对象
"""
if not webhook.get("Info", "Enabled"):
return
if webhook.get("Data", "Url") == "":
raise ValueError("Webhook URL 不能为空")
# 解析模板
template = (
webhook.get("Data", "Template")
or '{"title": "{title}", "content": "{content}"}'
)
# 替换模板变量
try:
# 准备模板变量
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:
# 定义邮件正文
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(),
Config.get(Config.notify_ToAddress),
)
) # 收件人显示的名字
message["Subject"] = Header(title, "utf-8")
# 解析模板为JSON对象然后替换其中的变量
template_obj = json.loads(template)
if mode == "网页":
message.attach(MIMEText(content, "html", "utf-8"))
smtpObj = smtplib.SMTP_SSL(
Config.get(Config.notify_SMTPServerAddress),
465,
)
smtpObj.login(
Config.get(Config.notify_FromAddress),
Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)),
)
smtpObj.sendmail(
Config.get(Config.notify_FromAddress),
Config.get(Config.notify_ToAddress),
message.as_string(),
)
smtpObj.quit()
logger.success("邮件发送成功")
except Exception as e:
logger.error(f"发送邮件时出错:\n{e}")
self.push_info_bar.emit("error", "发送邮件时出错", f"{e}", -1)
def ServerChanPush(self, title, content):
"""使用Server酱推送通知支持 tag 和 channel避免使用SDK"""
if Config.get(Config.notify_IfServerChan):
send_key = Config.get(Config.notify_ServerChanKey)
if not send_key:
logger.error("请正确设置Server酱的SendKey")
self.push_info_bar.emit(
"error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1
)
return None
try:
# 构造 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"
# 递归替换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:
raise ValueError("SendKey 格式错误sctp")
else:
url = f"https://sctapi.ftqq.com/{send_key}.send"
return obj
# 构建 tags 和 channel
def is_valid(s):
return s == "" or (
s == "|".join(s.split("|"))
and (s.count("|") == 0 or all(s.split("|")))
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
)
tags = "|".join(
_.strip()
for _ in Config.get(Config.notify_ServerChanTag).split("|")
)
channels = "|".join(
_.strip()
for _ in Config.get(Config.notify_ServerChanChannel).split("|")
)
options = {}
if is_valid(tags):
options["tags"] = tags
else:
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)
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:
logger.exception("Server酱通知推送异常")
self.push_info_bar.emit(
"error", "Server酱通知推送异常", f"请检查相关设置,如还有问题可联系开发者", -1
)
return f"Server酱通知推送异常{str(e)}"
def CompanyWebHookBotPush(self, title, content):
"""使用企业微信群机器人推送通知"""
if Config.get(Config.notify_IfCompanyWebHookBot):
if Config.get(Config.notify_CompanyWebHookBotUrl) == "":
logger.error("请正确设置企业微信群机器人的WebHook地址")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"请正确设置企业微信群机器人的WebHook地址",
-1,
)
return None
content = f"{title}\n{content}"
data = {"msgtype": "text", "text": {"content": content}}
# 从远程服务器获取最新主题图像
for _ in range(3):
# 再次尝试解析为JSON
try:
response = requests.post(
url=Config.get(Config.notify_CompanyWebHookBotUrl),
json=data,
timeout=10,
)
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'使用企业微信群机器人推送通知时出错:{info["errmsg"]}',
-1,
)
return None
data = json.loads(formatted_template)
logger.debug(f"字符串模板解析为JSON成功: {data}")
except json.JSONDecodeError:
# 最终作为纯文本发送
data = formatted_template
logger.debug(f"作为纯文本发送: {data}")
if info["errcode"] == 0:
logger.info("企业微信群机器人推送通知成功")
return True
else:
logger.error(f"企业微信群机器人推送通知失败:{info}")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送失败",
f'使用企业微信群机器人推送通知时出错:{info["errmsg"]}',
-1,
)
return f'使用企业微信群机器人推送通知时出错:{info["errmsg"]}'
except Exception as e:
logger.warning(f"模板解析失败,使用默认格式: {e}")
data = {"title": title, "content": content}
def send_test_notification(self):
# 准备请求头
headers = {"Content-Type": "application/json"}
headers.update(json.loads(webhook.get("Data", "Headers")))
if webhook.get("Data", "Method") == "POST":
if isinstance(data, dict):
response = requests.post(
url=webhook.get("Data", "Url"),
json=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
elif isinstance(data, str):
response = requests.post(
url=webhook.get("Data", "Url"),
data=data,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
elif webhook.get("Data", "Method") == "GET":
if isinstance(data, dict):
# Flatten params to ensure all values are str or list of str
params = {}
for k, v in data.items():
if isinstance(v, (dict, list)):
params[k] = json.dumps(v, ensure_ascii=False)
else:
params[k] = str(v)
else:
params = {"message": str(data)}
response = requests.get(
url=webhook.get("Data", "Url"),
params=params,
headers=headers,
timeout=10,
proxies=Config.get_proxies(),
)
# 检查响应
if response.status_code == 200:
logger.success(
f"自定义Webhook推送成功: {webhook.get('Info', 'Name')} - {title}"
)
else:
raise Exception(f"HTTP {response.status_code}: {response.text}")
async def _WebHookPush(self, title, content, webhook_url) -> None:
"""
WebHook 推送通知 (即将弃用)
:param title: 通知标题
:param content: 通知内容
:param webhook_url: WebHook地址
"""
if not webhook_url:
raise ValueError("WebHook 地址不能为空")
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()
if info["errcode"] == 0:
logger.success(f"WebHook 推送通知成功: {title}")
else:
raise Exception(f"WebHook 推送通知失败: {response.text}")
async def CompanyWebHookBotPushImage(
self, image_path: Path, webhook_url: str
) -> None:
"""
使用企业微信群机器人推送图片通知(等待重新适配)
:param image_path: 图片文件路径
:param webhook_url: 企业微信群机器人的WebHook地址
"""
if not webhook_url:
raise ValueError("webhook URL 不能为空")
# 压缩图片
ImageUtils.compress_image_if_needed(image_path)
# 检查图片是否存在
if not image_path.exists():
raise FileNotFoundError(f"文件未找到: {image_path}")
# 获取图片base64和md5
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
data = {
"msgtype": "image",
"image": {"base64": image_base64, "md5": image_md5},
}
response = requests.post(
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
)
info = response.json()
if info.get("errcode") == 0:
logger.success(f"企业微信群机器人推送图片成功: {image_path.name}")
else:
raise Exception(f"企业微信群机器人推送图片失败: {response.text}")
async def send_test_notification(self) -> None:
"""发送测试通知到所有已启用的通知渠道"""
logger.info("发送测试通知到所有已启用的通知渠道")
# 发送系统通知
self.push_plyer(
await self.push_plyer(
"测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
"测试通知",
3,
)
# 发送邮件通知
if Config.get(Config.notify_IfSendMail):
self.send_mail(
if Config.get("Notify", "IfSendMail"):
await self.send_mail(
"文本",
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
Config.get("Notify", "ToAddress"),
)
# 发送Server酱通知
if Config.get(Config.notify_IfServerChan):
self.ServerChanPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
if Config.get("Notify", "IfServerChan"):
await self.ServerChanPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
Config.get("Notify", "ServerChanKey"),
)
# 发送企业微信机器人通知
if Config.get(Config.notify_IfCompanyWebHookBot):
self.CompanyWebHookBotPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
# 发送自定义Webhook通知
for webhook in Config.Notify_CustomWebhooks.values():
await self.WebhookPush(
"AUTO-MAS测试通知",
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
webhook,
)
return True
logger.success("测试通知发送完成")
Notify = Notification()

View File

@@ -1,212 +0,0 @@
# 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.3
作者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():
# 使用旧管理密钥解密
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():
# 使用新管理密钥重新加密
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 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,58 +1,58 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA系统服务
v4.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import QApplication
import sys
import ctypes
import asyncio
import win32gui
import win32process
import winreg
import psutil
import subprocess
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):
def __init__(self) -> None:
self.power_task: Optional[asyncio.Task] = None
self.set_Sleep()
self.set_SelfStart()
def set_Sleep(self) -> None:
async def set_Sleep(self) -> None:
"""同步系统休眠状态"""
if Config.get(Config.function_IfAllowSleep):
if Config.get("Function", "IfAllowSleep"):
# 设置系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(
self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED
@@ -61,41 +61,154 @@ class _SystemHandler:
# 恢复系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS)
def set_SelfStart(self) -> None:
async def set_SelfStart(self) -> None:
"""同步开机自启"""
if Config.get(Config.start_IfSelfStart) and not self.is_startup():
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
winreg.KEY_SET_VALUE,
winreg.KEY_ALL_ACCESS | winreg.KEY_WRITE | winreg.KEY_CREATE_SUB_KEY,
)
winreg.SetValueEx(key, "AUTO_MAA", 0, winreg.REG_SZ, Config.app_path_sys)
winreg.CloseKey(key)
elif not Config.get(Config.start_IfSelfStart) and self.is_startup():
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
winreg.KEY_SET_VALUE,
winreg.KEY_ALL_ACCESS | winreg.KEY_WRITE | winreg.KEY_CREATE_SUB_KEY,
)
winreg.DeleteValue(key, "AUTO_MAA")
winreg.CloseKey(key)
if Config.get("Start", "IfSelfStart") and not await self.is_startup():
def set_power(self, mode) -> None:
# 创建任务计划
try:
# 获取当前用户和时间
current_user = getpass.getuser()
current_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
# XML 模板
xml_content = f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>{current_time}</Date>
<Author>{current_user}</Author>
<Description>AUTO-MAS自启动服务</Description>
<URI>\\AUTO-MAS_AutoStart</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<StartBoundary>{current_time}</StartBoundary>
<Enabled>true</Enabled>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>"{Path.cwd() / 'AUTO-MAS.exe'}"</Command>
</Exec>
</Actions>
</Task>"""
# 创建临时 XML 文件并执行
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False, encoding="utf-16"
) as f:
f.write(xml_content)
xml_file = f.name
try:
result = subprocess.run(
[
"schtasks",
"/create",
"/tn",
"AUTO-MAS_AutoStart",
"/xml",
xml_file,
"/f",
],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.success(
f"程序自启动任务计划已创建: {Path.cwd() / 'AUTO-MAS.exe'}"
)
else:
logger.error(f"程序自启动任务计划创建失败: {result.stderr}")
finally:
# 删除临时文件
try:
Path(xml_file).unlink()
except:
pass
except Exception as e:
logger.exception(f"程序自启动任务计划创建失败: {e}")
elif not Config.get("Start", "IfSelfStart") and await self.is_startup():
try:
result = subprocess.run(
["schtasks", "/delete", "/tn", "AUTO-MAS_AutoStart", "/f"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.success("程序自启动任务计划已删除")
else:
logger.error(f"程序自启动任务计划删除失败: {result.stderr}")
except Exception as e:
logger.exception(f"程序自启动任务计划删除失败: {e}")
async def set_power(
self,
mode: Literal[
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
],
) -> None:
"""
执行系统电源操作
:param mode: 电源操作
"""
if sys.platform.startswith("win"):
if mode == "None":
if mode == "NoAction":
logger.info("不执行系统电源操作")
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("执行休眠操作")
@@ -108,14 +221,14 @@ class _SystemHandler:
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
)
elif mode == "KillSelf":
elif mode == "KillSelf" and Config.server is not None:
Config.main_window.close()
QApplication.quit()
logger.info("执行退出主程序操作")
Config.server.should_exit = True
elif sys.platform.startswith("linux"):
if mode == "None":
if mode == "NoAction":
logger.info("不执行系统电源操作")
@@ -134,31 +247,87 @@ class _SystemHandler:
logger.info("执行睡眠操作")
subprocess.run(["systemctl", "suspend"])
elif mode == "KillSelf":
elif mode == "KillSelf" and Config.server is not None:
Config.main_window.close()
QApplication.quit()
logger.info("执行退出主程序操作")
Config.server.should_exit = True
def is_startup(self) -> bool:
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:
"""判断程序是否已经开机自启"""
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0,
winreg.KEY_READ,
)
try:
value, _ = winreg.QueryValueEx(key, "AUTO_MAA")
winreg.CloseKey(key)
return True
except FileNotFoundError:
winreg.CloseKey(key)
result = subprocess.run(
["schtasks", "/query", "/tn", "AUTO-MAS_AutoStart"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
return result.returncode == 0
except Exception as e:
logger.exception(f"检查任务计划程序失败: {e}")
return False
def get_window_info(self) -> list:
"""获取当前窗口信息"""
async def get_window_info(self) -> list:
"""获取当前前台窗口信息"""
def callback(hwnd, window_info):
if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd):
@@ -171,10 +340,16 @@ class _SystemHandler:
win32gui.EnumWindows(callback, window_info)
return window_info
def kill_process(self, path: Path) -> None:
"""根据路径中止进程"""
async def kill_process(self, path: Path) -> None:
"""
根据路径中止进程
for pid in self.search_pids(path):
:param path: 进程路径
"""
logger.info(f"开始中止进程: {path}")
for pid in await self.search_pids(path):
killprocess = subprocess.Popen(
f"taskkill /F /T /PID {pid}",
shell=True,
@@ -182,8 +357,17 @@ class _SystemHandler:
)
killprocess.wait()
def search_pids(self, path: Path) -> list:
"""根据路径查找进程PID"""
logger.success(f"进程已中止: {path}")
async def search_pids(self, path: Path) -> list:
"""
根据路径查找进程PID
:param path: 进程路径
:return: 匹配的进程PID列表
"""
logger.info(f"开始查找进程 PID: {path}")
pids = []
for proc in psutil.process_iter(["pid", "exe"]):
@@ -191,7 +375,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

389
app/services/update.py Normal file
View File

@@ -0,0 +1,389 @@
# 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()

2086
app/task/MAA.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,32 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA图形化界面包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .main_window import AUTO_MAA
from .Widget import ProgressRingMessageBox
__all__ = ["AUTO_MAA", "ProgressRingMessageBox"]
from .skland import skland_sign_in
from .general import GeneralManager
from .MAA import MaaManager
__all__ = ["skland_sign_in", "GeneralManager", "MaaManager"]

1103
app/task/general.py Normal file

File diff suppressed because it is too large Load Diff

266
app/task/skland.py Normal file
View File

@@ -0,0 +1,266 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# 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.
# 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 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:
"""森空岛签到"""
app_code = "4ca99fa6b56cc2ba"
# 用于获取grant code
grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
# 用于获取cred
cred_code_url = "https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code"
# 查询角色绑定
binding_url = "https://zonai.skland.com/api/v1/game/player/binding"
# 签到接口
sign_url = "https://zonai.skland.com/api/v1/game/attendance"
# 基础请求头
header = {
"cred": "",
"User-Agent": "Skland/1.5.1 (com.hypergryph.skland; build:100501001; Android 34;) Okhttp/4.11.0",
"Accept-Encoding": "gzip",
"Connection": "close",
}
header_login = header.copy()
header_for_sign = {
"platform": "1",
"timestamp": "",
"dId": "",
"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字符串
:return: (sign, 新的header_for_sign字典)
"""
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
header_ca_str = json.dumps(header_ca, separators=(",", ":"))
s = path + body_or_query + t + header_ca_str # 拼接原始字符串
# HMAC-SHA256 + MD5得到最终sign
hex_s = hmac.new(token_bytes, s.encode("utf-8"), hashlib.sha256).hexdigest()
md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
return md5, header_ca
def get_sign_header(url: str, method, body, old_header, sign_token):
"""
获取带签名的请求头
:param url: 请求完整url
:param method: 请求方式 GET/POST
:param body: POST请求体或GET时为None
:param old_header: 原始请求头
:param sign_token: 当前会话的签名token
:return: 新请求头
"""
h = json.loads(json.dumps(old_header))
p = parse.urlparse(url)
if method.lower() == "get":
sign, header_ca = generate_signature(sign_token, p.path, p.query)
else:
sign, header_ca = generate_signature(
sign_token, p.path, json.dumps(body) if body else ""
)
h["sign"] = sign
for i in header_ca:
h[i] = header_ca[i]
return h
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
:param token_code: 你的skyland token
:return: (cred, sign_token)
"""
try:
# token为json对象时提取data.content
t = json.loads(token_code)
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)
"""
rsp = requests.post(
cred_code_url,
json={"code": grant, "kind": 1},
headers=header_login,
proxies=Config.get_proxies(),
).json()
if rsp["code"] != 0:
raise Exception(f"获得cred失败: {rsp.get('message')}")
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
"""
rsp = requests.post(
grant_code_url,
json={"appCode": app_code, "token": token, "type": 0},
headers=header_login,
proxies=Config.get_proxies(),
).json()
if rsp["status"] != 0:
raise Exception(
f"使用token: {token[:3]}******{token[-3:]} 获得认证代码失败: {rsp.get('msg')}"
)
return rsp["data"]["code"]
async def get_binding_list(cred, sign_token):
"""
查询已绑定的角色列表
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 角色列表
"""
v = []
rsp = requests.get(
binding_url,
headers=get_sign_header(
binding_url, "get", None, copy_header(cred), sign_token
),
proxies=Config.get_proxies(),
).json()
if rsp["code"] != 0:
logger.error(f"请求角色列表出现问题: {rsp['message']}")
if rsp.get("message") == "用户未登录":
logger.error(f"用户登录可能失效了, 请重新登录!")
return v
# 只取明日方舟arknights的绑定账号
for i in rsp["data"]["list"]:
if i.get("appCode") != "arknights":
continue
v.extend(i.get("bindingList"))
return v
async def do_sign(cred, sign_token) -> dict:
"""
对所有绑定的角色进行签到
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 签到结果字典
"""
characters = await get_binding_list(cred, sign_token)
result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
for character in characters:
body = {
"uid": character.get("uid"),
"gameId": character.get("channelMasterId"),
}
rsp = requests.post(
sign_url,
headers=get_sign_header(
sign_url, "post", body, copy_header(cred), sign_token
),
json=body,
proxies=Config.get_proxies(),
).json()
if rsp["code"] != 0:
result[
"重复" if rsp.get("message") == "请勿重复签到!" else "失败"
].append(
f"{character.get("nickName")}{character.get("channelName")}"
)
else:
result["成功"].append(
f"{character.get("nickName")}{character.get("channelName")}"
)
await asyncio.sleep(3)
return result
# 主流程
try:
# 拿到cred和sign_token
cred, sign_token = await login_by_token(token)
await asyncio.sleep(1)
# 依次签到
return await do_sign(cred, sign_token)
except Exception as e:
logger.exception(f"森空岛签到失败: {e}")
return {"成功": [], "重复": [], "失败": [], "总计": 0}

File diff suppressed because it is too large Load Diff

View File

@@ -1,508 +0,0 @@
# 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.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
CardWidget,
Pivot,
ScrollArea,
FluentIcon,
HeaderCardWidget,
FluentIcon,
TextBrowser,
ComboBox,
SubtitleLabel,
PushButton,
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QTextCursor
from typing import List, Dict
from app.core import Config, TaskManager, Task, MainInfoBar
from .Widget import StatefulItemCard, ComboBoxMessageBox
class DispatchCenter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度中枢")
self.pivot = Pivot(self)
self.stackedWidget = QStackedWidget(self)
self.Layout = QVBoxLayout(self)
self.script_list: Dict[str, DispatchBox] = {}
dispatch_box = 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,
)
self.Layout.addWidget(self.pivot, 0, Qt.AlignHCenter)
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 = 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.multi_button.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(
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.multi_button.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(
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(info["Config"].MaaSet_Name) == ""
else f"实例 - {info['Type']} - {info["Config"].get(info["Config"].MaaSet_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)
class DispatchBox(QWidget):
def __init__(self, name: str, parent=None):
super().__init__(parent)
self.setObjectName(name)
layout = QVBoxLayout()
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
self.top_bar = self.DispatchTopBar(self, name)
self.info = self.DispatchInfoCard(self)
content_layout.addWidget(self.top_bar)
content_layout.addWidget(self.info)
scrollArea.setWidget(content_widget)
layout.addWidget(scrollArea)
self.setLayout(layout)
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.multi_button = PushButton("添加任务")
self.multi_button.clicked.connect(self.start_multi_task)
self.main_button = PushButton("开始任务")
self.main_button.clicked.connect(self.start_main_task)
self.multi_button.hide()
Layout.addWidget(self.Lable)
Layout.addWidget(self.object)
Layout.addWidget(self.mode)
Layout.addStretch(1)
Layout.addWidget(self.multi_button)
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():
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():
if Config.member_dict[self.object.currentData()]["Type"] == "Maa":
logger.info(f"用户添加任务:{self.object.currentData()}")
TaskManager.add_task(
f"{self.mode.currentText()}_主调度台",
"自定义队列",
{"Queue": {"Member_1": self.object.currentData()}},
)
def start_multi_task(self):
"""开始任务"""
# 获取所有可用的队列和实例
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(info["Config"].MaaSet_Name) == ""
else f"实例 - {info['Type']} - {info["Config"].get(info["Config"].MaaSet_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():
if (
Config.member_dict[choice.input[0].currentData()]["Type"]
== "Maa"
):
logger.info(f"用户添加任务:{choice.input[0].currentData()}")
TaskManager.add_task(
"自动代理_新调度台",
f"自定义队列 - {choice.input[0].currentData()}",
{"Queue": {"Member_1": choice.input[0].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()

View File

@@ -1,592 +0,0 @@
# 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更新器
v1.2
作者DLmaster_361
"""
import json
import zipfile
import requests
import subprocess
import time
import psutil
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
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.url = url
self.start_byte = start_byte
self.end_byte = end_byte
self.download_path = download_path
self.check_times = check_times
def run(self) -> None:
# 清理可能存在的临时文件
if self.download_path.exists():
self.download_path.unlink()
headers = {"Range": f"bytes={self.start_byte}-{self.end_byte}"}
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
)
if response.status_code != 206:
if self.check_times != -1:
self.check_times -= 1
time.sleep(1)
continue
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)
else:
self.accomplish.emit(time.time() - start_time)
break
except Exception as e:
if self.check_times != -1:
self.check_times -= 1
time.sleep(1)
else:
if self.download_path.exists():
self.download_path.unlink()
self.accomplish.emit(0)
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.name = name
self.app_path = app_path
self.download_path = download_path
def run(self) -> None:
try:
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()
break
except PermissionError:
if self.name == "AUTO_MAA":
self.info.emit(f"解压出错AUTO_MAA正在运行正在尝试将其关闭")
self.kill_process(self.app_path / "AUTO_MAA.exe")
else:
self.info.emit(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}")
return None
def kill_process(self, path: Path) -> None:
"""根据路径中止进程"""
for pid in self.search_pids(path):
killprocess = subprocess.Popen(
f"taskkill /F /PID {pid}",
shell=True,
creationflags=subprocess.CREATE_NO_WINDOW,
)
killprocess.wait()
def search_pids(self, path: Path) -> list:
"""根据路径查找进程PID"""
pids = []
for proc in psutil.process_iter(["pid", "exe"]):
try:
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
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.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,
) 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
) 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] = {}
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()
if any(speed == -1 for _, speed in self.test_speed_result.items()):
return None
# 保存测速结果
self.config["speed_result"] = 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)
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,
start_byte,
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
# 合并下载的分段文件
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()
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:
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()

View File

@@ -1,388 +0,0 @@
# 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.3
作者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 Union, List, Dict
from app.core import Config
from .Widget import StatefulItemCard, QuantifiedItemCard, QuickExpandGroupCard
class History(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("历史记录")
content_widget = QWidget()
self.content_layout = QVBoxLayout(content_widget)
self.history_top_bar = self.HistoryTopBar(self)
self.history_top_bar.search_history.connect(self.reload_history)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setWidget(content_widget)
layout = QVBoxLayout()
layout.addWidget(self.history_top_bar)
layout.addWidget(scrollArea)
self.setLayout(layout)
self.history_card_list = []
def reload_history(self, mode: str, start_date: QDate, end_date: QDate) -> None:
"""加载历史记录界面"""
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 in history_dict.items():
self.history_card_list.append(self.HistoryCard(mode, date, user, 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,
mode: str,
date: str,
user: Union[List[Path], 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 = []
if mode == "按日合并":
for user_path in user:
self.user_history_card_list.append(
self.UserHistoryCard(mode, user_path.stem, user_path, self)
)
Layout.addWidget(self.user_history_card_list[-1])
elif mode in ["按周合并", "按月合并"]:
for user, info in user.items():
self.user_history_card_list.append(
self.UserHistoryCard(mode, user, info, self)
)
Layout.addWidget(self.user_history_card_list[-1])
class UserHistoryCard(HeaderCardWidget):
"""用户历史记录卡片"""
def __init__(
self,
mode: str,
name: str,
user_history: Union[Path, List[Path]],
parent=None,
):
super().__init__(parent)
self.setTitle(name)
if mode == "按日合并":
self.user_history_path = user_history
self.main_history = Config.load_maa_logs("总览", user_history)
self.index_card = self.IndexCard(
self.main_history["条目索引"], self
)
self.index_card.index_changed.connect(self.update_info)
self.viewLayout.addWidget(self.index_card)
elif mode in ["按周合并", "按月合并"]:
history = Config.merge_maa_logs("指定项", user_history)
self.main_history = {}
self.main_history["统计数据"] = {
"公招统计": list(history["recruit_statistics"].items())
}
for game_id, drops in history["drop_statistics"].items():
self.main_history["统计数据"][f"掉落统计:{game_id}"] = list(
drops.items()
)
self.statistics_card = QHBoxLayout()
self.log_card = self.LogCard(self)
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 update_info(self, index: str) -> None:
"""更新信息"""
if index == "数据总览":
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()
for name, item_list in self.main_history["统计数据"].items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
self.log_card.hide()
else:
single_history = Config.load_maa_logs(
"单项",
self.user_history_path.with_suffix("")
/ f"{index.replace(":","-")}.json",
)
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()
for name, item_list in single_history["统计数据"].items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
self.log_card.text.setText(single_history["日志信息"])
self.log_card.open_file.clicked.disconnect()
self.log_card.open_file.clicked.connect(
lambda: os.startfile(
self.user_history_path.with_suffix("")
/ f"{index.replace(":","-")}.log"
)
)
self.log_card.open_dir.clicked.disconnect()
self.log_card.open_dir.clicked.connect(
lambda: subprocess.Popen(
[
"explorer",
"/select,",
str(
self.user_history_path.with_suffix("")
/ f"{index.replace(":","-")}.log"
),
]
)
)
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, index_list: list, 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] = []
for index in index_list:
self.index_cards.append(StatefulItemCard(index))
self.index_cards[-1].clicked.connect(
partial(self.index_changed.emit, index[0])
)
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)

View File

@@ -1,402 +0,0 @@
# 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.3
作者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()
widget = QWidget()
Layout = QVBoxLayout(widget)
Layout.addWidget(self.banner)
Layout.addWidget(self.banner_text)
Layout.setStretch(0, 2)
Layout.setStretch(1, 3)
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)
layout = QVBoxLayout()
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setWidget(widget)
layout.addWidget(scrollArea)
self.setLayout(layout)
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.set_info(
mode="get",
url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/theme_image.json",
)
Network.start()
Network.loop.exec()
if Network.stutus_code == 200:
theme_image = Network.response_json
else:
logger.warning(f"获取最新主题图像时出错:{Network.error_message}")
MainInfoBar.push_info_bar(
"warning",
"获取最新主题图像时出错",
f"网络错误:{Network.stutus_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.set_info(
mode="get_file",
url=theme_image["url"],
path=Config.app_path / "resources/images/Home/BannerTheme.jpg",
)
Network.start()
Network.loop.exec()
if Network.stutus_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.error_message}")
MainInfoBar.push_info_bar(
"warning",
"下载最新主题图像时出错",
f"网络错误:{Network.stutus_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/"))

View File

@@ -1,442 +0,0 @@
# 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.3
作者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
from app.services import Notify, Crypto, System
from .home import Home
from .member_manager import MemberManager
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.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.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(
lambda index: (
self.queue_manager.reload_member_name() if index == 2 else None
)
)
self.stackedWidget.currentChanged.connect(
lambda index: (
self.dispatch_center.pivot.setCurrentItem("主调度台")
if index == 3
else None
)
)
self.stackedWidget.currentChanged.connect(
lambda index: (
self.dispatch_center.update_top_bar() if index == 3 else None
)
)
# 创建系统托盘及其菜单
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.user_info_changed.connect(self.member_manager.refresh_dashboard)
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 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()
# 获取主题图像
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()
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 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 show_ui(self, mode: str, if_quick: bool = False) -> 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):
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()
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 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()

File diff suppressed because it is too large Load Diff

View File

@@ -1,710 +0,0 @@
# 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.3
作者DLmaster_361
"""
from loguru import logger
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
Action,
Pivot,
ScrollArea,
FluentIcon,
MessageBox,
HeaderCardWidget,
CommandBar,
)
from PySide6.QtCore import Qt
from typing import List
from app.core import QueueConfig, Config, MainInfoBar
from .Widget import (
SwitchSettingCard,
ComboBoxSettingCard,
LineEditSettingCard,
TimeEditSettingCard,
NoOptionComboBoxSettingCard,
HistoryCard,
)
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_QueueSettingBox(index)
self.queue_manager.switch_SettingBox(index)
logger.success(f"调度队列_{index} 添加成功")
MainInfoBar.push_info_bar("success", "操作成功", f"添加 调度队列_{index}", 3000)
def del_setting_box(self):
"""删除一个调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name == 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)
def left_setting_box(self):
"""向左移动调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name == 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 == 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(v["Config"].MaaSet_Name) == ""
else f"{k} - {v["Config"].get(v["Config"].MaaSet_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.pivot = Pivot(self)
self.stackedWidget = QStackedWidget(self)
self.Layout = QVBoxLayout(self)
self.script_list: List[
QueueManager.QueueSettingBox.QueueMemberSettingBox
] = []
self.Layout.addWidget(self.pivot, 0, Qt.AlignHCenter)
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_QueueSettingBox(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_QueueSettingBox(self, uid: int) -> None:
"""添加一个调度队列设置界面"""
maa_setting_box = self.QueueMemberSettingBox(uid, self)
self.script_list.append(maa_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"]
layout = QVBoxLayout()
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
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_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.setWidget(content_widget)
layout.addWidget(scrollArea)
self.setLayout(layout)
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(v["Config"].MaaSet_Name) == ""
else f"{k} - {v["Config"].get(v["Config"].MaaSet_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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +0,0 @@
; 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;

88
app/utils/ImageUtils.py Normal file
View File

@@ -0,0 +1,88 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# 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
import base64
import hashlib
from pathlib import Path
from PIL import Image
class ImageUtils:
@staticmethod
def get_base64_from_file(image_path):
"""从本地文件读取并返回base64编码字符串"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
@staticmethod
def calculate_md5_from_file(image_path):
"""从本地文件读取并返回md5值hex字符串"""
with open(image_path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
@staticmethod
def calculate_md5_from_base64(base64_content):
"""从base64字符串计算md5"""
image_data = base64.b64decode(base64_content)
return hashlib.md5(image_data).hexdigest()
@staticmethod
def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
"""
如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径Path对象
"""
RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0及以后
max_size = max_size_mb * 1024 * 1024
if image_path.stat().st_size <= max_size:
return image_path
img = Image.open(image_path)
suffix = image_path.suffix.lower()
quality = 90 if suffix in [".jpg", ".jpeg"] else None
step = 5
if quality is not None:
while True:
img.save(image_path, quality=quality, optimize=True)
if image_path.stat().st_size <= max_size or quality <= 10:
break
quality -= step
elif suffix == ".png":
width, height = img.size
while True:
img.save(image_path, optimize=True)
if (
image_path.stat().st_size <= max_size
or width <= 200
or height <= 200
):
break
width = int(width * 0.95)
height = int(height * 0.95)
img = img.resize((width, height), RESAMPLE)
else:
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩")
return image_path

156
app/utils/LogMonitor.py Normal file
View File

@@ -0,0 +1,156 @@
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

156
app/utils/ProcessManager.py Normal file
View File

@@ -0,0 +1,156 @@
# 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
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()

View File

@@ -1,32 +1,44 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# 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_MAA.
# This file is part of AUTO-MAS.
# AUTO_MAA is free software: you can redistribute it and/or modify
# 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_MAA is distributed in the hope that it will be useful,
# 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_MAA. If not, see <https://www.gnu.org/licenses/>.
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA工具包
v4.3
作者DLmaster_361
"""
__version__ = "4.2.0"
__version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
__all__ = []
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",
]

292
app/utils/constants.py Normal file
View File

@@ -0,0 +1,292 @@
# 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
TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"}
"""配置类型映射表"""
RESOURCE_STAGE_INFO = [
{"value": "-", "text": "当前/上次", "days": [1, 2, 3, 4, 5, 6, 7]},
{"value": "1-7", "text": "1-7", "days": [1, 2, 3, 4, 5, 6, 7]},
{"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

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

View File

@@ -0,0 +1,492 @@
# 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

@@ -0,0 +1,278 @@
"""
键盘工具模块
提供虚拟键码到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

@@ -0,0 +1,329 @@
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

@@ -0,0 +1,220 @@
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

@@ -0,0 +1,104 @@
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"
}
}
"""
...

70
app/utils/logger.py Normal file
View File

@@ -0,0 +1,70 @@
# 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"]

View File

@@ -1,144 +0,0 @@
# 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.3
作者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(".")))
updater_version_numb = list(map(int, version["updater_version"].split(".")))
print("Packaging AUTO_MAA main program ...")
os.system(
"powershell -Command python -m nuitka --standalone --onefile --mingw64"
" --enable-plugins=pyside6 --windows-console-mode=disable"
" --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",
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{version_text(updater_version_numb)}\n<!--{json.dumps(version["version_info"], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
encoding="utf-8",
)

70
app/utils/security.py Normal file
View File

@@ -0,0 +1,70 @@
# 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

@@ -0,0 +1,411 @@
# 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

@@ -0,0 +1,126 @@
# 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

@@ -0,0 +1,427 @@
# 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

@@ -0,0 +1,336 @@
## 核心架构设计
### 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. 详细的调试和监控信息
**适用场景**:
- 实时数据展示
- 任务状态监控
- 系统通知推送
- 双向通信应用

2
frontend/.env Normal file
View File

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

View File

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

28
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# 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

3
frontend/.prettierignore Normal file
View File

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

9
frontend/.prettierrc Normal file
View File

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

1
frontend/.yarnrc.yml Normal file
View File

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

60
frontend/README.md Normal file
View File

@@ -0,0 +1,60 @@
# 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

1404
frontend/electron/main.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
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),
// 对话框相关
showQuestionDialog: (questionData: any) => ipcRenderer.invoke('show-question-dialog', questionData),
dialogResponse: (messageId: string, choice: boolean) => ipcRenderer.invoke('dialog-response', messageId, choice),
resizeDialogWindow: (height: number) => ipcRenderer.invoke('resize-dialog-window', height),
moveWindow: (deltaX: number, deltaY: number) => ipcRenderer.invoke('move-window', deltaX, deltaY),
// 主题信息获取
getThemeInfo: () => ipcRenderer.invoke('get-theme-info'),
getTheme: () => ipcRenderer.invoke('get-theme'),
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('download-progress', (_, progress) => callback(progress))
},
removeDownloadProgressListener: () => {
ipcRenderer.removeAllListeners('download-progress')
},
})

View File

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,34 @@
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

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

View File

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,642 @@
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

@@ -0,0 +1,125 @@
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()
})
}

34
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,34 @@
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',
},
},
];

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>

116
frontend/package.json Normal file
View File

@@ -0,0 +1,116 @@
{
"name": "frontend",
"private": true,
"version": "v5.0.0-alpha.3",
"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.

After

Width:  |  Height:  |  Size: 450 KiB

509
frontend/public/dialog.html Normal file
View File

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

98
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,98 @@
<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

@@ -0,0 +1,25 @@
/* 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

@@ -0,0 +1,17 @@
/* 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

@@ -0,0 +1,11 @@
/* 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

@@ -0,0 +1,131 @@
/* 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

@@ -0,0 +1,32 @@
/* 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

@@ -0,0 +1,323 @@
/* 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);
}
});
};

120
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,120 @@
/* 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';

100
frontend/src/api/mirrors.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* 镜像源 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

@@ -0,0 +1,15 @@
/* 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

@@ -0,0 +1,24 @@
/* 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

@@ -0,0 +1,35 @@
/* 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

@@ -0,0 +1,11 @@
/* 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;
};

View File

@@ -0,0 +1,27 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { GeneralConfig_Game } from './GeneralConfig_Game';
import type { GeneralConfig_Info } from './GeneralConfig_Info';
import type { GeneralConfig_Run } from './GeneralConfig_Run';
import type { GeneralConfig_Script } from './GeneralConfig_Script';
export type GeneralConfig = {
/**
* 脚本基础信息
*/
Info?: (GeneralConfig_Info | null);
/**
* 脚本配置
*/
Script?: (GeneralConfig_Script | null);
/**
* 游戏配置
*/
Game?: (GeneralConfig_Game | null);
/**
* 运行配置
*/
Run?: (GeneralConfig_Run | null);
};

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