Merge branch 'feature/refactor' into z
This commit is contained in:
@@ -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:
|
||||
"""初始化配置管理"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""静默模式通过模拟老板键来隐藏模拟器窗口"""
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
125
app/services/matomo.py
Normal 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()
|
||||
@@ -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('<>:"/\\|?*')
|
||||
"""文件名非法字符集合"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
<!-- <!– 折叠按钮 –>-->
|
||||
<!-- <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>
|
||||
<!-- 调整:外框(菜单项背景块)水平居中,文字与图标左对齐 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 兼容旧版 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
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
// 心跳检测
|
||||
const heartbeat = (ws: WebSocket) => {
|
||||
const pingMessage = {
|
||||
type: 'Ping',
|
||||
data: {}
|
||||
}
|
||||
ws.send(JSON.stringify(pingMessage))
|
||||
// 日志工具
|
||||
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)
|
||||
}
|
||||
|
||||
// 建立WebSocket连接
|
||||
const connect = async (config: WebSocketConfig): Promise<string | null> => {
|
||||
try {
|
||||
const ws = new WebSocket(BASE_WS_URL)
|
||||
const taskId = config.taskId
|
||||
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)
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
statuses.value.set(taskId, '已连接')
|
||||
config.onStatusChange?.('已连接')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全局存储
|
||||
const getGlobalStorage = (): GlobalWSStorage => {
|
||||
if (!(window as any)[WS_STORAGE_KEY]) {
|
||||
wsLog('首次初始化全局 WebSocket 存储 - 永久连接模式')
|
||||
;(window as any)[WS_STORAGE_KEY] = initGlobalStorage()
|
||||
}
|
||||
|
||||
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 heartbeatInterval = setInterval(() => {
|
||||
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) {
|
||||
heartbeat(ws)
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
// 清理定时器
|
||||
ws.addEventListener('close', () => {
|
||||
clearInterval(heartbeatInterval)
|
||||
})
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketBaseMessage
|
||||
const pingTime = Date.now()
|
||||
global.lastPingTime = pingTime
|
||||
const pingData = { Ping: pingTime, connectionId: global.connectionId }
|
||||
|
||||
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
|
||||
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) {
|
||||
console.error('WebSocket消息解析错误:', 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))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
statuses.value.set(taskId, '连接错误')
|
||||
config.onStatusChange?.('连接错误')
|
||||
config.onError?.({ msg: 'WebSocket连接错误', code: 500 })
|
||||
// 延迟重连函数
|
||||
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)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
statuses.value.set(taskId, '已断开')
|
||||
config.onStatusChange?.('已断开')
|
||||
connections.value.delete(taskId)
|
||||
// 创建 WebSocket 连接 - 移除销毁检查,确保永不放弃连接
|
||||
const createGlobalWebSocket = (): WebSocket => {
|
||||
const global = getGlobalStorage()
|
||||
|
||||
// 检查现有连接状态
|
||||
if (global.wsRef) {
|
||||
wsLog(`检查现有连接状态: ${global.wsRef.readyState}`)
|
||||
|
||||
if (global.wsRef.readyState === WebSocket.OPEN) {
|
||||
wsLog('检测到已有活跃连接,直接返回现有连接')
|
||||
return global.wsRef
|
||||
}
|
||||
|
||||
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 })
|
||||
if (global.wsRef.readyState === WebSocket.CONNECTING) {
|
||||
wsLog('检测到正在连接的 WebSocket,返回现有连接实例')
|
||||
return global.wsRef
|
||||
}
|
||||
return null
|
||||
|
||||
wsLog('现有连接状态为 CLOSING 或 CLOSED,将创建新连接')
|
||||
}
|
||||
|
||||
wsLog(`开始创建新的 WebSocket 连接到: ${BASE_WS_URL}`)
|
||||
const ws = new WebSocket(BASE_WS_URL)
|
||||
|
||||
// 记录连接创建
|
||||
wsLog(`WebSocket 实例已创建 [连接ID: ${global.connectionId}]`)
|
||||
|
||||
ws.onopen = () => {
|
||||
wsLog(`WebSocket 连接已建立 [连接ID: ${global.connectionId}]`)
|
||||
global.isConnecting = false
|
||||
global.hasEverConnected = true
|
||||
global.reconnectAttempts = 0 // 重置重连计数
|
||||
setGlobalStatus('已连接')
|
||||
startGlobalHeartbeat(ws)
|
||||
|
||||
// 发送连接确认
|
||||
try {
|
||||
const connectData = { Connect: true, connectionId: global.connectionId }
|
||||
wsLog('发送连接确认信号', connectData)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'Signal',
|
||||
data: connectData
|
||||
}))
|
||||
} catch (e) {
|
||||
wsError('发送连接确认失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送任务开始指令
|
||||
const startTask = (taskId: string, params: any) => {
|
||||
const ws = connections.value.get(taskId)
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const raw = JSON.parse(ev.data) as WebSocketBaseMessage
|
||||
handleMessage(raw)
|
||||
} catch (e) {
|
||||
wsError('解析 WebSocket 消息失败', e, '原始数据:', ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 兼容旧版 API:connect 重载
|
||||
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
|
||||
}
|
||||
|
||||
// 兼容旧版 API:disconnect / 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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
4
frontend/src/types/electron.d.ts
vendored
4
frontend/src/types/electron.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
<!-- <!– 数据总览 –>-->
|
||||
<!-- <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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
const tab: SchedulerTab = { key: `tab-${tabCounter}`, title: `调度台${tabCounter}`, closable: true, runningTasks: [], activeTaskPanels: [], completionAction: 'none' }
|
||||
schedulerTabs.value.push(tab)
|
||||
activeSchedulerTab.value = tab.key
|
||||
}
|
||||
schedulerTabs.value.push(newTab)
|
||||
activeSchedulerTab.value = newTab.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
|
||||
// 颜色映射
|
||||
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())
|
||||
|
||||
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 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 {
|
||||
|
||||
@@ -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
@@ -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
13
main.py
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user