Merge branch 'feature/refactor' into z
This commit is contained in:
@@ -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底部内边距) -->
|
||||
<a-menu
|
||||
mode="inline"
|
||||
:inline-collapsed="collapsed"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
:items="mainMenuItems"
|
||||
@click="onMenuClick"
|
||||
/>
|
||||
<a-menu
|
||||
mode="inline"
|
||||
: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
|
||||
showNotifications?: boolean
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const connections = ref<Map<string, WebSocket>>(new Map())
|
||||
const statuses = ref<Map<string, WebSocketStatus>>(new Map())
|
||||
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
|
||||
// 兼容旧版 connect(config) 接口
|
||||
export interface WebSocketConfig {
|
||||
taskId: string
|
||||
mode?: string
|
||||
showNotifications?: boolean
|
||||
onProgress?: (data: ProgressMessage) => void
|
||||
onResult?: (data: ResultMessage) => void
|
||||
onError?: (err: ErrorMessage | string) => void
|
||||
onNotify?: (n: NotifyMessage) => void
|
||||
onMessage?: (raw: WebSocketBaseMessage) => void
|
||||
onStatusChange?: (status: WebSocketStatus) => void
|
||||
}
|
||||
|
||||
// 心跳检测
|
||||
const heartbeat = (ws: WebSocket) => {
|
||||
const pingMessage = {
|
||||
type: 'Ping',
|
||||
data: {}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
const wsWarn = (message: string, ...args: any[]) => {
|
||||
if (!WS_DEV) return
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0]
|
||||
console.warn(`[WS ${timestamp}] ${message}`, ...args)
|
||||
}
|
||||
|
||||
const wsError = (message: string, ...args: any[]) => {
|
||||
if (!WS_DEV) return
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0]
|
||||
console.error(`[WS ${timestamp}] ${message}`, ...args)
|
||||
}
|
||||
|
||||
// 全局存储接口 - 移除销毁相关字段
|
||||
interface GlobalWSStorage {
|
||||
wsRef: WebSocket | null
|
||||
status: Ref<WebSocketStatus>
|
||||
subscribers: Ref<Map<string, WebSocketSubscriber>>
|
||||
heartbeatTimer?: number
|
||||
isConnecting: boolean
|
||||
lastPingTime: number
|
||||
connectionId: string
|
||||
moduleLoadCount: number
|
||||
createdAt: number
|
||||
hasEverConnected: boolean
|
||||
reconnectAttempts: number // 新增:重连尝试次数
|
||||
}
|
||||
|
||||
const WS_STORAGE_KEY = Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')
|
||||
|
||||
// 初始化全局存储
|
||||
const initGlobalStorage = (): GlobalWSStorage => {
|
||||
return {
|
||||
wsRef: null,
|
||||
status: ref<WebSocketStatus>('已断开'),
|
||||
subscribers: ref(new Map<string, WebSocketSubscriber>()),
|
||||
heartbeatTimer: undefined,
|
||||
isConnecting: false,
|
||||
lastPingTime: 0,
|
||||
connectionId: Math.random().toString(36).substr(2, 9),
|
||||
moduleLoadCount: 0,
|
||||
createdAt: Date.now(),
|
||||
hasEverConnected: false,
|
||||
reconnectAttempts: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全局存储
|
||||
const getGlobalStorage = (): GlobalWSStorage => {
|
||||
if (!(window as any)[WS_STORAGE_KEY]) {
|
||||
wsLog('首次初始化全局 WebSocket 存储 - 永久连接模式')
|
||||
;(window as any)[WS_STORAGE_KEY] = initGlobalStorage()
|
||||
}
|
||||
|
||||
// 建立WebSocket连接
|
||||
const connect = async (config: WebSocketConfig): Promise<string | null> => {
|
||||
try {
|
||||
const ws = new WebSocket(BASE_WS_URL)
|
||||
const taskId = config.taskId
|
||||
const storage = (window as any)[WS_STORAGE_KEY] as GlobalWSStorage
|
||||
storage.moduleLoadCount++
|
||||
|
||||
ws.onopen = () => {
|
||||
statuses.value.set(taskId, '已连接')
|
||||
config.onStatusChange?.('已连接')
|
||||
const uptime = ((Date.now() - storage.createdAt) / 1000).toFixed(1)
|
||||
wsLog(`模块加载第${storage.moduleLoadCount}次,存储运行时间: ${uptime}s,连接状态: ${storage.status.value}`)
|
||||
|
||||
// 启动心跳
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
heartbeat(ws)
|
||||
return storage
|
||||
}
|
||||
|
||||
// 设置全局状态
|
||||
const setGlobalStatus = (status: WebSocketStatus) => {
|
||||
const global = getGlobalStorage()
|
||||
const oldStatus = global.status.value
|
||||
global.status.value = status
|
||||
wsLog(`状态变更: ${oldStatus} -> ${status} [连接ID: ${global.connectionId}]`)
|
||||
|
||||
// 广播状态变化给所有订阅者(兼容 onStatusChange)
|
||||
global.subscribers.value.forEach(sub => {
|
||||
sub.onStatusChange?.(status)
|
||||
})
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopGlobalHeartbeat = () => {
|
||||
const global = getGlobalStorage()
|
||||
if (global.heartbeatTimer) {
|
||||
clearInterval(global.heartbeatTimer)
|
||||
global.heartbeatTimer = undefined
|
||||
wsLog('心跳检测已停止')
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startGlobalHeartbeat = (ws: WebSocket) => {
|
||||
const global = getGlobalStorage()
|
||||
stopGlobalHeartbeat()
|
||||
|
||||
wsLog('启动心跳检测,间隔15秒')
|
||||
global.heartbeatTimer = window.setInterval(() => {
|
||||
wsLog(`心跳检测 - WebSocket状态: ${ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)`)
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const pingTime = Date.now()
|
||||
global.lastPingTime = pingTime
|
||||
const pingData = { Ping: pingTime, connectionId: global.connectionId }
|
||||
|
||||
wsLog('发送心跳ping', pingData)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'Signal',
|
||||
data: pingData
|
||||
}))
|
||||
|
||||
// 心跳超时检测 - 但不主动断开连接
|
||||
setTimeout(() => {
|
||||
if (global.lastPingTime === pingTime && ws.readyState === WebSocket.OPEN) {
|
||||
wsWarn(`心跳超时 - 发送时间: ${pingTime}, 当前lastPingTime: ${global.lastPingTime}, 连接状态: ${ws.readyState}`)
|
||||
wsWarn('心跳超时但保持连接,等待网络层或服务端处理')
|
||||
}
|
||||
}, 30000)
|
||||
}, HEARTBEAT_TIMEOUT)
|
||||
|
||||
// 清理定时器
|
||||
ws.addEventListener('close', () => {
|
||||
clearInterval(heartbeatInterval)
|
||||
} catch (e) {
|
||||
wsError('心跳发送失败', e)
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
wsWarn('心跳发送失败,当前连接已不再是 OPEN 状态')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wsWarn(`心跳检测时连接状态异常: ${ws.readyState},但不主动断开连接`)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
const handleMessage = (raw: WebSocketBaseMessage) => {
|
||||
const global = getGlobalStorage()
|
||||
const msgType = String(raw.type)
|
||||
const id = raw.id
|
||||
|
||||
// 处理心跳响应
|
||||
if (msgType === 'Signal' && raw.data && raw.data.Pong) {
|
||||
const pongTime = raw.data.Pong
|
||||
const latency = Date.now() - pongTime
|
||||
wsLog(`收到心跳pong响应,延迟: ${latency}ms`)
|
||||
global.lastPingTime = 0 // 重置ping时间,表示收到了响应
|
||||
return
|
||||
}
|
||||
|
||||
// 记录其他类型的消息
|
||||
if (msgType !== 'Signal') {
|
||||
wsLog(`收到消息: type=${msgType}, id=${id || 'broadcast'}`)
|
||||
}
|
||||
|
||||
const dispatch = (sub: WebSocketSubscriber) => {
|
||||
if (msgType === 'Signal') return
|
||||
|
||||
// 兼容旧版:先调用通用 onMessage 回调
|
||||
sub.onMessage?.(raw)
|
||||
|
||||
if (msgType === 'Progress') return sub.onProgress?.(raw.data as ProgressMessage)
|
||||
if (msgType === 'Result') return sub.onResult?.(raw.data as ResultMessage)
|
||||
if (msgType === 'Error') {
|
||||
sub.onError?.(raw.data as ErrorMessage)
|
||||
if (!sub.onError && raw.data && (raw.data as ErrorMessage).msg) {
|
||||
message.error((raw.data as ErrorMessage).msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msgType === 'Notify') {
|
||||
sub.onNotify?.(raw.data as NotifyMessage)
|
||||
if (raw.data && (raw.data as NotifyMessage).title) {
|
||||
notification.info({
|
||||
message: (raw.data as NotifyMessage).title,
|
||||
description: (raw.data as NotifyMessage).content
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// 其他类型可扩展
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketBaseMessage
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket消息解析错误:', e)
|
||||
}
|
||||
}
|
||||
// 延迟重连函数
|
||||
const scheduleReconnect = (global: GlobalWSStorage) => {
|
||||
const delay = Math.min(1000 * Math.pow(2, global.reconnectAttempts), 30000) // 最大30秒
|
||||
wsLog(`计划在 ${delay}ms 后重连 (第${global.reconnectAttempts + 1}次尝试)`)
|
||||
|
||||
ws.onerror = (error) => {
|
||||
statuses.value.set(taskId, '连接错误')
|
||||
config.onStatusChange?.('连接错误')
|
||||
config.onError?.({ msg: 'WebSocket连接错误', code: 500 })
|
||||
}
|
||||
setTimeout(() => {
|
||||
global.reconnectAttempts++
|
||||
createGlobalWebSocket()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
statuses.value.set(taskId, '已断开')
|
||||
config.onStatusChange?.('已断开')
|
||||
connections.value.delete(taskId)
|
||||
}
|
||||
// 创建 WebSocket 连接 - 移除销毁检查,确保永不放弃连接
|
||||
const createGlobalWebSocket = (): WebSocket => {
|
||||
const global = getGlobalStorage()
|
||||
|
||||
connections.value.set(taskId, ws)
|
||||
statuses.value.set(taskId, '连接中')
|
||||
config.onStatusChange?.('连接中')
|
||||
// 检查现有连接状态
|
||||
if (global.wsRef) {
|
||||
wsLog(`检查现有连接状态: ${global.wsRef.readyState}`)
|
||||
|
||||
return taskId
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '连接失败'
|
||||
if (config.onError) {
|
||||
config.onError({ msg: errorMsg, code: 500 })
|
||||
}
|
||||
return null
|
||||
if (global.wsRef.readyState === WebSocket.OPEN) {
|
||||
wsLog('检测到已有活跃连接,直接返回现有连接')
|
||||
return global.wsRef
|
||||
}
|
||||
|
||||
if (global.wsRef.readyState === WebSocket.CONNECTING) {
|
||||
wsLog('检测到正在连接的 WebSocket,返回现有连接实例')
|
||||
return global.wsRef
|
||||
}
|
||||
|
||||
wsLog('现有连接状态为 CLOSING 或 CLOSED,将创建新连接')
|
||||
}
|
||||
|
||||
wsLog(`开始创建新的 WebSocket 连接到: ${BASE_WS_URL}`)
|
||||
const ws = new WebSocket(BASE_WS_URL)
|
||||
|
||||
// 记录连接创建
|
||||
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
|
||||
}
|
||||
}
|
||||
ws.send(JSON.stringify(message))
|
||||
}
|
||||
})
|
||||
const startTaskRaw = (params: any) => {
|
||||
wsLog('发送启动任务请求', params)
|
||||
sendRaw('StartTask', params)
|
||||
}
|
||||
|
||||
// 关闭连接
|
||||
// 移除 destroy 功能,确保连接永不断开
|
||||
const forceReconnect = () => {
|
||||
wsLog('手动触发重连')
|
||||
if (global.wsRef) {
|
||||
// 不关闭现有连接,直接尝试创建新连接
|
||||
global.isConnecting = false
|
||||
connectGlobalWebSocket()
|
||||
}
|
||||
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>
|
||||
</a-form>
|
||||
</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>
|
||||
</a-form>
|
||||
</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 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 validateStageName = (stageName: string): boolean => {
|
||||
if (!stageName || !stageName.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 简单的关卡名称验证,可以根据实际需要调整
|
||||
const stagePattern = /^[a-zA-Z0-9\-_\u4e00-\u9fa5]+$/
|
||||
return stagePattern.test(stageName.trim())
|
||||
}
|
||||
|
||||
// 选择基建配置文件
|
||||
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 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
|
||||
})
|
||||
|
||||
message.success(`自定义关卡 "${trimmedName}" 添加成功`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 导入基建配置
|
||||
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;
|
||||
@@ -1341,4 +1741,4 @@ onMounted(() => {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user