Merge branch 'feature/refactor' into z

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

View File

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

View File

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

View File

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

View File

@@ -1,78 +1,31 @@
<template>
<a-layout style="height: 100vh; overflow: hidden" class="app-layout-collapsed">
<a-layout style="height: 100vh; overflow: hidden">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:trigger="null"
:width="180"
:collapsed-width="60"
:width="SIDER_WIDTH"
:theme="isDark ? 'dark' : 'light'"
style="height: calc(100vh - 32px); position: fixed; left: 0; top: 32px; z-index: 100"
:style="{ height: 'calc(100vh - 32px)', position: 'fixed', left: '0', top: '32px', zIndex: 100, background: 'var(--app-sider-bg)', borderRight: '1px solid var(--app-sider-border-color)' }"
>
<div class="sider-content">
<!-- &lt;!&ndash; 折叠按钮 &ndash;&gt;-->
<!-- <div class="collapse-trigger" @click="toggleCollapse">-->
<!-- <MenuFoldOutlined v-if="!collapsed" />-->
<!-- <MenuUnfoldOutlined v-else />-->
<!-- </div>-->
<!-- 主菜单容器 -->
<div class="main-menu-container">
<a-menu
mode="inline"
:inline-collapsed="collapsed"
:theme="isDark ? 'dark' : 'light'"
class="main-menu"
v-model:selectedKeys="selectedKeys"
>
<template v-for="item in mainMenuItems" :key="item.path">
<a-menu-item @click="goTo(item.path)" :data-title="item.label">
<template #icon>
<component :is="item.icon" />
</template>
<span v-if="!collapsed" class="menu-text">{{ item.label }}</span>
</a-menu-item>
</template>
</a-menu>
</div>
<!-- 底部菜单带3px底部内边距 -->
<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>
<!-- 调整外框菜单项背景块水平居中文字与图标左对齐 -->

View File

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

View File

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

View File

@@ -1,216 +1,577 @@
import { ref, reactive, onUnmounted } from 'vue'
import { ref, type Ref } from 'vue'
import { message, notification } from 'ant-design-vue'
// WebSocket连接状态
// WebSocket 调试开关
const WS_DEV = true
const WS_VERSION = 'v2.5-PERSISTENT-' + Date.now()
console.log(`🚀 WebSocket 模块已加载: ${WS_VERSION} - 永久连接模式`)
// 基础配置
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
const HEARTBEAT_INTERVAL = 15000
const HEARTBEAT_TIMEOUT = 5000
// 类型定义
export type WebSocketStatus = '连接中' | '已连接' | '已断开' | '连接错误'
// WebSocket消息类型
export type WebSocketMessageType = 'Update' | 'Message' | 'Info' | 'Signal'
// WebSocket基础消息接口
export interface WebSocketBaseMessage {
type: WebSocketMessageType
data: any
id?: string
type: string
data?: any
}
// 进度消息接口
export interface ProgressMessage {
taskId: string
status: 'running' | 'waiting' | 'finished' | 'failed'
progress: number
msg: string
percent?: number
status?: string
msg?: string
}
// 结果消息接口
export interface ResultMessage {
taskId: string
status: 'success' | 'failed'
result: any
success?: boolean
result?: any
}
// 错误消息接口
export interface ErrorMessage {
msg: string
code: number
msg?: string
code?: number
}
// 通知消息接口
export interface NotifyMessage {
title: string
content: string
title?: string
content?: string
}
// WebSocket连接配置
export interface WebSocketConfig {
taskId: string
export interface WebSocketSubscriber {
id: string
onProgress?: (data: ProgressMessage) => void
onResult?: (data: ResultMessage) => void
onError?: (error: ErrorMessage) => void
onNotify?: (notify: NotifyMessage) => void
onError?: (err: ErrorMessage) => void
onNotify?: (n: NotifyMessage) => void
// 兼容旧版 API
onMessage?: (raw: WebSocketBaseMessage) => void
onStatusChange?: (status: WebSocketStatus) => void
showNotifications?: boolean
}
export function useWebSocket() {
const connections = ref<Map<string, WebSocket>>(new Map())
const statuses = ref<Map<string, WebSocketStatus>>(new Map())
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
// 兼容旧版 connect(config) 接口
export interface WebSocketConfig {
taskId: string
mode?: string
showNotifications?: boolean
onProgress?: (data: ProgressMessage) => void
onResult?: (data: ResultMessage) => void
onError?: (err: ErrorMessage | string) => void
onNotify?: (n: NotifyMessage) => void
onMessage?: (raw: WebSocketBaseMessage) => void
onStatusChange?: (status: WebSocketStatus) => void
}
// 心跳检测
const heartbeat = (ws: WebSocket) => {
const pingMessage = {
type: 'Ping',
data: {}
}
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
}
// 兼容旧版 APIconnect 重载
async function connect(): Promise<boolean>
async function connect(config: WebSocketConfig): Promise<string | null>
async function connect(config?: WebSocketConfig): Promise<boolean | string | null> {
if (!config) {
// 无参数调用:返回连接状态
return connectGlobalWebSocket()
}
// 有参数调用:建立订阅,复用现有连接
const ok = await connectGlobalWebSocket()
if (!ok) {
// 即使连接失败也要建立订阅,等待连接恢复
wsLog('连接暂时不可用,但仍建立订阅等待连接恢复')
}
// 先移除旧订阅避免重复
if (global.subscribers.value.has(config.taskId)) {
unsubscribe(config.taskId)
}
subscribe(config.taskId, {
onProgress: config.onProgress,
onResult: config.onResult,
onError: (e) => {
if (typeof config.onError === 'function') config.onError(e)
},
onNotify: (n) => {
config.onNotify?.(n)
if (config.showNotifications && n?.title) {
notification.info({ message: n.title, description: n.content })
}
},
onMessage: config.onMessage,
onStatusChange: config.onStatusChange
})
// 立即推送当前状态
config.onStatusChange?.(global.status.value)
// 可根据 mode 发送一个初始信号(可选)
if (config.mode) {
sendRaw('Mode', { mode: config.mode }, config.taskId)
}
return config.taskId
}
// 兼容旧版 APIdisconnect / disconnectAll - 只取消订阅,不断开连接
const disconnect = (taskId: string) => {
const ws = connections.value.get(taskId)
if (ws) {
ws.close()
connections.value.delete(taskId)
statuses.value.delete(taskId)
}
if (!taskId) return
unsubscribe(taskId)
wsLog(`兼容模式取消订阅: ${taskId}`)
}
// 关闭所有连接
const disconnectAll = () => {
connections.value.forEach((ws, taskId) => {
disconnect(taskId)
})
const ids = Array.from(global.subscribers.value.keys())
ids.forEach((id: string) => unsubscribe(id))
wsLog('已取消所有订阅 (disconnectAll)')
}
// 组件卸载时清理所有连接
onUnmounted(() => {
disconnectAll()
})
return {
// 兼容 API
connect,
disconnect,
disconnectAll,
startTask,
updateConfig,
statuses
// 原有 API & 工具
subscribe,
unsubscribe,
sendRaw,
startTaskRaw,
forceReconnect,
getConnectionInfo,
status: global.status,
subscribers: global.subscribers
}
}
}

View File

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

View File

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

View File

@@ -51,8 +51,13 @@
</div>
<div class="user-edit-content">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="user-form">
<a-card title="基本信息" class="form-card">
<a-card class="config-card">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="config-form">
<!-- 基本信息 -->
<div class="form-section">
<div class="section-header">
<h3>基本信息</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="userName" required>
@@ -69,6 +74,7 @@
placeholder="请输入用户名"
:disabled="loading"
size="large"
class="modern-input"
/>
</a-form-item>
</a-col>
@@ -128,11 +134,16 @@
placeholder="请输入备注信息"
:rows="4"
:disabled="loading"
class="modern-input"
/>
</a-form-item>
</a-card>
</div>
<a-card title="额外脚本" class="form-card">
<!-- 额外脚本 -->
<div class="form-section">
<div class="section-header">
<h3>额外脚本</h3>
</div>
<a-form-item name="scriptBeforeTask">
<template #label>
<a-tooltip title="在任务执行前运行自定义脚本">
@@ -217,9 +228,13 @@
</a-col>
</a-row>
</a-form-item>
</a-card>
</div>
<a-card title="通知配置" class="form-card">
<!-- 通知配置 -->
<div class="form-section">
<div class="section-header">
<h3>通知配置</h3>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">启用通知</span>
@@ -302,11 +317,13 @@
"
size="large"
style="width: 100%"
class="modern-input"
/>
</a-col>
</a-row>
</a-card>
</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;

View File

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

View File

@@ -52,8 +52,13 @@
</div>
<div class="user-edit-content">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="user-form">
<a-card title="基本信息" class="form-card">
<a-card class="config-card">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="config-form">
<!-- 基本信息 -->
<div class="form-section">
<div class="section-header">
<h3>基本信息</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="userName" required>
@@ -70,6 +75,7 @@
placeholder="请输入用户名"
:disabled="loading"
size="large"
class="modern-input"
/>
</a-form-item>
</a-col>
@@ -280,11 +286,16 @@
placeholder="请输入备注信息"
:rows="4"
:disabled="loading"
class="modern-input"
/>
</a-form-item>
</a-card>
</div>
<a-card title="关卡配置" class="form-card">
<!-- 关卡配置 -->
<div class="form-section">
<div class="section-header">
<h3>关卡配置</h3>
</div>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item name="mode">
@@ -395,7 +406,28 @@
v-model:value="formData.Info.Stage"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stageInputRef"
v-model:value="customStageName"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage"
/>
<a-button type="text" size="small" @click="addCustomStage">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -405,6 +437,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -428,7 +463,28 @@
v-model:value="formData.Info.Stage_1"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage1InputRef"
v-model:value="customStage1Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage1"
/>
<a-button type="text" size="small" @click="addCustomStage1">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -438,6 +494,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -459,7 +518,28 @@
v-model:value="formData.Info.Stage_2"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage2InputRef"
v-model:value="customStage2Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage2"
/>
<a-button type="text" size="small" @click="addCustomStage2">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -469,6 +549,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -490,7 +573,28 @@
v-model:value="formData.Info.Stage_3"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stage3InputRef"
v-model:value="customStage3Name"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStage3"
/>
<a-button type="text" size="small" @click="addCustomStage3">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -500,6 +604,9 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
@@ -519,7 +626,28 @@
v-model:value="formData.Info.Stage_Remain"
:disabled="loading"
size="large"
placeholder="选择或输入自定义关卡"
>
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px" size="small">
<a-input
ref="stageRemainInputRef"
v-model:value="customStageRemainName"
placeholder="输入自定义关卡,如: 11-8"
style="flex: 1"
size="small"
@keyup.enter="addCustomStageRemain"
/>
<a-button type="text" size="small" @click="addCustomStageRemain">
<template #icon>
<PlusOutlined />
</template>
添加关卡
</a-button>
</a-space>
</template>
<a-select-option v-for="option in stageOptions" :key="option.value" :value="option.value">
<template v-if="option.label.includes('|')">
<span>{{ option.label.split('|')[0] }}</span>
@@ -529,15 +657,22 @@
</template>
<template v-else>
{{ option.label }}
<a-tag v-if="option.isCustom" color="blue" size="small" style="margin-left: 8px;">
自定义
</a-tag>
</template>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="任务配置" class="form-card">
<!-- 任务配置 -->
<div class="form-section">
<div class="section-header">
<h3>任务配置</h3>
</div>
<a-row :gutter="24">
<a-col :span="6">
<a-form-item name="ifWakeUp" label="开始唤醒">
@@ -594,9 +729,21 @@
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="森空岛配置" class="form-card">
<!-- 森空岛配置 -->
<div class="form-section">
<div class="section-header">
<h3>森空岛配置</h3>
<a
href="https://doc.auto-mas.top/docs/advanced-features.html#%E8%8E%B7%E5%8F%96%E9%B9%B0%E8%A7%92%E7%BD%91%E7%BB%9C%E9%80%9A%E8%A1%8C%E8%AF%81%E7%99%BB%E5%BD%95%E5%87%AD%E8%AF%81"
target="_blank"
class="section-doc-link"
title="查看森空岛签到配置文档"
>
文档
</a>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">森空岛签到</span>
@@ -617,21 +764,15 @@
style="margin-top: 8px; width: 100%"
allow-clear
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
请在森空岛官网获取您的专属Token并粘贴到此处详细教程见
<a
href="https://doc.auto-mas.top/docs/advanced-features.html#%E8%8E%B7%E5%8F%96%E9%B9%B0%E8%A7%92%E7%BD%91%E7%BB%9C%E9%80%9A%E8%A1%8C%E8%AF%81%E7%99%BB%E5%BD%95%E5%87%AD%E8%AF%81"
target="_blank"
style="color: #409eff"
>获取鹰角网络通行证登录凭证</a
>
文档
</div>
</a-col>
</a-row>
</a-card>
</div>
<a-card title="通知配置" class="form-card">
<!-- 通知配置 -->
<div class="form-section">
<div class="section-header">
<h3>通知配置</h3>
</div>
<a-row :gutter="24" align="middle">
<a-col :span="6">
<span style="font-weight: 500">启用通知</span>
@@ -718,11 +859,13 @@
"
size="large"
style="width: 100%"
class="modern-input"
/>
</a-col>
</a-row>
</a-card>
</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

View File

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

File diff suppressed because it is too large Load Diff

View File

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