Merge branch 'feature/refactor' into z

This commit is contained in:
Zrief
2025-09-09 00:06:31 +08:00
26 changed files with 2121 additions and 3064 deletions

View File

@@ -116,6 +116,10 @@ class GlobalConfig(ConfigBase):
"Update", "MirrorChyanCDK", "", EncryptValidator()
)
Data_UID = ConfigItem("Data", "UID", str(uuid.uuid4()), UUIDValidator())
Data_LastStatisticsUpload = ConfigItem(
"Data", "LastStatisticsUpload", "2000-01-01 00:00:00"
)
Data_LastStageUpdated = ConfigItem(
"Data", "LastStageUpdated", "2000-01-01 00:00:00"
)
@@ -190,7 +194,7 @@ class MaaUserConfig(ConfigBase):
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新用户")
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
self.Info_Id = ConfigItem("Info", "Id", "")
self.Info_Mode = ConfigItem(
"Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
@@ -451,7 +455,7 @@ class GeneralUserConfig(ConfigBase):
def __init__(self) -> None:
super().__init__()
self.Info_Name = ConfigItem("Info", "Name", "新用户")
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)
@@ -460,13 +464,13 @@ class GeneralUserConfig(ConfigBase):
"Info", "IfScriptBeforeTask", False, BoolValidator()
)
self.Info_ScriptBeforeTask = ConfigItem(
"Info", "ScriptBeforeTask", "", FileValidator()
"Info", "ScriptBeforeTask", str(Path.cwd()), FileValidator()
)
self.Info_IfScriptAfterTask = ConfigItem(
"Info", "IfScriptAfterTask", False, BoolValidator()
)
self.Info_ScriptAfterTask = ConfigItem(
"Info", "ScriptAfterTask", "", FileValidator()
"Info", "ScriptAfterTask", str(Path.cwd()), FileValidator()
)
self.Info_Notes = ConfigItem("Info", "Notes", "")
@@ -571,7 +575,7 @@ TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"}
class AppConfig(GlobalConfig):
VERSION = "5.0.0.1"
VERSION = [5, 0, 0, 1]
def __init__(self) -> None:
super().__init__(if_save_multi_config=False)
@@ -579,7 +583,7 @@ class AppConfig(GlobalConfig):
logger.info("")
logger.info("===================================")
logger.info("AUTO_MAA 后端应用程序")
logger.info(f"版本号: v{self.VERSION}")
logger.info(f"版本号: {self.version()}")
logger.info(f"工作目录: {Path.cwd()}")
logger.info("===================================")
@@ -605,6 +609,16 @@ class AppConfig(GlobalConfig):
truststore.inject_into_ssl()
def version(self) -> str:
"""获取版本号字符串"""
if self.VERSION[3] == 0:
return f"v{'.'.join(str(_) for _ in self.VERSION[0:3])}"
else:
return (
f"v{'.'.join(str(_) for _ in self.VERSION[0:3])}-beta.{self.VERSION[3]}"
)
async def init_config(self) -> None:
"""初始化配置管理"""

View File

@@ -212,6 +212,14 @@ class _TaskManager:
partial(self.task_dict.pop, script_id)
)
await self.task_dict[script_id]
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:
"""

View File

@@ -22,7 +22,7 @@ import asyncio
import keyboard
from datetime import datetime
from app.services import System
from app.services import Matomo, System
from app.utils import get_logger
from .config import Config
@@ -42,6 +42,33 @@ class _MainTimer:
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)
async def set_silence(self):
"""静默模式通过模拟老板键来隐藏模拟器窗口"""

View File

@@ -25,10 +25,11 @@ import uuid
import win32com.client
from copy import deepcopy
from pathlib import Path
from typing import List, Any, Dict, Union
from typing import List, Any, Dict, Union, Optional
from app.utils import dpapi_encrypt, dpapi_decrypt
from app.utils.constants import RESERVED_NAMES, ILLEGAL_CHARS
class ConfigValidator:
@@ -97,8 +98,22 @@ class UidValidator(ConfigValidator):
return value if self.validate(value) else None
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 EncryptValidator(ConfigValidator):
"""加数据验证器"""
"""数据验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
@@ -163,6 +178,46 @@ class FolderValidator(ConfigValidator):
return Path(value).resolve().as_posix()
class UserNameValidator(ConfigValidator):
"""用户名验证器"""
def validate(self, value: Any) -> bool:
if not isinstance(value, str):
return False
if not value or not value.strip():
return False
if value != value.strip() or value != value.strip("."):
return False
if any(char in ILLEGAL_CHARS for char in value):
return False
if value.upper() in RESERVED_NAMES:
return False
if len(value) > 255:
return False
return True
def correct(self, value: Any) -> str:
if not isinstance(value, str):
value = "默认用户名"
value = value.strip().strip(".")
value = "".join(char for char in value if char not in ILLEGAL_CHARS)
if value.upper() in RESERVED_NAMES or not value:
value = "默认用户名"
if len(value) > 255:
value = value[:255]
return value
class ConfigItem:
"""配置项"""
@@ -171,7 +226,7 @@ class ConfigItem:
group: str,
name: str,
default: Any,
validator: None | ConfigValidator = None,
validator: Optional[ConfigValidator] = None,
):
"""
Parameters
@@ -195,7 +250,10 @@ class ConfigItem:
self.validator = validator or ConfigValidator()
self.is_locked = False
self.setValue(default)
if not self.validator.validate(self.value):
raise ValueError(
f"配置项 '{self.group}.{self.name}' 的默认值 '{self.value}' 不合法"
)
def setValue(self, value: Any):
"""

View File

@@ -22,6 +22,7 @@ __version__ = "5.0.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .matomo import Matomo
from .notification import Notify
from .system import System

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

@@ -0,0 +1,125 @@
# 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
import asyncio
import aiohttp
import json
import uuid
import psutil
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

@@ -226,3 +226,32 @@ MATERIALS_MAP = {
"PR-D": "近卫/特种芯片",
}
"""掉落物索引表"""
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('<>:"/\\|?*')
"""文件名非法字符集合"""

View File

@@ -490,6 +490,26 @@ ipcMain.handle('open-url', async (_event, url: string) => {
}
})
// 打开文件
ipcMain.handle('open-file', async (_event, filePath: string) => {
try {
await shell.openPath(filePath)
} catch (error) {
console.error('打开文件失败:', error)
throw error
}
})
// 显示文件所在目录并选中文件
ipcMain.handle('show-item-in-folder', async (_event, filePath: string) => {
try {
shell.showItemInFolder(filePath)
} catch (error) {
console.error('显示文件所在目录失败:', error)
throw error
}
})
// 环境检查
ipcMain.handle('check-environment', async () => {
const appRoot = getAppRoot()

View File

@@ -50,6 +50,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveLogsToFile: (logs: string) => ipcRenderer.invoke('save-logs-to-file', logs),
loadLogsFromFile: () => ipcRenderer.invoke('load-logs-from-file'),
// 文件系统操作
openFile: (filePath: string) => ipcRenderer.invoke('open-file', filePath),
showItemInFolder: (filePath: string) => ipcRenderer.invoke('show-item-in-folder', filePath),
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('download-progress', (_, progress) => callback(progress))

View File

@@ -9,7 +9,8 @@
"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"
"web": "vite",
"release": "vite build && yarn build:main && electron-builder --win --publish always"
},
"build": {
"asar": true,
@@ -20,29 +21,37 @@
"appId": "xyz.automaa.frontend",
"productName": "AUTO_MAA",
"files": [
"dist",
"dist-electron",
"public",
"!src/assets/*"
"dist/**",
"dist-electron/**",
"public/**",
"!src/**",
"!**/*.map"
],
"publish": [
{
"provider": "github",
"owner": "DLmaster_361",
"repo": "AUTO_MAA"
}
],
"extraResources": [
{
"from": "src/assets",
"to": "assets",
"filter": []
}
{ "from": "src/assets", "to": "assets", "filter": ["**/*"] }
],
"win": {
"requestedExecutionLevel": "requireAdministrator",
"target": "dir",
"target": [
{ "target": "nsis", "arch": ["x64"] }
],
"icon": "public/AUTO-MAS.ico",
"artifactName": "AUTO_MAA.exe"
"artifactName": "AUTO_MAA-Setup-${version}-${arch}.${ext}"
},
"mac": {
"icon": "public/AUTO-MAS.ico"
},
"linux": {
"icon": "public/AUTO-MAS.ico"
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"shortcutName": "AUTO_MAA",
"differentialPackage": true
}
},
"dependencies": {

View File

@@ -1,78 +1,31 @@
<template>
<a-layout style="height: 100vh; overflow: hidden" class="app-layout-collapsed">
<a-layout style="height: 100vh; overflow: hidden">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:trigger="null"
:width="180"
:collapsed-width="60"
:width="SIDER_WIDTH"
:theme="isDark ? 'dark' : 'light'"
style="height: calc(100vh - 32px); position: fixed; left: 0; top: 32px; z-index: 100"
:style="{ height: 'calc(100vh - 32px)', position: 'fixed', left: '0', top: '32px', zIndex: 100, background: 'var(--app-sider-bg)', borderRight: '1px solid var(--app-sider-border-color)' }"
>
<div class="sider-content">
<!-- &lt;!&ndash; 折叠按钮 &ndash;&gt;-->
<!-- <div class="collapse-trigger" @click="toggleCollapse">-->
<!-- <MenuFoldOutlined v-if="!collapsed" />-->
<!-- <MenuUnfoldOutlined v-else />-->
<!-- </div>-->
<!-- 主菜单容器 -->
<div class="main-menu-container">
<a-menu
mode="inline"
:inline-collapsed="collapsed"
:theme="isDark ? 'dark' : 'light'"
class="main-menu"
v-model:selectedKeys="selectedKeys"
>
<template v-for="item in mainMenuItems" :key="item.path">
<a-menu-item @click="goTo(item.path)" :data-title="item.label">
<template #icon>
<component :is="item.icon" />
</template>
<span v-if="!collapsed" class="menu-text">{{ item.label }}</span>
</a-menu-item>
</template>
</a-menu>
</div>
<!-- 底部菜单带3px底部内边距 -->
:items="mainMenuItems"
@click="onMenuClick"
/>
<a-menu
mode="inline"
:inline-collapsed="collapsed"
:theme="isDark ? 'dark' : 'light'"
class="bottom-menu"
v-model:selectedKeys="selectedKeys"
>
<template v-for="item in bottomMenuItems" :key="item.path">
<a-menu-item @click="goTo(item.path)" :data-title="item.label">
<template #icon>
<component :is="item.icon" />
</template>
<span v-if="!collapsed" class="menu-text">{{ item.label }}</span>
</a-menu-item>
</template>
</a-menu>
:items="bottomMenuItems"
@click="onMenuClick"
/>
</div>
</a-layout-sider>
<!-- 主内容区 -->
<a-layout
:style="{
marginLeft: collapsed ? '60px' : '180px',
transition: 'margin-left 0.2s',
height: 'calc(100vh - 32px)',
}"
>
<a-layout-content
class="content-area"
:style="{
padding: '24px',
background: isDark ? '#141414' : '#ffffff',
height: '100%',
overflow: 'auto',
}"
>
<a-layout :style="{ marginLeft: SIDER_WIDTH + 'px', height: 'calc(100vh - 32px)', transition: 'margin-left .2s' }">
<a-layout-content class="content-area">
<router-view />
</a-layout-content>
</a-layout>
@@ -88,202 +41,92 @@ import {
ControlOutlined,
HistoryOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons-vue'
import { ref, computed } from 'vue'
import { computed, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '../composables/useTheme.ts'
import type { MenuProps } from 'ant-design-vue'
const SIDER_WIDTH = 140
const router = useRouter()
const route = useRoute()
const { isDark } = useTheme()
const collapsed = ref<boolean>(false)
// 工具:生成菜单项
const icon = (Comp: any) => () => h(Comp)
// 菜单数据
const mainMenuItems = [
{ path: '/home', label: '主页', icon: HomeOutlined },
{ path: '/scripts', label: '脚本管理', icon: FileTextOutlined },
{ path: '/plans', label: '计划管理', icon: CalendarOutlined },
{ path: '/queue', label: '调度队列', icon: UnorderedListOutlined },
{ path: '/scheduler', label: '调度中心', icon: ControlOutlined },
{ path: '/history', label: '历史记录', icon: HistoryOutlined },
{ key: '/home', label: '主页', icon: icon(HomeOutlined) },
{ key: '/scripts', label: '脚本管理', icon: icon(FileTextOutlined) },
{ key: '/plans', label: '计划管理', icon: icon(CalendarOutlined) },
{ key: '/queue', label: '调度队列', icon: icon(UnorderedListOutlined) },
{ key: '/scheduler', label: '调度中心', icon: icon(ControlOutlined) },
{ key: '/history', label: '历史记录', icon: icon(HistoryOutlined) },
]
const bottomMenuItems = [
{ key: '/settings', label: '设置', icon: icon(SettingOutlined) },
]
const bottomMenuItems = [{ path: '/settings', label: '设置', icon: SettingOutlined }]
const allItems = [...mainMenuItems, ...bottomMenuItems]
// 自动同步选中项
// 选中项:根据当前路径前缀匹配
const selectedKeys = computed(() => {
const path = route.path
const allItems = [...mainMenuItems, ...bottomMenuItems]
const matched = allItems.find(item => path.startsWith(item.path))
return [matched?.path || '/home']
const matched = allItems.find(i => path.startsWith(String(i.key)))
return [matched?.key || '/home']
})
const goTo = (path: string) => {
router.push(path)
}
const toggleCollapse = () => {
collapsed.value = !collapsed.value
const onMenuClick: MenuProps['onClick'] = info => {
const target = String(info.key)
if (route.path !== target) router.push(target)
}
</script>
<style scoped>
.sider-content {
height: 100%;
display: flex;
flex-direction: column;
padding-bottom: 4px; /* 关键添加3px底部内边距 */
}
/* 折叠按钮 */
.collapse-trigger {
height: 42px;
.sider-content { height:100%; display:flex; flex-direction:column; padding:4px 0 8px 0; }
.sider-content :deep(.ant-menu) { border-inline-end: none !important; background: transparent !important; }
/* 菜单项外框居中(左右留空),内容左对齐 */
.sider-content :deep(.ant-menu .ant-menu-item) {
color: var(--app-menu-text-color);
margin: 2px auto; /* 水平居中 */
width: calc(100% - 16px); /* 两侧各留 8px 空隙 */
border-radius: 6px;
padding: 0 10px !important; /* 左右内边距 */
line-height: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
margin: 4px;
border-radius: 6px;
cursor: pointer;
justify-content: flex-start; /* 左对齐图标与文字 */
gap: 6px;
transition: background .16s ease, color .16s ease;
text-align: left;
}
.sider-content :deep(.ant-menu .ant-menu-item .anticon) {
color: var(--app-menu-icon-color);
font-size: 16px;
transition: background-color 0.2s;
line-height: 1;
transition: color .16s ease;
margin-right: 0;
}
.collapse-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
/* Hover */
.sider-content :deep(.ant-menu .ant-menu-item:hover) {
background: var(--app-menu-item-hover-bg, var(--app-menu-item-hover-bg-hex));
color: var(--app-menu-item-hover-text-color);
}
:deep(.ant-layout-sider-light) .collapse-trigger:hover {
background-color: rgba(0, 0, 0, 0.04);
}
:deep(.ant-layout-sider-dark) .collapse-trigger {
color: #fff;
}
:deep(.ant-layout-sider-light) .collapse-trigger {
color: rgba(0, 0, 0, 0.88);
}
/* 主菜单容器 */
.main-menu-container {
flex: 1;
overflow: auto;
/* 修复滚动条显示问题 */
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.main-menu-container::-webkit-scrollbar {
width: 6px;
}
.main-menu-container::-webkit-scrollbar-track {
background: transparent;
}
.main-menu-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
/* 底部菜单 */
.bottom-menu {
margin-top: auto;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
:deep(.ant-layout-sider-light .bottom-menu) {
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
/* 菜单项文字 */
.menu-text {
margin-left: 36px;
white-space: nowrap;
opacity: 1;
transition: opacity 0.2s ease;
}
/* 主题颜色 */
:deep(.ant-layout-sider-dark) .logo-text,
:deep(.ant-layout-sider-dark) .menu-text {
color: #fff;
}
:deep(.ant-layout-sider-light) .logo-text,
:deep(.ant-layout-sider-light) .menu-text {
color: rgba(0, 0, 0, 0.88);
}
/* 菜单项统一样式 */
:deep(.ant-menu-item),
:deep(.ant-menu-item-selected) {
position: relative;
height: 40px;
line-height: 34px;
margin: 0 6px;
border-radius: 6px;
padding: 0 !important;
}
/* 图标绝对定位 */
:deep(.ant-menu-item .ant-menu-item-icon) {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
pointer-events: none;
z-index: 2;
}
/* 隐藏内容区滚动条 */
.content-area {
scrollbar-width: none;
-ms-overflow-style: none;
}
.content-area::-webkit-scrollbar {
display: none;
.sider-content :deep(.ant-menu .ant-menu-item:hover .anticon) { color: var(--app-menu-item-hover-text-color); }
/* Selected */
.sider-content :deep(.ant-menu .ant-menu-item-selected) {
background: var(--app-menu-item-selected-bg, var(--app-menu-item-selected-bg-hex));
color: var(--app-menu-text-color) !important;
font-weight: 500;
}
.sider-content :deep(.ant-menu .ant-menu-item-selected .anticon) { color: var(--app-menu-icon-color); }
.sider-content :deep(.ant-menu-light .ant-menu-item::after),
.sider-content :deep(.ant-menu-dark .ant-menu-item::after) { display: none; }
.bottom-menu { margin-top:auto; }
.content-area { height:100%; overflow:auto; scrollbar-width:none; -ms-overflow-style:none; }
.content-area::-webkit-scrollbar { display:none; }
</style>
<!-- 全局样式 -->
<style>
/* 收缩状态下,通过 data-title 显示 Tooltip */
.app-layout-collapsed .ant-menu-inline-collapsed .ant-menu-item:hover::before {
content: attr(data-title);
position: absolute;
left: 60px;
top: 50%;
transform: translateY(-50%);
background: #1890ff;
color: #fff;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
font-size: 12px;
z-index: 1000;
opacity: 1;
pointer-events: none;
}
/* 修复底部菜单在折叠状态下的tooltip位置 */
.app-layout-collapsed .ant-menu-inline-collapsed .bottom-menu .ant-menu-item:hover::before {
left: 60px;
transform: translateY(-50%);
}
/* 确保底部菜单在收缩状态下也有3px间距 */
.app-layout-collapsed .ant-menu-inline-collapsed .bottom-menu {
padding-bottom: 6px;
}
</style>
<!-- 调整外框菜单项背景块水平居中文字与图标左对齐 -->

View File

@@ -3,6 +3,8 @@
<!-- 左侧Logo和软件名 -->
<div class="title-bar-left">
<div class="logo-section">
<!-- 新增虚化主题色圆形阴影 -->
<span class="logo-glow" aria-hidden="true"></span>
<img src="@/assets/AUTO-MAS.ico" alt="AUTO-MAS" class="title-logo" />
<span class="title-text">AUTO-MAS</span>
</div>
@@ -94,6 +96,7 @@ onMounted(async () => {
user-select: none;
position: relative;
z-index: 1000;
overflow: hidden; /* 新增:裁剪超出顶栏的发光 */
}
.title-bar-dark {
@@ -112,17 +115,42 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
position: relative; /* 使阴影绝对定位基准 */
}
/* 新增:主题色虚化圆形阴影 */
.logo-glow {
position: absolute;
left: 55px; /* 调整:更贴近图标 */
top: 50%;
transform: translate(-50%, -50%);
width: 200px; /* 缩小尺寸以适配 32px 高度 */
height: 100px;
pointer-events: none;
border-radius: 50%;
background: radial-gradient(circle at 50% 50%, var(--ant-color-primary) 0%, rgba(0,0,0,0) 70%);
filter: blur(24px); /* 降低模糊避免越界过多 */
opacity: 0.4;
z-index: 0;
}
.title-bar-dark .logo-glow {
opacity: 0.7;
filter: blur(24px);
}
.title-logo {
width: 20px;
height: 20px;
position: relative;
z-index: 1; /* 确保在阴影上方 */
}
.title-text {
font-size: 13px;
font-weight: 600;
color: #333;
position: relative;
z-index: 1;
}
.title-bar-dark .title-text {

View File

@@ -71,15 +71,32 @@ const updateCSSVariables = () => {
const root = document.documentElement
const primaryColor = themeColors[themeColor.value]
// 基础背景(用于估算混合)
const baseLightBg = '#ffffff'
const baseDarkBg = '#141414'
const baseMenuBg = isDark.value ? baseDarkBg : baseLightBg
// 改进:侧边栏背景使用 HSL 调整而不是简单线性混合,提高在不同主色下的可读性
const siderBg = deriveSiderBg(primaryColor, baseMenuBg, isDark.value)
const siderBorder = deriveSiderBorder(siderBg, primaryColor, isDark.value)
// 基础文字候选色
const candidateTextLight = 'rgba(255,255,255,0.88)'
const candidateTextDark = 'rgba(0,0,0,0.88)'
// 选菜单文字颜色:对 siderBg 计算对比度,优先满足 >=4.5
const menuTextColor = pickAccessibleColor(candidateTextDark, candidateTextLight, siderBg)
const iconColor = menuTextColor
// ===== AntD token 变量(保留) =====
if (isDark.value) {
// 深色模式变量
root.style.setProperty('--ant-color-primary', primaryColor)
root.style.setProperty('--ant-color-primary-hover', lightenColor(primaryColor, 10))
root.style.setProperty('--ant-color-primary-bg', `${primaryColor}1a`)
root.style.setProperty('--ant-color-primary-hover', hslLighten(primaryColor, 6))
root.style.setProperty('--ant-color-primary-bg', addAlpha(primaryColor, 0.10))
root.style.setProperty('--ant-color-text', 'rgba(255, 255, 255, 0.88)')
root.style.setProperty('--ant-color-text-secondary', 'rgba(255, 255, 255, 0.65)')
root.style.setProperty('--ant-color-text-tertiary', 'rgba(255, 255, 255, 0.45)')
root.style.setProperty('--ant-color-bg-container', '#141414')
root.style.setProperty('--ant-color-bg-container', baseDarkBg)
root.style.setProperty('--ant-color-bg-layout', '#000000')
root.style.setProperty('--ant-color-bg-elevated', '#1f1f1f')
root.style.setProperty('--ant-color-border', '#424242')
@@ -88,14 +105,13 @@ const updateCSSVariables = () => {
root.style.setProperty('--ant-color-success', '#52c41a')
root.style.setProperty('--ant-color-warning', '#faad14')
} else {
// 浅色模式变量
root.style.setProperty('--ant-color-primary', primaryColor)
root.style.setProperty('--ant-color-primary-hover', darkenColor(primaryColor, 10))
root.style.setProperty('--ant-color-primary-bg', `${primaryColor}1a`)
root.style.setProperty('--ant-color-primary-hover', hslDarken(primaryColor, 6))
root.style.setProperty('--ant-color-primary-bg', addAlpha(primaryColor, 0.10))
root.style.setProperty('--ant-color-text', 'rgba(0, 0, 0, 0.88)')
root.style.setProperty('--ant-color-text-secondary', 'rgba(0, 0, 0, 0.65)')
root.style.setProperty('--ant-color-text-tertiary', 'rgba(0, 0, 0, 0.45)')
root.style.setProperty('--ant-color-bg-container', '#ffffff')
root.style.setProperty('--ant-color-bg-container', baseLightBg)
root.style.setProperty('--ant-color-bg-layout', '#f5f5f5')
root.style.setProperty('--ant-color-bg-elevated', '#ffffff')
root.style.setProperty('--ant-color-border', '#d9d9d9')
@@ -104,9 +120,70 @@ const updateCSSVariables = () => {
root.style.setProperty('--ant-color-success', '#52c41a')
root.style.setProperty('--ant-color-warning', '#faad14')
}
// ===== 自定义菜单配色 =====
// 动态 Alpha根据主色亮度调整透明度以保持区分度
const lumPrim = getLuminance(primaryColor)
const hoverAlphaBase = isDark.value ? 0.22 : 0.14
const selectedAlphaBase = isDark.value ? 0.38 : 0.26
const hoverAlpha = clamp01(hoverAlphaBase + (isDark.value ? (lumPrim > 0.65 ? -0.04 : 0) : (lumPrim < 0.30 ? 0.04 : 0)))
const selectedAlpha = clamp01(selectedAlphaBase + (isDark.value ? (lumPrim > 0.65 ? -0.05 : 0) : (lumPrim < 0.30 ? 0.05 : 0)))
// 估算最终选中背景(混合算实际颜色用于对比度计算)
const estimatedSelectedBg = blendColors(baseMenuBg, primaryColor, selectedAlpha)
const selectedTextColor = pickAccessibleColor('rgba(0,0,0,0.90)', 'rgba(255,255,255,0.92)', estimatedSelectedBg)
const hoverTextColor = menuTextColor
root.style.setProperty('--app-sider-bg', siderBg)
root.style.setProperty('--app-sider-border-color', siderBorder)
root.style.setProperty('--app-menu-text-color', menuTextColor)
root.style.setProperty('--app-menu-icon-color', iconColor)
root.style.setProperty('--app-menu-item-hover-text-color', hoverTextColor)
root.style.setProperty('--app-menu-item-selected-text-color', selectedTextColor)
// 背景同时提供 rgba 与 hex alpha兼容处理
const hoverRgba = hexToRgba(primaryColor, hoverAlpha)
const selectedRgba = hexToRgba(primaryColor, selectedAlpha)
root.style.setProperty('--app-menu-item-hover-bg', hoverRgba)
root.style.setProperty('--app-menu-item-hover-bg-hex', addAlpha(primaryColor, hoverAlpha))
root.style.setProperty('--app-menu-item-selected-bg', selectedRgba)
root.style.setProperty('--app-menu-item-selected-bg-hex', addAlpha(primaryColor, selectedAlpha))
}
// 颜色工具函数
// ===== 新增缺失基础函数(从旧版本恢复) =====
const addAlpha = (hex: string, alpha: number) => {
const a = alpha > 1 ? alpha / 100 : alpha
const clamped = Math.min(1, Math.max(0, a))
const alphaHex = Math.round(clamped * 255).toString(16).padStart(2, '0')
return `${hex}${alphaHex}`
}
const blendColors = (color1: string, color2: string, ratio: number) => {
const r1 = hexToRgb(color1)
const r2 = hexToRgb(color2)
if (!r1 || !r2) return color1
const r = Math.round(r1.r * (1 - ratio) + r2.r * ratio)
const g = Math.round(r1.g * (1 - ratio) + r2.g * ratio)
const b = Math.round(r1.b * (1 - ratio) + r2.b * ratio)
return rgbToHex(r, g, b)
}
const getLuminance = (hex: string) => {
const rgb = hexToRgb(hex)
if (!rgb) return 0
const transform = (v: number) => {
const srgb = v / 255
return srgb <= 0.03928 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4)
}
const r = transform(rgb.r)
const g = transform(rgb.g)
const b = transform(rgb.b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
// ===== 新增/改进的颜色工具 =====
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
@@ -122,24 +199,105 @@ const rgbToHex = (r: number, g: number, b: number) => {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
const lightenColor = (hex: string, percent: number) => {
// 新增hex -> rgba 字符串
const hexToRgba = (hex: string, alpha: number) => {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const { r, g, b } = rgb
const amount = Math.round(2.55 * percent)
return rgbToHex(Math.min(255, r + amount), Math.min(255, g + amount), Math.min(255, b + amount))
if (!rgb) return 'rgba(0,0,0,0)'
const a = alpha > 1 ? alpha / 100 : alpha
return `rgba(${rgb.r},${rgb.g},${rgb.b},${clamp01(a)})`
}
const darkenColor = (hex: string, percent: number) => {
// HSL 转换(感知更平滑)
const rgbToHsl = (r: number, g: number, b: number) => {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
const d = max - min
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s, l }
}
const hslToRgb = (h: number, s: number, l: number) => {
h /= 360
if (s === 0) {
const val = Math.round(l * 255)
return { r: val, g: val, b: val }
}
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const r = hue2rgb(p, q, h + 1/3)
const g = hue2rgb(p, q, h)
const b = hue2rgb(p, q, h - 1/3)
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
const hslAdjust = (hex: string, dl: number) => {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b)
const nl = clamp01(l + dl)
const nrgb = hslToRgb(h, s, nl)
return rgbToHex(nrgb.r, nrgb.g, nrgb.b)
}
const { r, g, b } = rgb
const amount = Math.round(2.55 * percent)
const hslLighten = (hex: string, percent: number) => hslAdjust(hex, percent/100)
const hslDarken = (hex: string, percent: number) => hslAdjust(hex, -percent/100)
return rgbToHex(Math.max(0, r - amount), Math.max(0, g - amount), Math.max(0, b - amount))
// 对比度 (WCAG)
const contrastRatio = (hex1: string, hex2: string) => {
const L1 = getLuminance(hex1)
const L2 = getLuminance(hex2)
const light = Math.max(L1, L2)
const dark = Math.min(L1, L2)
return (light + 0.05) / (dark + 0.05)
}
const rgbaExtractHex = (color: string) => {
// 只支持 hex(#rrggbb) 直接返回;若 rgba 则忽略 alpha 并合成背景为黑假设
if (color.startsWith('#') && (color.length === 7)) return color
// 简化:返回黑或白占位
return '#000000'
}
const pickAccessibleColor = (c1: string, c2: string, bg: string, minRatio = 4.5) => {
const hexBg = rgbaExtractHex(bg)
const hex1 = rgbaExtractHex(c1 === 'rgba(255,255,255,0.88)' ? '#ffffff' : (c1.includes('255,255,255') ? '#ffffff' : '#000000'))
const hex2 = rgbaExtractHex(c2 === 'rgba(255,255,255,0.88)' ? '#ffffff' : (c2.includes('255,255,255') ? '#ffffff' : '#000000'))
const r1 = contrastRatio(hex1, hexBg)
const r2 = contrastRatio(hex2, hexBg)
// 优先满足 >= minRatio都满足取更高否则取更高
if (r1 >= minRatio && r2 >= minRatio) return r1 >= r2 ? c1 : c2
if (r1 >= minRatio) return c1
if (r2 >= minRatio) return c2
return r1 >= r2 ? c1 : c2
}
// 改进侧栏背景:如果深色模式,降低亮度并略增饱和;浅色模式提高亮度轻度染色
const deriveSiderBg = (primary: string, base: string, dark: boolean) => {
const mixRatio = dark ? 0.22 : 0.18
const mixed = blendColors(base, primary, mixRatio)
return dark ? hslDarken(mixed, 8) : hslLighten(mixed, 6)
}
const deriveSiderBorder = (siderBg: string, primary: string, dark: boolean) => {
return dark ? blendColors(siderBg, primary, 0.30) : blendColors('#d9d9d9', primary, 0.25)
}
// 监听系统主题变化

View File

@@ -1,216 +1,577 @@
import { ref, reactive, onUnmounted } from 'vue'
import { ref, type Ref } from 'vue'
import { message, notification } from 'ant-design-vue'
// WebSocket连接状态
// WebSocket 调试开关
const WS_DEV = true
const WS_VERSION = 'v2.5-PERSISTENT-' + Date.now()
console.log(`🚀 WebSocket 模块已加载: ${WS_VERSION} - 永久连接模式`)
// 基础配置
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
const HEARTBEAT_INTERVAL = 15000
const HEARTBEAT_TIMEOUT = 5000
// 类型定义
export type WebSocketStatus = '连接中' | '已连接' | '已断开' | '连接错误'
// WebSocket消息类型
export type WebSocketMessageType = 'Update' | 'Message' | 'Info' | 'Signal'
// WebSocket基础消息接口
export interface WebSocketBaseMessage {
type: WebSocketMessageType
data: any
id?: string
type: string
data?: any
}
// 进度消息接口
export interface ProgressMessage {
taskId: string
status: 'running' | 'waiting' | 'finished' | 'failed'
progress: number
msg: string
percent?: number
status?: string
msg?: string
}
// 结果消息接口
export interface ResultMessage {
taskId: string
status: 'success' | 'failed'
result: any
success?: boolean
result?: any
}
// 错误消息接口
export interface ErrorMessage {
msg: string
code: number
msg?: string
code?: number
}
// 通知消息接口
export interface NotifyMessage {
title: string
content: string
title?: string
content?: string
}
// WebSocket连接配置
export interface WebSocketConfig {
taskId: string
export interface WebSocketSubscriber {
id: string
onProgress?: (data: ProgressMessage) => void
onResult?: (data: ResultMessage) => void
onError?: (error: ErrorMessage) => void
onNotify?: (notify: NotifyMessage) => void
onError?: (err: ErrorMessage) => void
onNotify?: (n: NotifyMessage) => void
// 兼容旧版 API
onMessage?: (raw: WebSocketBaseMessage) => void
onStatusChange?: (status: WebSocketStatus) => void
showNotifications?: boolean
}
export function useWebSocket() {
const connections = ref<Map<string, WebSocket>>(new Map())
const statuses = ref<Map<string, WebSocketStatus>>(new Map())
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
// 兼容旧版 connect(config) 接口
export interface WebSocketConfig {
taskId: string
mode?: string
showNotifications?: boolean
onProgress?: (data: ProgressMessage) => void
onResult?: (data: ResultMessage) => void
onError?: (err: ErrorMessage | string) => void
onNotify?: (n: NotifyMessage) => void
onMessage?: (raw: WebSocketBaseMessage) => void
onStatusChange?: (status: WebSocketStatus) => void
}
// 心跳检测
const heartbeat = (ws: WebSocket) => {
const pingMessage = {
type: 'Ping',
data: {}
// 日志工具
const wsLog = (message: string, ...args: any[]) => {
if (!WS_DEV) return
const timestamp = new Date().toISOString().split('T')[1].split('.')[0]
console.log(`[WS ${timestamp}] ${message}`, ...args)
}
const wsWarn = (message: string, ...args: any[]) => {
if (!WS_DEV) return
const timestamp = new Date().toISOString().split('T')[1].split('.')[0]
console.warn(`[WS ${timestamp}] ${message}`, ...args)
}
const wsError = (message: string, ...args: any[]) => {
if (!WS_DEV) return
const timestamp = new Date().toISOString().split('T')[1].split('.')[0]
console.error(`[WS ${timestamp}] ${message}`, ...args)
}
// 全局存储接口 - 移除销毁相关字段
interface GlobalWSStorage {
wsRef: WebSocket | null
status: Ref<WebSocketStatus>
subscribers: Ref<Map<string, WebSocketSubscriber>>
heartbeatTimer?: number
isConnecting: boolean
lastPingTime: number
connectionId: string
moduleLoadCount: number
createdAt: number
hasEverConnected: boolean
reconnectAttempts: number // 新增:重连尝试次数
}
const WS_STORAGE_KEY = Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')
// 初始化全局存储
const initGlobalStorage = (): GlobalWSStorage => {
return {
wsRef: null,
status: ref<WebSocketStatus>('已断开'),
subscribers: ref(new Map<string, WebSocketSubscriber>()),
heartbeatTimer: undefined,
isConnecting: false,
lastPingTime: 0,
connectionId: Math.random().toString(36).substr(2, 9),
moduleLoadCount: 0,
createdAt: Date.now(),
hasEverConnected: false,
reconnectAttempts: 0
}
ws.send(JSON.stringify(pingMessage))
}
// 获取全局存储
const getGlobalStorage = (): GlobalWSStorage => {
if (!(window as any)[WS_STORAGE_KEY]) {
wsLog('首次初始化全局 WebSocket 存储 - 永久连接模式')
;(window as any)[WS_STORAGE_KEY] = initGlobalStorage()
}
// 建立WebSocket连接
const connect = async (config: WebSocketConfig): Promise<string | null> => {
const storage = (window as any)[WS_STORAGE_KEY] as GlobalWSStorage
storage.moduleLoadCount++
const uptime = ((Date.now() - storage.createdAt) / 1000).toFixed(1)
wsLog(`模块加载第${storage.moduleLoadCount}次,存储运行时间: ${uptime}s连接状态: ${storage.status.value}`)
return storage
}
// 设置全局状态
const setGlobalStatus = (status: WebSocketStatus) => {
const global = getGlobalStorage()
const oldStatus = global.status.value
global.status.value = status
wsLog(`状态变更: ${oldStatus} -> ${status} [连接ID: ${global.connectionId}]`)
// 广播状态变化给所有订阅者(兼容 onStatusChange
global.subscribers.value.forEach(sub => {
sub.onStatusChange?.(status)
})
}
// 停止心跳
const stopGlobalHeartbeat = () => {
const global = getGlobalStorage()
if (global.heartbeatTimer) {
clearInterval(global.heartbeatTimer)
global.heartbeatTimer = undefined
wsLog('心跳检测已停止')
}
}
// 启动心跳
const startGlobalHeartbeat = (ws: WebSocket) => {
const global = getGlobalStorage()
stopGlobalHeartbeat()
wsLog('启动心跳检测间隔15秒')
global.heartbeatTimer = window.setInterval(() => {
wsLog(`心跳检测 - WebSocket状态: ${ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)`)
if (ws.readyState === WebSocket.OPEN) {
try {
const pingTime = Date.now()
global.lastPingTime = pingTime
const pingData = { Ping: pingTime, connectionId: global.connectionId }
wsLog('发送心跳ping', pingData)
ws.send(JSON.stringify({
type: 'Signal',
data: pingData
}))
// 心跳超时检测 - 但不主动断开连接
setTimeout(() => {
if (global.lastPingTime === pingTime && ws.readyState === WebSocket.OPEN) {
wsWarn(`心跳超时 - 发送时间: ${pingTime}, 当前lastPingTime: ${global.lastPingTime}, 连接状态: ${ws.readyState}`)
wsWarn('心跳超时但保持连接,等待网络层或服务端处理')
}
}, HEARTBEAT_TIMEOUT)
} catch (e) {
wsError('心跳发送失败', e)
if (ws.readyState !== WebSocket.OPEN) {
wsWarn('心跳发送失败,当前连接已不再是 OPEN 状态')
}
}
} else {
wsWarn(`心跳检测时连接状态异常: ${ws.readyState},但不主动断开连接`)
}
}, HEARTBEAT_INTERVAL)
}
// 处理消息
const handleMessage = (raw: WebSocketBaseMessage) => {
const global = getGlobalStorage()
const msgType = String(raw.type)
const id = raw.id
// 处理心跳响应
if (msgType === 'Signal' && raw.data && raw.data.Pong) {
const pongTime = raw.data.Pong
const latency = Date.now() - pongTime
wsLog(`收到心跳pong响应延迟: ${latency}ms`)
global.lastPingTime = 0 // 重置ping时间表示收到了响应
return
}
// 记录其他类型的消息
if (msgType !== 'Signal') {
wsLog(`收到消息: type=${msgType}, id=${id || 'broadcast'}`)
}
const dispatch = (sub: WebSocketSubscriber) => {
if (msgType === 'Signal') return
// 兼容旧版:先调用通用 onMessage 回调
sub.onMessage?.(raw)
if (msgType === 'Progress') return sub.onProgress?.(raw.data as ProgressMessage)
if (msgType === 'Result') return sub.onResult?.(raw.data as ResultMessage)
if (msgType === 'Error') {
sub.onError?.(raw.data as ErrorMessage)
if (!sub.onError && raw.data && (raw.data as ErrorMessage).msg) {
message.error((raw.data as ErrorMessage).msg)
}
return
}
if (msgType === 'Notify') {
sub.onNotify?.(raw.data as NotifyMessage)
if (raw.data && (raw.data as NotifyMessage).title) {
notification.info({
message: (raw.data as NotifyMessage).title,
description: (raw.data as NotifyMessage).content
})
}
return
}
// 其他类型可扩展
}
if (id) {
const sub = global.subscribers.value.get(id)
if (sub) {
dispatch(sub)
} else {
wsWarn(`未找到 ws_id=${id} 的订阅者, type=${msgType}`)
}
} else {
// 无 id 的消息广播给所有订阅者
global.subscribers.value.forEach((sub: WebSocketSubscriber) => dispatch(sub))
}
}
// 延迟重连函数
const scheduleReconnect = (global: GlobalWSStorage) => {
const delay = Math.min(1000 * Math.pow(2, global.reconnectAttempts), 30000) // 最大30秒
wsLog(`计划在 ${delay}ms 后重连 (第${global.reconnectAttempts + 1}次尝试)`)
setTimeout(() => {
global.reconnectAttempts++
createGlobalWebSocket()
}, delay)
}
// 创建 WebSocket 连接 - 移除销毁检查,确保永不放弃连接
const createGlobalWebSocket = (): WebSocket => {
const global = getGlobalStorage()
// 检查现有连接状态
if (global.wsRef) {
wsLog(`检查现有连接状态: ${global.wsRef.readyState}`)
if (global.wsRef.readyState === WebSocket.OPEN) {
wsLog('检测到已有活跃连接,直接返回现有连接')
return global.wsRef
}
if (global.wsRef.readyState === WebSocket.CONNECTING) {
wsLog('检测到正在连接的 WebSocket返回现有连接实例')
return global.wsRef
}
wsLog('现有连接状态为 CLOSING 或 CLOSED将创建新连接')
}
wsLog(`开始创建新的 WebSocket 连接到: ${BASE_WS_URL}`)
const ws = new WebSocket(BASE_WS_URL)
const taskId = config.taskId
// 记录连接创建
wsLog(`WebSocket 实例已创建 [连接ID: ${global.connectionId}]`)
ws.onopen = () => {
statuses.value.set(taskId, '已连接')
config.onStatusChange?.('已连接')
wsLog(`WebSocket 连接已建立 [连接ID: ${global.connectionId}]`)
global.isConnecting = false
global.hasEverConnected = true
global.reconnectAttempts = 0 // 重置重连计数
setGlobalStatus('已连接')
startGlobalHeartbeat(ws)
// 启动心跳
const heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
heartbeat(ws)
}
}, 30000)
// 清理定时器
ws.addEventListener('close', () => {
clearInterval(heartbeatInterval)
})
}
ws.onmessage = (event) => {
// 发送连接确认
try {
const message = JSON.parse(event.data) as WebSocketBaseMessage
switch (message.type) {
case 'Signal':
// 心跳信<E8B7B3><E4BFA1>无需特殊处理
break
case 'Progress':
config.onProgress?.(message.data as ProgressMessage)
break
case 'Result':
config.onResult?.(message.data as ResultMessage)
break
case 'Error':
const errorData = message.data as ErrorMessage
config.onError?.(errorData)
if (config.showNotifications) {
message.error(errorData.msg)
}
break
case 'Notify':
const notifyData = message.data as NotifyMessage
config.onNotify?.(notifyData)
if (config.showNotifications) {
notification.info({
message: notifyData.title,
description: notifyData.content
})
}
break
}
const connectData = { Connect: true, connectionId: global.connectionId }
wsLog('发送连接确认信号', connectData)
ws.send(JSON.stringify({
type: 'Signal',
data: connectData
}))
} catch (e) {
console.error('WebSocket消息解析错误:', e)
wsError('发送连接确认失败', e)
}
}
ws.onerror = (error) => {
statuses.value.set(taskId, '连接错误')
config.onStatusChange?.('连接错误')
config.onError?.({ msg: 'WebSocket连接错误', code: 500 })
}
ws.onclose = () => {
statuses.value.set(taskId, '已断开')
config.onStatusChange?.('已断开')
connections.value.delete(taskId)
}
connections.value.set(taskId, ws)
statuses.value.set(taskId, '连接中')
config.onStatusChange?.('连接中')
return taskId
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '连接失败'
if (config.onError) {
config.onError({ msg: errorMsg, code: 500 })
}
return null
ws.onmessage = (ev) => {
try {
const raw = JSON.parse(ev.data) as WebSocketBaseMessage
handleMessage(raw)
} catch (e) {
wsError('解析 WebSocket 消息失败', e, '原始数据:', ev.data)
}
}
// 发送任务开始指令
const startTask = (taskId: string, params: any) => {
const ws = connections.value.get(taskId)
ws.onerror = (event) => {
wsError(`WebSocket 连接错误 [连接ID: ${global.connectionId}]`, event)
wsError(`错误发生时连接状态: ${ws.readyState}`)
setGlobalStatus('连接错误')
}
ws.onclose = (event) => {
wsLog(`WebSocket 连接已关闭 [连接ID: ${global.connectionId}]`)
wsLog(`关闭码: ${event.code}, 关闭原因: "${event.reason}", 是否干净关闭: ${event.wasClean}`)
// 详细分析关闭原因
const closeReasons: { [key: number]: string } = {
1000: '正常关闭',
1001: '终端离开(如页面关闭)',
1002: '协议错误',
1003: '不支持的数据类型',
1005: '未收到状态码',
1006: '连接异常关闭',
1007: '数据格式错误',
1008: '策略违规',
1009: '消息过大',
1010: '扩展协商失败',
1011: '服务器意外错误',
1015: 'TLS握手失败'
}
const reasonDesc = closeReasons[event.code] || '未知原因'
wsLog(`关闭详情: ${reasonDesc}`)
setGlobalStatus('已断开')
stopGlobalHeartbeat()
global.isConnecting = false
// 永不放弃:立即安排重连
wsLog('连接断开,安排自动重连以保持永久连接')
scheduleReconnect(global)
}
// 为新创建的 WebSocket 设置引用
global.wsRef = ws
wsLog(`WebSocket 引用已设置到全局存储`)
return ws
}
// 连接全局 WebSocket - 简化逻辑,移除销毁检查
const connectGlobalWebSocket = async (): Promise<boolean> => {
const global = getGlobalStorage()
// 详细检查连接状态
if (global.wsRef) {
wsLog(`检查现有连接: readyState=${global.wsRef.readyState}, isConnecting=${global.isConnecting}`)
if (global.wsRef.readyState === WebSocket.OPEN) {
wsLog('WebSocket 已连接,直接返回')
return true
}
if (global.wsRef.readyState === WebSocket.CONNECTING) {
wsLog('WebSocket 正在连接中')
return true
}
}
if (global.isConnecting) {
wsLog('全局连接标志显示正在连接中,等待连接完成')
return true
}
try {
wsLog('开始建立 WebSocket 连接流程')
global.isConnecting = true
global.wsRef = createGlobalWebSocket()
setGlobalStatus('连接中')
wsLog('WebSocket 连接流程已启动')
return true
} catch (e) {
wsError('创建 WebSocket 失败', e)
setGlobalStatus('连接错误')
global.isConnecting = false
// 即使创建失败也要安排重连
scheduleReconnect(global)
return false
}
}
// 模块初始化逻辑
wsLog('=== WebSocket 模块开始初始化 - 永久连接模式 ===')
const global = getGlobalStorage()
if (global.moduleLoadCount > 1) {
wsLog(`检测到模块热更新重载 (第${global.moduleLoadCount}次)`)
wsLog(`当前连接状态: ${global.wsRef ? global.wsRef.readyState : 'null'}`)
wsLog('保持现有连接,不重新建立连接')
} else {
wsLog('首次加载模块,建立永久 WebSocket 连接')
connectGlobalWebSocket()
}
// 页面卸载时不关闭连接,保持永久连接
window.addEventListener('beforeunload', () => {
wsLog('页面即将卸载,但保持 WebSocket 连接')
})
// 主要 Hook 函数
export function useWebSocket() {
const global = getGlobalStorage()
const subscribe = (id: string, handlers: Omit<WebSocketSubscriber, 'id'>) => {
global.subscribers.value.set(id, { id, ...handlers })
wsLog(`添加订阅者: ${id},当前订阅者总数: ${global.subscribers.value.size}`)
}
const unsubscribe = (id: string) => {
const existed = global.subscribers.value.delete(id)
wsLog(`移除订阅者: ${id},是否存在: ${existed},剩余订阅者: ${global.subscribers.value.size}`)
}
const sendRaw = (type: string, data?: any, id?: string) => {
const ws = global.wsRef
wsLog(`尝试发送消息: type=${type}, id=${id || 'broadcast'}`)
if (ws && ws.readyState === WebSocket.OPEN) {
const message = {
type: 'StartTask',
data: {
taskId,
params
try {
const messageData = { id, type, data }
ws.send(JSON.stringify(messageData))
wsLog('消息发送成功')
} catch (e) {
wsError('发送消息失败', e)
}
}
ws.send(JSON.stringify(message))
} else {
wsWarn(`WebSocket 未准备就绪: ${ws ? `状态=${ws.readyState}` : '连接为null'}`)
wsWarn('消息将在连接恢复后可用')
}
}
// 更新配置
const updateConfig = (configKey: string, value: any) => {
// 发送给所<E7BB99><E68980><EFBFBD>活跃连接
connections.value.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
const message = {
type: 'UpdateConfig',
data: {
configKey,
value
const startTaskRaw = (params: any) => {
wsLog('发送启动任务请求', params)
sendRaw('StartTask', params)
}
// 移除 destroy 功能,确保连接永不断开
const forceReconnect = () => {
wsLog('手动触发重连')
if (global.wsRef) {
// 不关闭现有连接,直接尝试创建新连接
global.isConnecting = false
connectGlobalWebSocket()
}
ws.send(JSON.stringify(message))
return true
}
const getConnectionInfo = () => {
const info = {
connectionId: global.connectionId,
status: global.status.value,
subscriberCount: global.subscribers.value.size,
moduleLoadCount: global.moduleLoadCount,
wsReadyState: global.wsRef ? global.wsRef.readyState : null,
isConnecting: global.isConnecting,
hasHeartbeat: !!global.heartbeatTimer,
hasEverConnected: global.hasEverConnected,
reconnectAttempts: global.reconnectAttempts,
wsDevEnabled: WS_DEV,
isPersistentMode: true // 标识为永久连接模式
}
wsLog('连接信息查询', info)
return info
}
// 兼容旧版 APIconnect 重载
async function connect(): Promise<boolean>
async function connect(config: WebSocketConfig): Promise<string | null>
async function connect(config?: WebSocketConfig): Promise<boolean | string | null> {
if (!config) {
// 无参数调用:返回连接状态
return connectGlobalWebSocket()
}
// 有参数调用:建立订阅,复用现有连接
const ok = await connectGlobalWebSocket()
if (!ok) {
// 即使连接失败也要建立订阅,等待连接恢复
wsLog('连接暂时不可用,但仍建立订阅等待连接恢复')
}
// 先移除旧订阅避免重复
if (global.subscribers.value.has(config.taskId)) {
unsubscribe(config.taskId)
}
subscribe(config.taskId, {
onProgress: config.onProgress,
onResult: config.onResult,
onError: (e) => {
if (typeof config.onError === 'function') config.onError(e)
},
onNotify: (n) => {
config.onNotify?.(n)
if (config.showNotifications && n?.title) {
notification.info({ message: n.title, description: n.content })
}
},
onMessage: config.onMessage,
onStatusChange: config.onStatusChange
})
// 立即推送当前状态
config.onStatusChange?.(global.status.value)
// 可根据 mode 发送一个初始信号(可选)
if (config.mode) {
sendRaw('Mode', { mode: config.mode }, config.taskId)
}
// 关闭连接
return config.taskId
}
// 兼容旧版 APIdisconnect / disconnectAll - 只取消订阅,不断开连接
const disconnect = (taskId: string) => {
const ws = connections.value.get(taskId)
if (ws) {
ws.close()
connections.value.delete(taskId)
statuses.value.delete(taskId)
}
if (!taskId) return
unsubscribe(taskId)
wsLog(`兼容模式取消订阅: ${taskId}`)
}
// 关闭所有连接
const disconnectAll = () => {
connections.value.forEach((ws, taskId) => {
disconnect(taskId)
})
const ids = Array.from(global.subscribers.value.keys())
ids.forEach((id: string) => unsubscribe(id))
wsLog('已取消所有订阅 (disconnectAll)')
}
// 组件卸载时清理所有连接
onUnmounted(() => {
disconnectAll()
})
return {
// 兼容 API
connect,
disconnect,
disconnectAll,
startTask,
updateConfig,
statuses
// 原有 API & 工具
subscribe,
unsubscribe,
sendRaw,
startTaskRaw,
forceReconnect,
getConnectionInfo,
status: global.status,
subscribers: global.subscribers
}
}

View File

@@ -34,18 +34,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/ScriptEdit.vue'),
meta: { title: '编辑脚本' },
},
{
path: '/scripts/:scriptId/users/add',
name: 'UserAdd',
component: () => import('../views/UserEdit.vue'),
meta: { title: '添加用户' },
},
{
path: '/scripts/:scriptId/users/:userId/edit',
name: 'UserEdit',
component: () => import('../views/UserEdit.vue'),
meta: { title: '编辑用户' },
},
{
path: '/scripts/:scriptId/users/add/maa',
name: 'MAAUserAdd',

View File

@@ -40,6 +40,10 @@ export interface ElectronAPI {
saveLogsToFile: (logs: string) => Promise<void>
loadLogsFromFile: () => Promise<string | null>
// 文件系统操作
openFile: (filePath: string) => Promise<void>
showItemInFolder: (filePath: string) => Promise<void>
// 监听下载进度
onDownloadProgress: (callback: (progress: any) => void) => void
removeDownloadProgressListener: () => void

View File

@@ -51,8 +51,13 @@
</div>
<div class="user-edit-content">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="user-form">
<a-card title="基本信息" class="form-card">
<a-card class="config-card">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="config-form">
<!-- 基本信息 -->
<div class="form-section">
<div class="section-header">
<h3>基本信息</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="userName" required>
@@ -69,6 +74,7 @@
placeholder="请输入用户名"
:disabled="loading"
size="large"
class="modern-input"
/>
</a-form-item>
</a-col>
@@ -128,11 +134,16 @@
placeholder="请输入备注信息"
:rows="4"
:disabled="loading"
class="modern-input"
/>
</a-form-item>
</a-card>
</div>
<a-card title="额外脚本" class="form-card">
<!-- 额外脚本 -->
<div class="form-section">
<div class="section-header">
<h3>额外脚本</h3>
</div>
<a-form-item name="scriptBeforeTask">
<template #label>
<a-tooltip title="在任务执行前运行自定义脚本">
@@ -217,9 +228,13 @@
</a-col>
</a-row>
</a-form-item>
</a-card>
</div>
<a-card title="通知配置" class="form-card">
<!-- 通知配置 -->
<div class="form-section">
<div class="section-header">
<h3>通知配置</h3>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">启用通知</span>
@@ -302,11 +317,13 @@
"
size="large"
style="width: 100%"
class="modern-input"
/>
</a-col>
</a-row>
</a-card>
</div>
</a-form>
</a-card>
</div>
<a-float-button
@@ -697,6 +714,89 @@ onMounted(() => {
margin: 0 auto;
}
.config-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.config-card :deep(.ant-card-body) {
padding: 32px;
}
.config-form {
max-width: none;
}
.form-section {
margin-bottom: 12px;
}
.form-section:last-child {
margin-bottom: 0;
}
.section-header {
margin-bottom: 6px;
padding-bottom: 8px;
border-bottom: 2px solid var(--ant-color-border-secondary);
}
.section-header h3 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--ant-color-text);
display: flex;
align-items: center;
gap: 12px;
}
.section-header h3::before {
content: '';
width: 4px;
height: 24px;
background: linear-gradient(135deg, var(--ant-color-primary), var(--ant-color-primary-hover));
border-radius: 2px;
}
/* 表单标签 */
.form-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ant-color-text);
font-size: 14px;
}
.help-icon {
color: var(--ant-color-text-tertiary);
font-size: 14px;
cursor: help;
transition: color 0.3s ease;
}
.help-icon:hover {
color: var(--ant-color-primary);
}
.modern-input {
border-radius: 8px;
border: 2px solid var(--ant-color-border);
background: var(--ant-color-bg-container);
transition: all 0.3s ease;
}
.modern-input:hover {
border-color: var(--ant-color-primary-hover);
}
.modern-input:focus,
.modern-input.ant-input-focused {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.1);
}
.form-card {
margin-bottom: 24px;
border-radius: 12px;

View File

@@ -32,7 +32,7 @@
<a-select v-model:value="searchForm.mode" style="width: 100%">
<a-select-option value="按日合并">按日合并</a-select-option>
<a-select-option value="按周合并">按周合并</a-select-option>
<a-select-option value="按月并">按月合并</a-select-option>
<a-select-option value="按月并">按月合并</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -90,32 +90,6 @@
<div v-else class="history-layout">
<!-- 左侧日期列表 -->
<div class="date-sidebar">
<!-- &lt;!&ndash; 数据总览 &ndash;&gt;-->
<!-- <div class="overview-section">-->
<!-- <a-card size="small" title="数据总览" class="overview-card">-->
<!-- <div class="overview-stats">-->
<!-- <a-statistic-->
<!-- title="总公招数"-->
<!-- :value="totalOverview.totalRecruit"-->
<!-- :value-style="{ color: '#1890ff', fontSize: '18px' }"-->
<!-- >-->
<!-- <template #prefix>-->
<!-- <UserOutlined />-->
<!-- </template>-->
<!-- </a-statistic>-->
<!-- <a-statistic-->
<!-- title="总掉落数"-->
<!-- :value="totalOverview.totalDrop"-->
<!-- :value-style="{ color: '#52c41a', fontSize: '18px' }"-->
<!-- >-->
<!-- <template #prefix>-->
<!-- <GiftOutlined />-->
<!-- </template>-->
<!-- </a-statistic>-->
<!-- </div>-->
<!-- </a-card>-->
<!-- </div>-->
<!-- 日期折叠列表 -->
<div class="date-list">
<a-collapse v-model:activeKey="activeKeys" ghost>
@@ -187,7 +161,21 @@
<div class="record-info">
<div class="record-header">
<span class="record-time">{{ record.date }}</span>
<a-tooltip
v-if="record.status === '异常' && selectedUserData?.error_info && selectedUserData.error_info[record.date]"
:title="selectedUserData.error_info[record.date]"
placement="topLeft"
>
<a-tag
color="error"
size="small"
class="error-tag-with-tooltip"
>
{{ record.status }}
</a-tag>
</a-tooltip>
<a-tag
v-else
:color="record.status === '完成' ? 'success' : 'error'"
size="small"
>
@@ -287,7 +275,30 @@
<a-card size="small" title="详细日志" class="log-card">
<template #extra>
<a-space>
<FileTextOutlined />
<a-tooltip title="打开日志文件">
<a-button
size="small"
type="text"
:disabled="!currentJsonFile"
@click="handleOpenLogFile"
>
<template #icon>
<FileOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="打开日志文件所在目录">
<a-button
size="small"
type="text"
:disabled="!currentJsonFile"
@click="handleOpenLogDirectory"
>
<template #icon>
<FolderOpenOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>
</template>
<a-spin :spinning="detailLoading">
@@ -316,13 +327,14 @@ import {
HistoryOutlined,
UserOutlined,
GiftOutlined,
ExclamationCircleOutlined,
FileSearchOutlined,
FileTextOutlined,
RightOutlined,
FolderOpenOutlined,
FileOutlined,
} from '@ant-design/icons-vue'
import { Service } from '@/api/services/Service'
import type { HistorySearchIn, HistoryData, HistoryDataGetIn } from '@/api/models'
import type { HistorySearchIn, HistoryData } from '@/api'
import dayjs from 'dayjs'
// 响应式数据
@@ -565,6 +577,70 @@ const loadUserLog = async (jsonFile: string) => {
}
}
// 打开日志文件
const handleOpenLogFile = async () => {
if (!currentJsonFile.value) {
message.warning('请先选择一条记录')
return
}
try {
// 将 .json 扩展名替换为 .log
const logFilePath = currentJsonFile.value.replace(/\.json$/, '.log')
console.log('尝试打开日志文件:', logFilePath)
console.log('electronAPI 可用性:', !!window.electronAPI)
console.log('openFile 方法可用性:', !!(window.electronAPI && (window.electronAPI as any).openFile))
// 调用系统API打开文件
if (window.electronAPI && (window.electronAPI as any).openFile) {
await (window.electronAPI as any).openFile(logFilePath)
message.success('日志文件已打开')
} else {
const errorMsg = !window.electronAPI
? '当前环境不支持打开文件功能electronAPI 不可用)'
: '当前环境不支持打开文件功能openFile 方法不可用)'
console.error(errorMsg)
message.error(errorMsg)
}
} catch (error) {
console.error('打开日志文件失败:', error)
message.error(`打开日志文件失败: ${error}`)
}
}
// 打开日志文件所在目录
const handleOpenLogDirectory = async () => {
if (!currentJsonFile.value) {
message.warning('请先选择一条记录')
return
}
try {
// 将 .json 扩展名替换为 .log
const logFilePath = currentJsonFile.value.replace(/\.json$/, '.log')
console.log('尝试打开日志文件目录:', logFilePath)
console.log('electronAPI 可用性:', !!window.electronAPI)
console.log('showItemInFolder 方法可用性:', !!(window.electronAPI && (window.electronAPI as any).showItemInFolder))
// 调用系统API打开目录并选中文件
if (window.electronAPI && (window.electronAPI as any).showItemInFolder) {
await (window.electronAPI as any).showItemInFolder(logFilePath)
message.success('日志文件目录已打开')
} else {
const errorMsg = !window.electronAPI
? '当前环境不支持打开目录功能electronAPI 不可用)'
: '当前环境不支持打开目录功能showItemInFolder 方法不可用)'
console.error(errorMsg)
message.error(errorMsg)
}
} catch (error) {
console.error('打开日志文件目录失败:', error)
message.error(`打开日志文件目录失败: ${error}`)
}
}
// 获取日期状态颜色
const getDateStatusColor = (users: Record<string, HistoryData>) => {
const hasError = Object.values(users).some(
@@ -612,7 +688,7 @@ const getDateStatusColor = (users: Record<string, HistoryData>) => {
/* 左侧日期栏 */
.date-sidebar {
width: 320px;
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
@@ -947,6 +1023,16 @@ const getDateStatusColor = (users: Record<string, HistoryData>) => {
}
}
/* 带tooltip的错误tag样式 */
.error-tag-with-tooltip {
cursor: help;
position: relative;
}
.error-tag-with-tooltip:hover {
opacity: 0.8;
}
/* 统计数据标题样式 */
.stat-subtitle {
font-size: 12px;

View File

@@ -52,8 +52,13 @@
</div>
<div class="user-edit-content">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="user-form">
<a-card title="基本信息" class="form-card">
<a-card class="config-card">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="config-form">
<!-- 基本信息 -->
<div class="form-section">
<div class="section-header">
<h3>基本信息</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="userName" required>
@@ -70,6 +75,7 @@
placeholder="请输入用户名"
:disabled="loading"
size="large"
class="modern-input"
/>
</a-form-item>
</a-col>
@@ -280,11 +286,16 @@
placeholder="请输入备注信息"
:rows="4"
:disabled="loading"
class="modern-input"
/>
</a-form-item>
</a-card>
</div>
<a-card title="关卡配置" class="form-card">
<!-- 关卡配置 -->
<div class="form-section">
<div class="section-header">
<h3>关卡配置</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="mode">
@@ -395,7 +406,28 @@
v-model:value="formData.Info.Stage"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stageInputRef"
v-model:value="customStageName"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage"
/>
<a-button type="text" size="small" @click="addCustomStage">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -405,6 +437,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -428,7 +463,28 @@
v-model:value="formData.Info.Stage_1"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage1InputRef"
v-model:value="customStage1Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage1"
/>
<a-button type="text" size="small" @click="addCustomStage1">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -438,6 +494,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -459,7 +518,28 @@
v-model:value="formData.Info.Stage_2"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage2InputRef"
v-model:value="customStage2Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage2"
/>
<a-button type="text" size="small" @click="addCustomStage2">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -469,6 +549,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -490,7 +573,28 @@
v-model:value="formData.Info.Stage_3"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage3InputRef"
v-model:value="customStage3Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage3"
/>
<a-button type="text" size="small" @click="addCustomStage3">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -500,6 +604,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -519,7 +626,28 @@
v-model:value="formData.Info.Stage_Remain"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stageRemainInputRef"
v-model:value="customStageRemainName"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStageRemain"
/>
<a-button type="text" size="small" @click="addCustomStageRemain">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -529,15 +657,22 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="任务配置" class="form-card">
<!-- 任务配置 -->
<div class="form-section">
<div class="section-header">
<h3>任务配置</h3>
</div>
<a-row :gutter="24">
<a-col :span="6">
<a-form-item name="ifWakeUp" label="开始唤醒">
@@ -594,9 +729,21 @@
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="森空岛配置" class="form-card">
<!-- 森空岛配置 -->
<div class="form-section">
<div class="section-header">
<h3>森空岛配置</h3>
<a
href="https://doc.auto-mas.top/docs/advanced-features.html#%E8%8E%B7%E5%8F%96%E9%B9%B0%E8%A7%92%E7%BD%91%E7%BB%9C%E9%80%9A%E8%A1%8C%E8%AF%81%E7%99%BB%E5%BD%95%E5%87%AD%E8%AF%81"
target="_blank"
class="section-doc-link"
title="查看森空岛签到配置文档"
>
文档
</a>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">森空岛签到</span>
@@ -617,21 +764,15 @@
style="margin-top: 8px; width: 100%"
allow-clear
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
请在森空岛官网获取您的专属Token并粘贴到此处详细教程见
<a
href="https://doc.auto-mas.top/docs/advanced-features.html#%E8%8E%B7%E5%8F%96%E9%B9%B0%E8%A7%92%E7%BD%91%E7%BB%9C%E9%80%9A%E8%A1%8C%E8%AF%81%E7%99%BB%E5%BD%95%E5%87%AD%E8%AF%81"
target="_blank"
style="color: #409eff"
>获取鹰角网络通行证登录凭证</a
>
文档
</div>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="通知配置" class="form-card">
<!-- 通知配置 -->
<div class="form-section">
<div class="section-header">
<h3>通知配置</h3>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">启用通知</span>
@@ -718,11 +859,13 @@
"
size="large"
style="width: 100%"
class="modern-input"
/>
</a-col>
</a-row>
</a-card>
</div>
</a-form>
</a-card>
</div>
<a-float-button
@@ -748,6 +891,7 @@ import {
QuestionCircleOutlined,
SaveOutlined,
SettingOutlined,
PlusOutlined,
} from '@ant-design/icons-vue'
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { useUserApi } from '@/composables/useUserApi'
@@ -790,6 +934,12 @@ const serverOptions = [
{ label: '繁中服txwy', value: 'txwy' },
]
// 关卡选项
const stageOptions = ref<any[]>([{ label: '不选择', value: '' }])
// 关卡配置模式选项
const stageModeOptions = ref<any[]>([{ label: '固定', value: 'Fixed' }])
// MAA脚本默认用户数据
const getDefaultMAAUserData = () => ({
Info: {
@@ -976,6 +1126,93 @@ const loadUserData = async () => {
}
}
const loadStageOptions = async () => {
try {
const response = await Service.getStageComboxApiInfoComboxStagePost({
type: 'Today',
})
if (response && response.code === 200 && response.data) {
const sorted = [...response.data].sort((a, b) => {
if (a.value === '-') return -1
if (b.value === '-') return 1
return 0
})
stageOptions.value = sorted
}
} catch (error) {
console.error('加载关卡选项失败:', error)
// 保持默认选项
}
}
const loadStageModeOptions = async () => {
try {
const response = await Service.getPlanComboxApiInfoComboxPlanPost()
if (response && response.code === 200 && response.data) {
stageModeOptions.value = response.data
}
} catch (error) {
console.error('加载关卡配置模式选项失败:', error)
// 保持默认的固定选项
}
}
// 选择基建配置文件
const selectInfrastructureConfig = async () => {
try {
const path = await window.electronAPI?.selectFile([
{ name: 'JSON 文件', extensions: ['json'] },
{ name: '所有文件', extensions: ['*'] },
])
if (path && path.length > 0) {
infrastructureConfigPath.value = path
formData.Info.InfrastPath = path[0]
message.success('文件选择成功')
}
} catch (error) {
console.error('文件选择失败:', error)
message.error('文件选择失败')
}
}
// 导入基建配置
const importInfrastructureConfig = async () => {
if (!infrastructureConfigPath.value) {
message.warning('请先选择配置文件')
return
}
if (!isEdit.value) {
message.warning('请先保存用户后再导入配置')
return
}
try {
infrastructureImporting.value = true
// 调用API导入基建配置
const result = await Service.importInfrastructureApiScriptsUserInfrastructurePost({
scriptId: scriptId,
userId: userId,
jsonFile: infrastructureConfigPath.value[0],
})
if (result && result.code === 200) {
message.success('基建配置导入成功')
// 清空文件路径
infrastructureConfigPath.value = ''
} else {
message.error(result?.msg || '基建配置导入失败')
}
} catch (error) {
console.error('基建配置导入失败:', error)
message.error('基建配置导入失败')
} finally {
infrastructureImporting.value = false
}
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
@@ -1083,94 +1320,147 @@ const handleMAAConfig = async () => {
}
}
const stageModeOptions = ref([{ label: '固定', value: 'Fixed' }])
// 自定义关卡相关
const customStageName = ref('')
const customStage1Name = ref('')
const customStage2Name = ref('')
const customStage3Name = ref('')
const customStageRemainName = ref('')
const loadStageModeOptions = async () => {
try {
const response = await Service.getPlanComboxApiInfoComboxPlanPost()
if (response && response.code === 200 && response.data) {
stageModeOptions.value = response.data
}
} catch (error) {
console.error('加载关卡配置模式选项失败:', error)
// 保持默认的固定选项
}
// 输入框引用
const stageInputRef = ref()
const stage1InputRef = ref()
const stage2InputRef = ref()
const stage3InputRef = ref()
const stageRemainInputRef = ref()
// VNodes 组件,用于渲染下拉菜单内容
const VNodes = {
props: {
vnodes: {
type: Object,
required: true,
},
},
render() {
return this.vnodes
},
}
const stageOptions = ref([{ label: '不选择', value: '' }])
// 验证关卡名称格式
const validateStageName = (stageName: string): boolean => {
if (!stageName || !stageName.trim()) {
return false
}
const loadStageOptions = async () => {
try {
const response = await Service.getStageComboxApiInfoComboxStagePost({
type: 'Today',
// 简单的关卡名称验证,可以根据实际需要调整
const stagePattern = /^[a-zA-Z0-9\-_\u4e00-\u9fa5]+$/
return stagePattern.test(stageName.trim())
}
// 添加自定义关卡到选项列表
const addStageToOptions = (stageName: string) => {
if (!stageName || !stageName.trim()) {
return false
}
const trimmedName = stageName.trim()
// 检查是否已存在
const exists = stageOptions.value.find((option: any) => option.value === trimmedName)
if (exists) {
message.warning(`关卡 "${trimmedName}" 已存在`)
return false
}
// 添加到选项列表
stageOptions.value.push({
label: trimmedName,
value: trimmedName,
isCustom: true
})
if (response && response.code === 200 && response.data) {
const sorted = [...response.data].sort((a, b) => {
if (a.value === '-') return -1
if (b.value === '-') return 1
return 0
})
stageOptions.value = sorted
}
} catch (error) {
console.error('加载关卡选项失败:', error)
// 保持默认选项
}
message.success(`自定义关卡 "${trimmedName}" 添加成功`)
return true
}
// 选择基建配置文件
const selectInfrastructureConfig = async () => {
try {
const path = await window.electronAPI?.selectFile([
{ name: 'JSON 文件', extensions: ['json'] },
{ name: '所有文件', extensions: ['*'] },
])
if (path && path.length > 0) {
infrastructureConfigPath.value = path
formData.Info.InfrastPath = path[0]
message.success('文件选择成功')
}
} catch (error) {
console.error('文件选择失败:', error)
message.error('文件选择失败')
}
}
// 导入基建配置
const importInfrastructureConfig = async () => {
if (!infrastructureConfigPath.value) {
message.warning('请先选择配置文件')
// 添加主关卡
const addCustomStage = () => {
if (!validateStageName(customStageName.value)) {
message.error('请输入有效的关卡名称')
return
}
if (!isEdit.value) {
message.warning('请先保存用户后再导入配置')
if (addStageToOptions(customStageName.value)) {
formData.Info.Stage = customStageName.value.trim()
customStageName.value = ''
nextTick(() => {
stageInputRef.value?.focus()
})
}
}
// 添加备选关卡-1
const addCustomStage1 = () => {
if (!validateStageName(customStage1Name.value)) {
message.error('请输入有效的关卡名称')
return
}
try {
infrastructureImporting.value = true
// 调用API导入基建配置
const result = await Service.importInfrastructureApiScriptsUserInfrastructurePost({
scriptId: scriptId,
userId: userId,
jsonFile: infrastructureConfigPath.value[0],
if (addStageToOptions(customStage1Name.value)) {
formData.Info.Stage_1 = customStage1Name.value.trim()
customStage1Name.value = ''
nextTick(() => {
stage1InputRef.value?.focus()
})
if (result && result.code === 200) {
message.success('基建配置导入成功')
// 清空文件路径
infrastructureConfigPath.value = ''
} else {
message.error(result?.msg || '基建配置导入失败')
}
} catch (error) {
console.error('基建配置导入失败:', error)
message.error('基建配置导入失败')
} finally {
infrastructureImporting.value = false
}
// 添加备选关卡-2
const addCustomStage2 = () => {
if (!validateStageName(customStage2Name.value)) {
message.error('请输入有效的关卡名称')
return
}
if (addStageToOptions(customStage2Name.value)) {
formData.Info.Stage_2 = customStage2Name.value.trim()
customStage2Name.value = ''
nextTick(() => {
stage2InputRef.value?.focus()
})
}
}
// 添加备选关卡-3
const addCustomStage3 = () => {
if (!validateStageName(customStage3Name.value)) {
message.error('请输入有效的关卡名称')
return
}
if (addStageToOptions(customStage3Name.value)) {
formData.Info.Stage_3 = customStage3Name.value.trim()
customStage3Name.value = ''
nextTick(() => {
stage3InputRef.value?.focus()
})
}
}
// 添加剩余理智关卡
const addCustomStageRemain = () => {
if (!validateStageName(customStageRemainName.value)) {
message.error('请输入有效的关卡名称')
return
}
if (addStageToOptions(customStageRemainName.value)) {
formData.Info.Stage_Remain = customStageRemainName.value.trim()
customStageRemainName.value = ''
nextTick(() => {
stageRemainInputRef.value?.focus()
})
}
}
@@ -1183,6 +1473,7 @@ const handleCancel = () => {
router.push('/scripts')
}
// 初始化加载
onMounted(() => {
if (!scriptId) {
message.error('缺少脚本ID参数')
@@ -1241,6 +1532,115 @@ onMounted(() => {
margin: 0 auto;
}
.config-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.config-card :deep(.ant-card-body) {
padding: 32px;
}
.config-form {
max-width: none;
}
/* form-section 样式 - 来自 ScriptEdit.vue */
.form-section {
margin-bottom: 32px;
}
.form-section:last-child {
margin-bottom: 0;
}
.section-header {
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: 2px solid var(--ant-color-border-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h3 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--ant-color-text);
display: flex;
align-items: center;
gap: 12px;
}
.section-header h3::before {
content: '';
width: 4px;
height: 24px;
background: linear-gradient(135deg, var(--ant-color-primary), var(--ant-color-primary-hover));
border-radius: 2px;
}
/* 表单标签 */
.form-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ant-color-text);
font-size: 14px;
}
.help-icon {
color: var(--ant-color-text-tertiary);
font-size: 14px;
cursor: help;
transition: color 0.3s ease;
}
.help-icon:hover {
color: var(--ant-color-primary);
}
.modern-input {
border-radius: 8px;
border: 2px solid var(--ant-color-border);
background: var(--ant-color-bg-container);
transition: all 0.3s ease;
}
.modern-input:hover {
border-color: var(--ant-color-primary-hover);
}
.modern-input:focus,
.modern-input.ant-input-focused {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.1);
}
/* section标题右侧文档链接 */
.section-doc-link {
color: var(--ant-color-primary) !important;
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--ant-color-primary);
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
}
.section-doc-link:hover {
color: var(--ant-color-primary-hover) !important;
background-color: var(--ant-color-primary-bg);
border-color: var(--ant-color-primary-hover);
text-decoration: none;
}
.form-card {
margin-bottom: 24px;
border-radius: 12px;

View File

@@ -202,15 +202,21 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, h, nextTick, computed } from 'vue'
import { message, notification } from 'ant-design-vue'
import {
PlayCircleOutlined,
StopOutlined,
} from '@ant-design/icons-vue'
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons-vue'
import { Service } from '@/api/services/Service'
import type { ComboBoxItem } from '@/api/models/ComboBoxItem'
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
import { useWebSocket, type WebSocketBaseMessage } from '@/composables/useWebSocket'
// 调度台标签页相关
// 类型定义
interface RunningTask {
websocketId: string
taskName: string
status: string
logs: Array<{ time: string; message: string; type: 'info' | 'error' | 'warning' | 'success' }>
taskQueue: Array<{ name: string; status: string }>
userQueue: Array<{ name: string; status: string }>
}
interface SchedulerTab {
key: string
title: string
@@ -219,715 +225,263 @@ interface SchedulerTab {
activeTaskPanels: string[]
completionAction: string
}
interface TaskMessage { title: string; content: string; needInput: boolean; messageId?: string; taskId?: string }
const schedulerTabs = ref<SchedulerTab[]>([
{
key: 'main',
title: '主调度台',
closable: false,
runningTasks: [],
activeTaskPanels: [],
completionAction: 'none',
},
])
// 状态
const schedulerTabs = ref<SchedulerTab[]>([{ key: 'main', title: '主调度台', closable: false, runningTasks: [], activeTaskPanels: [], completionAction: 'none' }])
const activeSchedulerTab = ref('main')
let tabCounter = 1
const currentTab = computed(() => schedulerTabs.value.find(t => t.key === activeSchedulerTab.value) || schedulerTabs.value[0])
// 当前活动的调度台
const currentTab = computed(() => {
return (
schedulerTabs.value.find(tab => tab.key === activeSchedulerTab.value) || schedulerTabs.value[0]
)
})
// 响应式数据
const addTaskModalVisible = ref(false)
const messageModalVisible = ref(false)
const taskOptionsLoading = ref(false)
const addTaskLoading = ref(false)
const outputRefs = ref<Map<string, HTMLElement>>(new Map())
// 任务选项
const taskOptions = ref<ComboBoxItem[]>([])
// 任务表单(弹窗用)
const taskForm = reactive({
taskId: null,
mode: '自动代理' as TaskCreateIn['mode'],
})
// 快速任务表单(右上角用)
const quickTaskForm = reactive({
taskId: null,
mode: '自动代理' as TaskCreateIn['mode'],
})
// 运行中的任务
interface RunningTask {
websocketId: string
taskName: string
status: string
websocket: WebSocket | null
logs: Array<{
time: string
message: string
type: 'info' | 'error' | 'warning' | 'success'
}>
taskQueue: Array<{
name: string
status: string
}>
userQueue: Array<{
name: string
status: string
}>
}
// 消息处理
interface TaskMessage {
title: string
content: string
needInput: boolean
messageId?: string
taskId?: string
}
const outputRefs = ref(new Map<string, HTMLElement>())
const currentMessage = ref<TaskMessage | null>(null)
const messageResponse = ref('')
// 调度台标签页操作
const onSchedulerTabEdit = (targetKey: string | MouseEvent, action: 'add' | 'remove') => {
if (action === 'add') {
addSchedulerTab()
} else if (action === 'remove' && typeof targetKey === 'string') {
removeSchedulerTab(targetKey)
}
}
// 表单
const taskForm = reactive<{ taskId: string | null; mode: TaskCreateIn.mode }>({ taskId: null, mode: TaskCreateIn.mode.AutoMode })
const quickTaskForm = reactive<{ taskId: string | null; mode: TaskCreateIn.mode }>({ taskId: null, mode: TaskCreateIn.mode.AutoMode })
// WebSocket API
const { connect: wsConnect, disconnect: wsDisconnect, sendRaw } = useWebSocket()
// Tab 事件
const onSchedulerTabEdit = (targetKey: string | MouseEvent, action: 'add' | 'remove') => {
if (action === 'add') addSchedulerTab()
else if (action === 'remove' && typeof targetKey === 'string') removeSchedulerTab(targetKey)
}
const addSchedulerTab = () => {
tabCounter++
const newTab: SchedulerTab = {
key: `tab-${tabCounter}`,
title: `调度台${tabCounter}`,
closable: true,
runningTasks: [],
activeTaskPanels: [],
completionAction: 'none',
}
schedulerTabs.value.push(newTab)
activeSchedulerTab.value = newTab.key
const tab: SchedulerTab = { key: `tab-${tabCounter}`, title: `调度台${tabCounter}`, closable: true, runningTasks: [], activeTaskPanels: [], completionAction: 'none' }
schedulerTabs.value.push(tab)
activeSchedulerTab.value = tab.key
}
const removeSchedulerTab = (key: string) => {
const idx = schedulerTabs.value.findIndex(t => t.key === key)
if (idx === -1) return
schedulerTabs.value[idx].runningTasks.forEach(t => wsDisconnect(t.websocketId))
schedulerTabs.value.splice(idx, 1)
if (activeSchedulerTab.value === key) activeSchedulerTab.value = schedulerTabs.value[Math.max(0, idx - 1)]?.key || 'main'
}
const removeSchedulerTab = (targetKey: string) => {
const targetIndex = schedulerTabs.value.findIndex(tab => tab.key === targetKey)
if (targetIndex === -1) return
// 引用
const setOutputRef = (el: HTMLElement | null, id: string) => { if (el) outputRefs.value.set(id, el); else outputRefs.value.delete(id) }
// 停止该调度台的所有任务
const targetTab = schedulerTabs.value[targetIndex]
targetTab.runningTasks.forEach(task => {
if (task.websocket) {
task.websocket.close()
}
})
// 移除调度台
schedulerTabs.value.splice(targetIndex, 1)
// 如果删除的是当前活动的调度台,切换到其他调度台
if (activeSchedulerTab.value === targetKey) {
activeSchedulerTab.value = schedulerTabs.value[Math.max(0, targetIndex - 1)].key
}
}
// 设置输出容器引用
const setOutputRef = (el: HTMLElement | null, websocketId: string) => {
if (el) {
outputRefs.value.set(websocketId, el)
} else {
outputRefs.value.delete(websocketId)
}
}
// 获取任务选项
// 拉取任务选项
const loadTaskOptions = async () => {
try {
taskOptionsLoading.value = true
const response = await Service.getTaskComboxApiInfoComboxTaskPost()
if (response.code === 200) {
taskOptions.value = response.data
} else {
message.error('获取任务列表失败')
}
} catch (error) {
console.error('获取任务列表失败:', error)
const r = await Service.getTaskComboxApiInfoComboxTaskPost()
if (r.code === 200) taskOptions.value = r.data
else message.error('获取任务列表失败')
} catch (e) {
console.error(e)
message.error('获取任务列表失败')
} finally {
taskOptionsLoading.value = false
}
}
// 显示添加任务弹窗
const showAddTaskModal = () => {
addTaskModalVisible.value = true
if (taskOptions.value.length === 0) {
loadTaskOptions()
}
}
// 添加任务(弹窗方式,创建新调度台)
// 添加任务(新 Tab
const addTask = async () => {
if (!taskForm.taskId || !taskForm.mode) {
message.error('请填写完整的任务信息')
return
}
if (!taskForm.taskId) return message.error('请填写完整的任务信息')
try {
addTaskLoading.value = true
const response = await Service.addTaskApiDispatchStartPost({
taskId: taskForm.taskId,
mode: taskForm.mode,
})
if (response.code === 200) {
// 创建新的调度台
const r = await Service.addTaskApiDispatchStartPost({ taskId: taskForm.taskId, mode: taskForm.mode })
if (r.code === 200) {
addSchedulerTab()
// 查找任务名称
const selectedOption = taskOptions.value.find(option => option.value === taskForm.taskId)
const taskName = selectedOption?.label || '未知任务'
// 创建任务并添加到新调度台
const task: RunningTask = {
websocketId: response.websocketId,
taskName,
status: '连接中',
websocket: null,
logs: [],
taskQueue: [],
userQueue: [],
}
// 添加到当前活动的调度台
const opt = taskOptions.value.find(o => o.value === taskForm.taskId)
const task: RunningTask = { websocketId: r.websocketId, taskName: opt?.label || '未知任务', status: '连接中', logs: [], taskQueue: [], userQueue: [] }
currentTab.value.runningTasks.push(task)
currentTab.value.activeTaskPanels.push(task.websocketId)
// 连接WebSocket
connectWebSocket(task)
subscribeTask(task, taskForm.mode)
message.success('任务创建成功')
addTaskModalVisible.value = false
// 重置表单
taskForm.taskId = null
taskForm.mode = '自动代理'
} else {
message.error(response.message || '创建任务失败')
}
} catch (error) {
console.error('创建任务失败:', error)
taskForm.mode = TaskCreateIn.mode.AutoMode
} else message.error(r.message || '创建任务失败')
} catch (e) {
console.error(e)
message.error('创建任务失败')
} finally {
addTaskLoading.value = false
}
} finally { addTaskLoading.value = false }
}
// 快速开始任务(右上角方式,添加到当前调度台)
// 快速开始(当前 Tab
const startQuickTask = async () => {
if (!quickTaskForm.taskId || !quickTaskForm.mode) {
message.error('请选择任务和执行模式')
return
}
if (!quickTaskForm.taskId) return message.error('请选择任务和执行模式')
try {
const response = await Service.addTaskApiDispatchStartPost({
taskId: quickTaskForm.taskId,
mode: quickTaskForm.mode,
})
if (response.code === 200) {
// 查找任务名称
const selectedOption = taskOptions.value.find(option => option.value === quickTaskForm.taskId)
const taskName = selectedOption?.label || '未知任务'
// 检查是否已存在同名任务
const existingTaskIndex = currentTab.value.runningTasks.findIndex(t => t.taskName === taskName)
if (existingTaskIndex >= 0) {
// 如果存在同名任务,复用现有任务卡片
const existingTask = currentTab.value.runningTasks[existingTaskIndex]
// 关闭旧的WebSocket连接如果存在
if (existingTask.websocket) {
existingTask.websocket.close()
}
// 保存旧的 websocketId
const oldWebsocketId = existingTask.websocketId
// 更新任务信息
existingTask.websocketId = response.websocketId
existingTask.status = '连接中'
existingTask.websocket = null
existingTask.userQueue = []
// 添加分隔日志
existingTask.logs.push({
time: new Date().toLocaleTimeString(),
message: '========== 新任务开始 ==========',
type: 'info'
})
// 更新 activeTaskPanels 数组移除旧ID添加新ID
const oldPanelIndex = currentTab.value.activeTaskPanels.indexOf(oldWebsocketId)
if (oldPanelIndex >= 0) {
currentTab.value.activeTaskPanels.splice(oldPanelIndex, 1)
}
currentTab.value.activeTaskPanels.push(existingTask.websocketId)
// 连接新的WebSocket
connectWebSocket(existingTask)
message.success('任务启动成功')
const r = await Service.addTaskApiDispatchStartPost({ taskId: quickTaskForm.taskId, mode: quickTaskForm.mode })
if (r.code === 200) {
const opt = taskOptions.value.find(o => o.value === quickTaskForm.taskId)
const name = opt?.label || '未知任务'
const idx = currentTab.value.runningTasks.findIndex(t => t.taskName === name)
if (idx >= 0) {
const existing = currentTab.value.runningTasks[idx]
wsDisconnect(existing.websocketId)
const oldId = existing.websocketId
existing.websocketId = r.websocketId
existing.status = '连接中'
existing.userQueue = []
existing.logs.push({ time: new Date().toLocaleTimeString(), message: '========== 新任务开始 ==========', type: 'info' })
const pIdx = currentTab.value.activeTaskPanels.indexOf(oldId)
if (pIdx >= 0) currentTab.value.activeTaskPanels.splice(pIdx, 1)
currentTab.value.activeTaskPanels.push(existing.websocketId)
subscribeTask(existing, quickTaskForm.mode)
} else {
// 如果不存在同名任务,创建新任务
const task: RunningTask = {
websocketId: response.websocketId,
taskName,
status: '连接中',
websocket: null,
logs: [],
taskQueue: [],
userQueue: [],
}
const task: RunningTask = { websocketId: r.websocketId, taskName: name, status: '连接中', logs: [], taskQueue: [], userQueue: [] }
currentTab.value.runningTasks.push(task)
currentTab.value.activeTaskPanels.push(task.websocketId)
// 连接WebSocket
connectWebSocket(task)
message.success('任务启动成功')
subscribeTask(task, quickTaskForm.mode)
}
// 重置表单
quickTaskForm.taskId = null
quickTaskForm.mode = '自动代理'
} else {
message.error(response.message || '启动任务失败')
}
} catch (error) {
console.error('启动任务失败:', error)
quickTaskForm.mode = TaskCreateIn.mode.AutoMode
message.success('任务启动成功')
} else message.error(r.message || '启动任务失败')
} catch (e) {
console.error(e)
message.error('启动任务失败')
}
}
// 取消添加任务
// 订阅任务
const subscribeTask = (task: RunningTask, mode: TaskCreateIn.mode) => {
wsConnect({
taskId: task.websocketId,
mode,
onMessage: raw => handleWebSocketMessage(task, raw),
onStatusChange: st => {
if (st === '已连接' && task.status === '连接中') task.status = '运行中'
if (st === '已断开' && task.status === '运行中') task.status = '已断开'
if (st === '连接错误') task.status = '连接错误'
}
})
}
// 取消添加
const cancelAddTask = () => {
addTaskModalVisible.value = false
taskForm.taskId = null
taskForm.mode = '自动代理'
taskForm.mode = TaskCreateIn.mode.AutoMode
}
// 连接WebSocket
const connectWebSocket = (task: RunningTask) => {
const wsUrl = `ws://localhost:36163/api/dispatch/ws/${task.websocketId}`
try {
const ws = new WebSocket(wsUrl)
task.websocket = ws
ws.onopen = () => {
// 更新任务状态
const taskIndex = currentTab.value.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (taskIndex >= 0) {
currentTab.value.runningTasks[taskIndex].status = '运行中'
}
addTaskLog(task, '已连接到任务服务器', 'success')
}
ws.onmessage = event => {
try {
const data = JSON.parse(event.data)
handleWebSocketMessage(task, data)
} catch (error) {
console.error('解析WebSocket消息失败:', error)
addTaskLog(task, `收到无效消息: ${event.data}`, 'error')
}
}
ws.onclose = () => {
// 更新任务状态
const taskIndex = currentTab.value.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (taskIndex >= 0) {
currentTab.value.runningTasks[taskIndex].status = '已断开'
currentTab.value.runningTasks[taskIndex].websocket = null
}
addTaskLog(task, '与服务器连接已断开', 'warning')
}
ws.onerror = error => {
// 更新任务状态
const taskIndex = currentTab.value.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (taskIndex >= 0) {
currentTab.value.runningTasks[taskIndex].status = '连接错误'
}
addTaskLog(task, '连接发生错误', 'error')
console.error('WebSocket错误:', error)
}
} catch (error) {
task.status = '连接失败'
addTaskLog(task, '无法连接到服务器', 'error')
console.error('WebSocket连接失败:', error)
}
}
// 处理WebSocket消息
const handleWebSocketMessage = (task: RunningTask, data: any) => {
// 添加详细的调试日志
console.log('收到WebSocket消息:', data)
console.log('消息类型:', data.type)
console.log('消息数据:', data.data)
// 找到任务在当前调度台中的索引,确保实时更新
const taskIndex = currentTab.value.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (taskIndex === -1) {
console.log('未找到任务索引websocketId:', task.websocketId)
return
}
switch (data.type) {
case 'Update':
console.log('处理Update消息')
// 界面更新信息 - 更新用户队列
if (data.data) {
console.log('data.data存在:', data.data)
// 更新用户队列 - 只显示 task_list 中的 name 字段
if (data.data.task_list && Array.isArray(data.data.task_list)) {
console.log('找到task_list长度:', data.data.task_list.length)
console.log('task_list内容:', data.data.task_list)
// 直接更新响应式数组中的用户队列
const newUserQueue = data.data.task_list.map((item: any) => ({
name: item.name || '未知任务',
status: item.status || '未知',
}))
console.log('映射后的用户队列:', newUserQueue)
currentTab.value.runningTasks[taskIndex].userQueue = newUserQueue
// 强制触发响应式更新
// 日志工具
const addTaskLog = (task: RunningTask, msg: string, type: 'info' | 'error' | 'warning' | 'success' = 'info') => {
task.logs.push({ time: new Date().toLocaleTimeString(), message: msg, type })
nextTick(() => {
console.log('nextTick后的用户队列:', currentTab.value.runningTasks[taskIndex].userQueue)
const el = outputRefs.value.get(task.websocketId)
if (el) el.scrollTop = el.scrollHeight
})
// 添加调试日志,确认数据更新
console.log('用户队列已更新:', currentTab.value.runningTasks[taskIndex].userQueue)
console.log('当前任务对象:', currentTab.value.runningTasks[taskIndex])
} else {
console.log('task_list不存在或不是数组')
}
// 其他更新信息记录到日志,但不显示 task_list 的原始数据
for (const [key, value] of Object.entries(data.data)) {
if (key !== 'task_list') {
addTaskLog(currentTab.value.runningTasks[taskIndex], `${key}: ${value}`, 'info')
}
}
} else {
console.log('data.data不存在')
}
break
case 'Message':
// 需要用户输入的消息
currentMessage.value = {
title: '任务消息',
content: data.message || '任务需要您的输入',
needInput: true,
messageId: data.messageId,
taskId: task.websocketId,
}
messageModalVisible.value = true
break
case 'Info':
// 通知信息
let level = 'info'
let content = '未知通知'
// 检查数据中是否有 Error 字段
if (data.data?.Error) {
// 如果是错误信息,设置为 error 级别
level = 'error'
content = data.data.Error // 错误信息内容
} else {
// 如果没有 Error 字段,继续处理 val 或 message 字段
content = data.data?.val || data.data?.message || '未知通知'
}
addTaskLog(task, content, level as any)
// 显示系统通知
if (level === 'error') {
notification.error({ message: '任务错误', description: content })
} else if (level === 'warning') {
notification.warning({ message: '任务警告', description: content })
} else if (level === 'success') {
notification.success({ message: '任务成功', description: content })
} else {
notification.info({ message: '任务信息', description: content })
}
break
case 'Signal':
// 状态信号
if (data.data?.Accomplish !== undefined) {
const newStatus = data.data.Accomplish ? '已完成' : '已失败'
// 更新任务状态
currentTab.value.runningTasks[taskIndex].status = newStatus
addTaskLog(currentTab.value.runningTasks[taskIndex], `任务${newStatus}`, data.data.Accomplish ? 'success' : 'error')
// 检查是否所有任务都完成了
checkAllTasksCompleted()
// 断开连接
if (currentTab.value.runningTasks[taskIndex].websocket) {
currentTab.value.runningTasks[taskIndex].websocket.close()
currentTab.value.runningTasks[taskIndex].websocket = null
}
}
break
default:
addTaskLog(task, `收到未知消息类型: ${data.type}`, 'warning')
}
}
// 检查所有任务是否完成,执行完成后操作
// 颜色映射
const getTaskStatusColor = (s: string) => ({ '连接中': 'processing', '运行中': 'blue', '已完成': 'green', '已失败': 'red', '已断开': 'default', '连接错误': 'red' } as Record<string, string>)[s] || 'default'
const getQueueItemStatusColor = (s: string) => /成功|完成|已完成/.test(s) ? 'green' : /失败|错误|异常/.test(s) ? 'red' : /等待|排队|挂起/.test(s) ? 'orange' : /进行|执行|运行/.test(s) ? 'blue' : 'default'
const filterTaskOption = (input: string, option: any) => (option?.label || '').toLowerCase().includes(input.toLowerCase())
// 完成检测
const checkAllTasksCompleted = () => {
const allCompleted = currentTab.value.runningTasks.every(
task => task.status === '已完成' || task.status === '已失败' || task.status === '已断开'
)
if (allCompleted && currentTab.value.runningTasks.length > 0) {
executeCompletionAction(currentTab.value.completionAction)
}
const all: RunningTask[] = []
schedulerTabs.value.forEach(t => all.push(...t.runningTasks))
if (!all.length) return
if (!all.every(t => ['已完成', '已失败', '已断开'].includes(t.status))) return
const action = currentTab.value.completionAction
if (!action || action === 'none') return
message.success(`所有任务结束,准备执行动作: ${action}`)
}
// 执行完成后操作
const executeCompletionAction = (action: string) => {
switch (action) {
case 'none':
// 无动作
break
case 'exit':
// 退出软件
notification.info({ message: '所有任务已完成', description: '正在退出软件...' })
// 这里可以调用 Electron 的退出方法
window.close()
break
case 'sleep':
// 睡眠
notification.info({ message: '所有任务已完成', description: '正在进入睡眠模式...' })
// 这里需要调用系统睡眠 API
break
case 'hibernate':
// 休眠
notification.info({ message: '所有任务已完成', description: '正在进入休眠模式...' })
// 这里需要调用系统休眠 API
break
case 'shutdown':
// 关机
notification.info({ message: '所有任务已完成', description: '正在关机...' })
// 这里需要调用系统关机 API
break
case 'force-shutdown':
// 强制关机
notification.info({ message: '所有任务已完成', description: '正在强制关机...' })
// 这里需要调用系统强制关机 API
break
}
}
// 添加任务日志 - 使用 websocketId 而不是 task 对象
const addTaskLog = (
task: RunningTask,
message: string,
type: 'info' | 'error' | 'warning' | 'success' = 'info'
) => {
const now = new Date()
const time = now.toLocaleTimeString()
// 在所有调度台中查找正确的任务
let foundTask = false
for (const tab of schedulerTabs.value) {
const taskIndex = tab.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (taskIndex >= 0) {
// 直接修改 reactive 数组中的任务对象
tab.runningTasks[taskIndex].logs.push({
time,
message,
type,
})
// 更新任务状态(如果有变化)
if (tab.runningTasks[taskIndex].status !== task.status) {
tab.runningTasks[taskIndex].status = task.status
}
foundTask = true
break
}
}
if (!foundTask) {
console.warn('未找到对应的任务来添加日志websocketId:', task.websocketId)
}
// 自动滚动到底部
nextTick(() => {
const outputElement = outputRefs.value.get(task.websocketId)
if (outputElement) {
outputElement.scrollTop = outputElement.scrollHeight
}
})
}
//发送消息响应
const sendMessageResponse = () => {
if (!currentMessage.value || !currentMessage.value.taskId) {
return
}
// 在所有调度台中查找任务
let task: RunningTask | undefined
for (const tab of schedulerTabs.value) {
task = tab.runningTasks.find(t => t.websocketId === currentMessage.value?.taskId)
if (task) break
}
if (task && task.websocket) {
const response = {
type: 'MessageResponse',
messageId: currentMessage.value.messageId,
response: messageResponse.value,
}
task.websocket.send(JSON.stringify(response))
addTaskLog(task, `用户回复: ${messageResponse.value}`, 'info')
}
messageModalVisible.value = false
messageResponse.value = ''
currentMessage.value = null
}
// 取消消息
// 消息弹窗控制
const cancelMessage = () => {
messageModalVisible.value = false
messageResponse.value = ''
currentMessage.value = null
}
// WebSocket 消息处理
const handleWebSocketMessage = (task: RunningTask, raw: WebSocketBaseMessage) => {
const type = raw.type
const payload: any = raw.data
const idx = currentTab.value.runningTasks.findIndex(t => t.websocketId === task.websocketId)
if (idx === -1) return
switch (type) {
case 'Update': {
if (payload?.task_list) {
currentTab.value.runningTasks[idx].userQueue = payload.task_list.map((i: any) => ({ name: i.name || '未知任务', status: i.status || '未知' }))
}
if (payload) Object.entries(payload).forEach(([k, v]) => { if (k !== 'task_list') addTaskLog(currentTab.value.runningTasks[idx], `${k}: ${v}`, 'info') })
break
}
case 'Message': {
currentMessage.value = { title: '任务消息', content: payload?.message || payload?.val || '任务需要您的输入', needInput: true, messageId: payload?.messageId || (raw as any).messageId, taskId: task.websocketId }
messageModalVisible.value = true
break
}
case 'Info': {
const isErr = !!payload?.Error
const content = payload?.Error || payload?.val || payload?.message || '未知通知'
addTaskLog(task, content, isErr ? 'error' : 'info')
if (isErr) notification.error({ message: '任务错误', description: content })
else notification.info({ message: '任务信息', description: content })
break
}
case 'Signal': {
if (payload?.Accomplish !== undefined) {
const done = !!payload.Accomplish
currentTab.value.runningTasks[idx].status = done ? '已完成' : '已失败'
addTaskLog(currentTab.value.runningTasks[idx], `任务${done ? '已完成' : '已失败'}`, done ? 'success' : 'error')
checkAllTasksCompleted()
wsDisconnect(task.websocketId)
}
break
}
default:
addTaskLog(task, `收到未知消息类型: ${type}`, 'warning')
}
}
// 回复消息
const sendMessageResponse = () => {
if (!currentMessage.value?.taskId) return
const task = schedulerTabs.value.flatMap(t => t.runningTasks).find(t => t.websocketId === currentMessage.value!.taskId)
if (task) {
sendRaw('MessageResponse', { messageId: currentMessage.value!.messageId, response: messageResponse.value }, task.websocketId)
addTaskLog(task, `用户回复: ${messageResponse.value}`, 'info')
}
messageModalVisible.value = false
messageResponse.value = ''
currentMessage.value = null
}
// 停止任务
const stopTask = (taskId: string) => {
const taskIndex = currentTab.value.runningTasks.findIndex(t => t.websocketId === taskId)
if (taskIndex >= 0) {
const task = currentTab.value.runningTasks[taskIndex]
// 关闭WebSocket连接
if (task.websocket) {
task.websocket.close()
task.websocket = null
}
// 从列表中移除
currentTab.value.runningTasks.splice(taskIndex, 1)
// 从展开面板中移除
const panelIndex = currentTab.value.activeTaskPanels.indexOf(taskId)
if (panelIndex >= 0) {
currentTab.value.activeTaskPanels.splice(panelIndex, 1)
}
const stopTask = (id: string) => {
const idx = currentTab.value.runningTasks.findIndex(t => t.websocketId === id)
if (idx >= 0) {
const task = currentTab.value.runningTasks[idx]
wsDisconnect(task.websocketId)
currentTab.value.runningTasks.splice(idx, 1)
const p = currentTab.value.activeTaskPanels.indexOf(id)
if (p >= 0) currentTab.value.activeTaskPanels.splice(p, 1)
message.success('任务已停止')
}
}
// 清空任务输出
const clearTaskOutput = (taskId: string) => {
const task = currentTab.value.runningTasks.find(t => t.websocketId === taskId)
if (task) {
task.logs = []
}
// 清空日志(按钮已注释,可保留)
const clearTaskOutput = (id: string) => {
const t = currentTab.value.runningTasks.find(x => x.websocketId === id)
if (t) t.logs = []
}
// 获取任务状态颜色
const getTaskStatusColor = (status: string) => {
switch (status) {
case '运行中':
return 'processing'
case '已完成':
return 'success'
case '已失败':
return 'error'
case '连接中':
return 'default'
case '已断开':
return 'warning'
case '连接错误':
return 'error'
case '连接失败':
return 'error'
default:
return 'default'
}
}
// 获取队列项状态颜色
const getQueueItemStatusColor = (status: string) => {
switch (status) {
case '运行':
case '运行中':
return 'processing'
case '完成':
case '已完成':
return 'success'
case '失败':
case '已失败':
return 'error'
case '等待':
case '待执行':
return 'default'
case '暂停':
return 'warning'
default:
return 'default'
}
}
// 任务选项过滤
const filterTaskOption = (input: string, option: any) => {
return option.label.toLowerCase().includes(input.toLowerCase())
}
// 组件挂载时加载任务选项
onMounted(() => {
loadTaskOptions()
})
// 组件卸载时清理WebSocket连接
onUnmounted(() => {
schedulerTabs.value.forEach(tab => {
tab.runningTasks.forEach(task => {
if (task.websocket) {
task.websocket.close()
}
})
})
})
// 生命周期
onMounted(() => { wsConnect(); loadTaskOptions() })
onUnmounted(() => { schedulerTabs.value.forEach(tab => tab.runningTasks.forEach(t => wsDisconnect(t.websocketId))) })
</script>
<style scoped>
.scheduler-container {
height: 100%;
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
}
@@ -941,59 +495,41 @@ onUnmounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
<<<<<<< Updated upstream
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
border: 1px solid var(--ant-color-border);
=======
padding: 8px 16px;
border-bottom: 1px solid #d9d9d9;
>>>>>>> Stashed changes
}
.left-actions {
display: flex;
align-items: center;
gap: 12px;
}
.completion-label {
margin-right: 8px;
font-weight: 500;
}
.right-actions {
display: flex;
gap: 12px;
align-items: center;
}
.completion-label {
font-size: 14px;
color: var(--ant-color-text);
white-space: nowrap;
}
.execution-area {
flex: 1;
overflow: hidden;
overflow-y: auto;
padding: 16px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
text-align: center;
color: #999;
padding: 40px 0;
}
.task-panels {
height: 100%;
overflow-y: auto;
}
.task-detail-layout {
display: flex;
height: 400px;
gap: 1px;
border: 1px solid var(--ant-color-border);
border-radius: 6px;
overflow: hidden;
margin-top: 16px;
}
.realtime-logs-panel {
@@ -1001,7 +537,7 @@ onUnmounted(() => {
flex-direction: column;
}
.panel-hea der {
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;

View File

@@ -215,7 +215,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@@ -287,10 +287,6 @@ onMounted(() => {
loadScripts()
})
onUnmounted(() => {
// 清理所有WebSocket连接
disconnectAll()
})
const loadScripts = async () => {
try {
@@ -454,20 +450,31 @@ const handleDeleteScript = async (script: Script) => {
}
const handleAddUser = (script: Script) => {
// 跳转到添加用户页面
router.push(`/scripts/${script.id}/users/add`)
// 根据条件判断跳转到 MAA 还是通用用户添加页面
if (script.type === 'MAA') {
router.push(`/scripts/${script.id}/users/add/maa`) // 跳转到 MAA 用户添加页面
} else {
router.push(`/scripts/${script.id}/users/add/general`) // 跳转到通用用户添加页面
}
}
const handleEditUser = (user: User) => {
// 从用户数据中找到对应的脚本
const script = scripts.value.find(s => s.users.some(u => u.id === user.id))
if (script) {
// 跳转到编辑用户页面
router.push(`/scripts/${script.id}/users/${user.id}/edit`)
// 判断是 MAA 用户还是通用用户
if (user.Info.Server) {
// 跳转到 MAA 用户编辑页面
router.push(`/scripts/${script.id}/users/${user.id}/edit/maa`)
} else {
// 跳转到通用用户编辑页面
router.push(`/scripts/${script.id}/users/${user.id}/edit/general`)
}
} else {
message.error('找不到对应的脚本')
}
}
const handleDeleteUser = async (user: User) => {
// 从用户数据中找到对应的脚本
const script = scripts.value.find(s => s.users.some(u => u.id === user.id))

File diff suppressed because it is too large Load Diff

View File

@@ -2478,6 +2478,22 @@ __metadata:
languageName: node
linkType: hard
"electron-updater@npm:6.6.2":
version: 6.6.2
resolution: "electron-updater@npm:6.6.2"
dependencies:
builder-util-runtime: "npm:9.3.1"
fs-extra: "npm:^10.1.0"
js-yaml: "npm:^4.1.0"
lazy-val: "npm:^1.0.5"
lodash.escaperegexp: "npm:^4.1.2"
lodash.isequal: "npm:^4.5.0"
semver: "npm:^7.6.3"
tiny-typed-emitter: "npm:^2.1.0"
checksum: 10c0/2b9ae5583b95f6772c4a2515ddba7ba52b65460ab81f09ae4f0b97c7e3d7b7e3d9426775eb9a53d3193bd4c3d5466bf30827c1a6ee75e4aca739c647f6ac46ff
languageName: node
linkType: hard
"electron@npm:^37.2.5":
version: 37.4.0
resolution: "electron@npm:37.4.0"
@@ -3075,6 +3091,7 @@ __metadata:
electron: "npm:^37.2.5"
electron-builder: "npm:^26.0.12"
electron-log: "npm:^5.4.3"
electron-updater: "npm:6.6.2"
eslint: "npm:^9.32.0"
eslint-config-prettier: "npm:^10.1.8"
eslint-plugin-prettier: "npm:^5.5.3"
@@ -3872,6 +3889,20 @@ __metadata:
languageName: node
linkType: hard
"lodash.escaperegexp@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.escaperegexp@npm:4.1.2"
checksum: 10c0/484ad4067fa9119bb0f7c19a36ab143d0173a081314993fe977bd00cf2a3c6a487ce417a10f6bac598d968364f992153315f0dbe25c9e38e3eb7581dd333e087
languageName: node
linkType: hard
"lodash.isequal@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.isequal@npm:4.5.0"
checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f
languageName: node
linkType: hard
"lodash.merge@npm:^4.6.2":
version: 4.6.2
resolution: "lodash.merge@npm:4.6.2"
@@ -5374,6 +5405,13 @@ __metadata:
languageName: node
linkType: hard
"tiny-typed-emitter@npm:^2.1.0":
version: 2.1.0
resolution: "tiny-typed-emitter@npm:2.1.0"
checksum: 10c0/522bed4c579ee7ee16548540cb693a3d098b137496110f5a74bff970b54187e6b7343a359b703e33f77c5b4b90ec6cebc0d0ec3dbdf1bd418723c5c3ce36d8a2
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14":
version: 0.2.14
resolution: "tinyglobby@npm:0.2.14"

13
main.py
View File

@@ -81,19 +81,26 @@ def main():
await Config.init_config()
await Config.get_stage(if_start=True)
await Config.clean_old_history()
main_timer = asyncio.create_task(MainTimer.second_task())
second_timer = asyncio.create_task(MainTimer.second_task())
hour_timer = asyncio.create_task(MainTimer.hour_task())
await System.set_Sleep()
await System.set_SelfStart()
yield
await TaskManager.stop_task("ALL")
main_timer.cancel()
second_timer.cancel()
hour_timer.cancel()
try:
await main_timer
await second_timer
await hour_timer
except asyncio.CancelledError:
logger.info("主业务定时器已关闭")
from app.services import Matomo
await Matomo.close()
logger.info("AUTO_MAA 后端程序关闭")
from fastapi.middleware.cors import CORSMiddleware

View File

@@ -3,12 +3,15 @@ name = "AUTO_MAA"
version = "4.0.0.1"
description = "AUTO_MAA~"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.12"
dependencies = [
"loguru==0.7.3",
"fastapi==0.116.1",
"pydantic==2.11.7",
"uvicorn==0.35.0",
"websockets==15.0.1",
"aiofiles==24.1.0",
"aiohttp==3.12.15",
"plyer==2.1.0",
"psutil==7.0.0",
"jinja2==3.1.6",

View File

@@ -4,6 +4,7 @@ pydantic==2.11.7
uvicorn==0.35.0
websockets==15.0.1
aiofiles==24.1.0
aiohttp==3.12.15
plyer==2.1.0
psutil==7.0.0
jinja2==3.1.6