Merge branch 'feature/refactor' into v5.0.0-alpha.3

This commit is contained in:
2025-10-01 23:41:04 +08:00
20 changed files with 2623 additions and 566 deletions

View File

@@ -0,0 +1,427 @@
# useWebSocket API 参考文档
## 概述
`useWebSocket()` 组合式函数提供了完整的 WebSocket 通信接口,包含消息订阅、连接管理、状态监控等核心功能。
## 导出函数详解
### 1. subscribe() - 订阅消息
```typescript
const subscribe = (
filter: SubscriptionFilter,
handler: (message: WebSocketBaseMessage) => void
): string
```
#### 参数说明
**filter: SubscriptionFilter**
```typescript
interface SubscriptionFilter {
type?: string // 消息类型过滤器(可选)
id?: string // 消息ID过滤器可选
needCache?: boolean // 是否启用缓存回放(可选)
}
```
**handler: Function**
- 消息处理回调函数
- 参数: `message: WebSocketBaseMessage`
- 无返回值
#### 返回值
- `string`: 唯一的订阅ID用于后续取消订阅
#### 使用示例
```typescript
// 订阅所有消息
const allMsgSub = subscribe({}, (msg) => {
console.log('收到消息:', msg)
})
// 订阅特定类型消息
const taskSub = subscribe(
{ type: 'TaskUpdate', needCache: true },
(msg) => {
console.log('任务更新:', msg.data)
}
)
// 订阅特定ID消息
const specificSub = subscribe(
{ id: 'TaskManager' },
(msg) => {
console.log('任务管理器消息:', msg)
}
)
// 精确订阅同时匹配type和id
const preciseSub = subscribe(
{ type: 'Progress', id: 'task_001', needCache: true },
(msg) => {
console.log('特定任务进度:', msg.data)
}
)
```
#### 特殊功能
- **自动回放**: 如果 `needCache: true`,会立即回放匹配的历史消息
- **过滤优先级**: type + id > type > id > 全部
- **引用计数**: 多个订阅共享缓存,自动管理内存
---
### 2. unsubscribe() - 取消订阅
```typescript
const unsubscribe = (subscriptionId: string): void
```
#### 参数说明
**subscriptionId: string**
-`subscribe()` 返回的订阅ID
- 必须是有效的订阅ID
#### 使用示例
```typescript
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
// 取消订阅
unsubscribe(subId)
// Vue 组件中的最佳实践
import { onUnmounted } from 'vue'
const setupSubscription = () => {
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
onUnmounted(() => {
unsubscribe(subId)
})
}
```
#### 自动清理
- 自动减少缓存引用计数
- 引用计数为0时清理相关缓存
- 不会影响其他订阅者
---
### 3. sendRaw() - 发送消息
```typescript
const sendRaw = (type: string, data?: any, id?: string): void
```
#### 参数说明
**type: string** (必需)
- 消息类型标识
- 后端用于路由消息
**data: any** (可选)
- 消息负载数据
- 可以是任何可序列化的对象
**id: string** (可选)
- 消息标识符
- 用于消息跟踪和响应匹配
#### 使用示例
```typescript
// 发送简单消息
sendRaw('Hello')
// 发送带数据的消息
sendRaw('TaskStart', {
taskId: '12345',
config: { timeout: 30000 }
})
// 发送带ID的消息便于追踪响应
sendRaw('GetTaskStatus', { taskId: '12345' }, 'query_001')
// 发送控制信号
sendRaw('Signal', {
command: 'pause',
reason: '用户手动暂停'
}, 'TaskManager')
```
#### 发送条件
- 仅在 WebSocket 连接为 `OPEN` 状态时发送
- 连接异常时静默失败(不抛出异常)
- 自动JSON序列化
---
### 4. getConnectionInfo() - 获取连接信息
```typescript
const getConnectionInfo = (): ConnectionInfo
```
#### 返回值类型
```typescript
interface ConnectionInfo {
connectionId: string // 连接唯一标识
status: WebSocketStatus // 当前连接状态
subscriberCount: number // 当前订阅者数量
moduleLoadCount: number // 模块加载计数
wsReadyState: number | null // WebSocket原生状态
isConnecting: boolean // 是否正在连接
hasHeartbeat: boolean // 是否启用心跳
hasEverConnected: boolean // 是否曾经连接成功
reconnectAttempts: number // 重连尝试次数
isPersistentMode: boolean // 是否持久化模式
}
```
#### 使用示例
```typescript
const info = getConnectionInfo()
console.log('连接ID:', info.connectionId)
console.log('连接状态:', info.status)
console.log('订阅者数量:', info.subscriberCount)
// 检查连接是否健康
const isHealthy = info.status === '已连接' &&
info.hasHeartbeat &&
info.wsReadyState === WebSocket.OPEN
// 监控重连情况
if (info.reconnectAttempts > 0) {
console.log(`已重连 ${info.reconnectAttempts} 次`)
}
```
#### 调试用途
- 诊断连接问题
- 监控连接质量
- 统计使用情况
---
### 5. status - 连接状态
```typescript
const status: Ref<WebSocketStatus>
```
#### 状态类型
```typescript
type WebSocketStatus = '连接中' | '已连接' | '已断开' | '连接错误'
```
#### 状态说明
| 状态 | 描述 | 触发条件 |
|------|------|----------|
| `'连接中'` | 正在建立连接 | WebSocket.CONNECTING |
| `'已连接'` | 连接成功 | WebSocket.OPEN |
| `'已断开'` | 连接断开 | WebSocket.CLOSED |
| `'连接错误'` | 连接异常 | WebSocket.onerror |
#### 使用示例
```typescript
import { watch } from 'vue'
const { status } = useWebSocket()
// 监听状态变化
watch(status, (newStatus) => {
console.log('连接状态变化:', newStatus)
switch (newStatus) {
case '已连接':
console.log('✅ WebSocket 连接成功')
break
case '已断开':
console.log('❌ WebSocket 连接断开')
break
case '连接错误':
console.log('⚠️ WebSocket 连接错误')
break
case '连接中':
console.log('🔄 WebSocket 连接中...')
break
}
})
// 在模板中显示状态
// <div>连接状态: {{ status }}</div>
```
#### 响应式特性
- Vue 响应式 Ref 对象
- 自动更新 UI
- 可用于计算属性和监听器
---
### 6. backendStatus - 后端状态
```typescript
const backendStatus: Ref<BackendStatus>
```
#### 状态类型
```typescript
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
```
#### 状态说明
| 状态 | 描述 | 含义 |
|------|------|------|
| `'unknown'` | 未知状态 | 初始状态,尚未检测 |
| `'starting'` | 启动中 | 后端服务正在启动 |
| `'running'` | 运行中 | 后端服务正常运行 |
| `'stopped'` | 已停止 | 后端服务已停止 |
| `'error'` | 错误状态 | 后端服务异常 |
#### 使用示例
```typescript
const { backendStatus, restartBackend } = useWebSocket()
// 监听后端状态
watch(backendStatus, (newStatus) => {
console.log('后端状态:', newStatus)
switch (newStatus) {
case 'running':
console.log('✅ 后端服务运行正常')
break
case 'stopped':
console.log('⏹️ 后端服务已停止')
break
case 'error':
console.log('❌ 后端服务异常')
// 可以提示用户或自动重启
break
case 'starting':
console.log('🚀 后端服务启动中...')
break
}
})
// 根据状态显示不同UI
const statusColor = computed(() => {
switch (backendStatus.value) {
case 'running': return 'green'
case 'error': return 'red'
case 'starting': return 'orange'
default: return 'gray'
}
})
```
#### 自动管理
- 每3秒自动检测一次
- 异常时自动尝试重启最多3次
- 集成 Electron 进程管理
---
## 完整使用示例
```typescript
import { onMounted, onUnmounted, watch } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
export default {
setup() {
const {
subscribe,
unsubscribe,
sendRaw,
getConnectionInfo,
status,
backendStatus
} = useWebSocket()
let taskSubscription: string
let systemSubscription: string
onMounted(() => {
// 订阅任务消息
taskSubscription = subscribe(
{ type: 'TaskUpdate', needCache: true },
(message) => {
console.log('任务更新:', message.data)
}
)
// 订阅系统消息
systemSubscription = subscribe(
{ id: 'System' },
(message) => {
console.log('系统消息:', message)
}
)
// 发送初始化消息
sendRaw('ClientReady', {
timestamp: Date.now()
}, 'System')
})
onUnmounted(() => {
// 清理订阅
if (taskSubscription) unsubscribe(taskSubscription)
if (systemSubscription) unsubscribe(systemSubscription)
})
// 监听连接状态
watch([status, backendStatus], ([wsStatus, beStatus]) => {
console.log(`WS: ${wsStatus}, Backend: ${beStatus}`)
})
// 获取连接信息
const connectionInfo = getConnectionInfo()
return {
status,
backendStatus,
connectionInfo,
sendMessage: (type: string, data: any) => sendRaw(type, data)
}
}
}
```
## 最佳实践
### 1. 订阅管理
- 总是在组件卸载时取消订阅
- 使用 `needCache: true` 确保不丢失消息
- 避免重复订阅相同的消息类型
### 2. 错误处理
- 监听连接状态变化
- 根据后端状态调整UI显示
- 实现重连提示和手动重启
### 3. 性能优化
- 精确的过滤条件减少不必要的处理
- 合理使用缓存避免消息丢失
- 及时取消不需要的订阅
### 4. 调试技巧
- 使用 `getConnectionInfo()` 诊断问题
- 开发环境下查看控制台日志
- 监控订阅者数量避免内存泄漏

View File

@@ -0,0 +1,336 @@
## 核心架构设计
### 1. 全局持久化存储
```typescript
const WS_STORAGE_KEY = Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')
```
- 使用 `Symbol.for()` 确保全局唯一性
- 存储在 `window` 对象上,实现跨组件共享
- 避免多次实例化,确保连接唯一性
### 2. 状态管理结构
```typescript
interface GlobalWSStorage {
wsRef: WebSocket | null // WebSocket 实例
status: Ref<WebSocketStatus> // 连接状态
subscriptions: Ref<Map<string, WebSocketSubscription>> // 订阅管理
cacheMarkers: Ref<Map<string, CacheMarker>> // 缓存标记
cachedMessages: Ref<Array<CachedMessage>> // 消息缓存
// ... 其他状态
}
```
## 核心功能模块
### 1. 配置管理
```typescript
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
const HEARTBEAT_INTERVAL = 15000 // 心跳间隔
const HEARTBEAT_TIMEOUT = 5000 // 心跳超时
const BACKEND_CHECK_INTERVAL = 3000 // 后端检查间隔
const MAX_RESTART_ATTEMPTS = 3 // 最大重启尝试次数
const RESTART_DELAY = 2000 // 重启延迟
const MAX_QUEUE_SIZE = 50 // 最大队列大小
const MESSAGE_TTL = 60000 // 消息过期时间
```
**要点**:
- 所有时间配置使用毫秒为单位
- 可根据网络环境调整超时时间
- 队列大小限制防止内存泄漏
### 2. 消息订阅系统
#### 订阅过滤器
```typescript
interface SubscriptionFilter {
type?: string // 消息类型过滤
id?: string // 消息ID过滤
needCache?: boolean // 是否需要缓存
}
```
#### 订阅机制
```typescript
export const subscribe = (
filter: SubscriptionFilter,
handler: (message: WebSocketBaseMessage) => void
): string => {
// 1. 生成唯一订阅ID
// 2. 创建订阅记录
// 3. 添加缓存标记
// 4. 回放匹配的缓存消息
}
```
**要点**:
- 支持按 `type``id` 的组合过滤
- 自动回放缓存消息,确保不丢失历史数据
- 返回订阅ID用于后续取消订阅
### 3. 智能缓存系统
#### 缓存标记机制
```typescript
interface CacheMarker {
type?: string
id?: string
refCount: number // 引用计数
}
```
#### 缓存策略
- **引用计数**: 订阅时 +1取消订阅时 -1
- **自动清理**: 引用计数为 0 时删除标记
- **TTL机制**: 消息超过 60 秒自动过期
- **大小限制**: 每个队列最多保留 50 条消息
### 4. 心跳检测机制
```typescript
const startGlobalHeartbeat = (ws: WebSocket) => {
global.heartbeatTimer = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
const pingTime = Date.now()
global.lastPingTime = pingTime
ws.send(JSON.stringify({
type: 'Signal',
data: { Ping: pingTime, connectionId: global.connectionId }
}))
}
}, HEARTBEAT_INTERVAL)
}
```
### 5. 后端服务监控
#### 状态检测
```typescript
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
```
#### 自动重启逻辑
```typescript
const restartBackend = async (): Promise<boolean> => {
// 1. 防重入检查
// 2. 递增重启计数
// 3. 调用 Electron API 启动后端
// 4. 更新状态
}
```
### 6. 连接控制机制
#### 连接权限控制
```typescript
const allowedConnectionReasons = ['后端启动后连接', '后端重启后重连']
const checkConnectionPermission = () => getGlobalStorage().allowNewConnection
```
#### 连接锁机制
```typescript
let isGlobalConnectingLock = false
const acquireConnectionLock = () => {
if (isGlobalConnectingLock) return false
isGlobalConnectingLock = true
return true
}
```
**AI 开发要点**:
- 防止并发连接导致的竞态条件
- 只允许特定原因的连接请求
- 确保全局唯一连接
## 消息流处理
### 1. 消息匹配算法
```typescript
const messageMatchesFilter = (message: WebSocketBaseMessage, filter: SubscriptionFilter): boolean => {
// 如果都不指定,匹配所有消息
if (!filter.type && !filter.id) return true
// 如果只指定type
if (filter.type && !filter.id) return message.type === filter.type
// 如果只指定id
if (!filter.type && filter.id) return message.id === filter.id
// 如果同时指定type和id必须都匹配
return message.type === filter.type && message.id === filter.id
}
```
### 2. 消息分发流程
```
WebSocket 接收消息
JSON 解析
遍历所有订阅者
匹配过滤条件
调用处理器函数
检查是否需要缓存
添加到缓存队列
```
## 外部接口设计
### 1. 主要导出函数
```typescript
export function useWebSocket() {
return {
subscribe, // 订阅消息
unsubscribe, // 取消订阅
sendRaw, // 发送消息
getConnectionInfo, // 获取连接信息
status, // 连接状态
backendStatus, // 后端状态
restartBackend, // 重启后端
getBackendStatus, // 获取后端状态
}
}
```
### 2. 特殊接口
```typescript
export const connectAfterBackendStart = async (): Promise<boolean>
```
- 后端启动后的连接入口
- 启动后端监控
- 设置连接权限
## 错误处理策略
### 1. 连接错误处理
- WebSocket 连接失败时设置状态为 '连接错误'
- 通过后端监控检测服务状态
- 自动重启后端服务
### 2. 消息处理错误
- 订阅处理器异常时记录警告但不中断其他订阅者
- JSON 解析失败时静默忽略
- 发送消息失败时静默处理
### 3. 后端故障处理
```typescript
const handleBackendFailure = async () => {
if (global.backendRestartAttempts >= MAX_RESTART_ATTEMPTS) {
// 显示错误对话框,提示重启应用
Modal.error({
title: '后端服务异常',
content: '后端服务多次重启失败,请重启整个应用程序。'
})
return
}
// 自动重启逻辑
}
```
## 调试和监控
### 1. 调试模式
```typescript
const DEBUG = process.env.NODE_ENV === 'development'
const log = (...args: any[]) => {
if (DEBUG) console.log('[WebSocket]', ...args)
}
```
### 2. 连接信息监控
```typescript
const getConnectionInfo = () => ({
connectionId: global.connectionId,
status: global.status.value,
subscriberCount: global.subscriptions.value.size,
moduleLoadCount: global.moduleLoadCount,
wsReadyState: global.wsRef ? global.wsRef.readyState : null,
isConnecting: global.isConnecting,
hasHeartbeat: !!global.heartbeatTimer,
hasEverConnected: global.hasEverConnected,
reconnectAttempts: global.reconnectAttempts,
isPersistentMode: true,
})
```
## AI 开发建议
### 1. 使用模式
```typescript
// 在 Vue 组件中使用
const { subscribe, unsubscribe, sendRaw, status } = useWebSocket()
// 订阅特定类型消息
const subId = subscribe(
{ type: 'TaskUpdate', needCache: true },
(message) => {
console.log('收到任务更新:', message.data)
}
)
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribe(subId)
})
```
### 2. 扩展建议
- 添加消息重试机制
- 实现消息优先级队列
- 支持消息压缩
- 添加连接质量监控
### 3. 性能优化点
- 使用 `Object.freeze()` 冻结配置对象
- 考虑使用 Web Worker 处理大量消息
- 实现消息批处理机制
- 添加消息去重功能
### 4. 安全考虑
- 验证消息来源
- 实现消息签名机制
- 添加连接认证
- 防止消息注入攻击
## 依赖关系
### 1. 外部依赖
- `vue`: 响应式系统和组合式API
- `ant-design-vue`: UI组件库Modal
- `schedulerHandlers`: 默认消息处理器
### 2. 运行时依赖
- `window.electronAPI`: Electron主进程通信
- WebSocket API: 浏览器原生支持
## 总结
这个 WebSocket 组合式函数是一个功能完整、设计精良的实时通信解决方案。它不仅解决了基本的 WebSocket 连接问题,还提供了高级功能如智能缓存、自动重连、后端监控等。
**核心优势**:
1. 全局持久化连接,避免重复建立
2. 智能订阅系统,支持精确过滤
3. 自动缓存回放,确保数据完整性
4. 完善的错误处理和自动恢复
5. 详细的调试和监控信息
**适用场景**:
- 实时数据展示
- 任务状态监控
- 系统通知推送
- 双向通信应用

View File

@@ -44,7 +44,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
loadConfig: () => ipcRenderer.invoke('load-config'),
resetConfig: () => ipcRenderer.invoke('reset-config'),
// 托盘设置实时更新
updateTraySettings: (uiSettings: any) => ipcRenderer.invoke('update-tray-settings', uiSettings),
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getLogs: (lines?: number, fileName?: string) => ipcRenderer.invoke('get-logs', lines, fileName),
clearLogs: (fileName?: string) => ipcRenderer.invoke('clear-logs', fileName),
cleanOldLogs: (daysToKeep?: number) => ipcRenderer.invoke('clean-old-logs', daysToKeep),
// 保留原有方法以兼容现有代码
saveLogsToFile: (logs: string) => ipcRenderer.invoke('save-logs-to-file', logs),
loadLogsFromFile: () => ipcRenderer.invoke('load-logs-from-file'),

View File

@@ -9,6 +9,8 @@ import TitleBar from './components/TitleBar.vue'
import UpdateModal from './components/UpdateModal.vue'
import DevDebugPanel from './components/DevDebugPanel.vue'
import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue'
import WebSocketMessageListener from './components/WebSocketMessageListener.vue'
import WebSocketDebugPanel from './components/WebSocketDebugPanel.vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { logger } from '@/utils/logger'
@@ -53,6 +55,12 @@ onMounted(() => {
<!-- 全局电源倒计时弹窗 -->
<GlobalPowerCountdown />
<!-- WebSocket 消息监听组件 -->
<WebSocketMessageListener />
<!-- WebSocket 调试面板 (仅开发环境) -->
<WebSocketDebugPanel v-if="$route.query.debug === 'true'" />
</ConfigProvider>
</template>

View File

@@ -102,7 +102,7 @@
ghost-class="user-ghost"
chosen-class="user-chosen"
drag-class="user-drag"
@end="evt => onUserDragEnd(evt, script)"
@end="(evt: any) => onUserDragEnd(evt, script)"
class="users-list"
>
<template #item="{ element: user }">
@@ -326,7 +326,7 @@ import { message } from 'ant-design-vue'
interface Props {
scripts: Script[]
activeConnections: Map<string, string>
activeConnections: Map<string, { subscriptionId: string; websocketId: string }>
}
interface Emits {
@@ -464,7 +464,7 @@ const onScriptDragEnd = async () => {
}
// 处理用户拖拽结束
const onUserDragEnd = async (evt: any, script: Script) => {
const onUserDragEnd = async (_evt: any, script: Script) => {
try {
const userIds = script.users?.map(user => user.id) || []
await Service.reorderUserApiScriptsUserOrderPost({

View File

@@ -0,0 +1,275 @@
<template>
<div class="websocket-debug">
<h3>WebSocket 调试面板</h3>
<div class="debug-section">
<h4>连接状态</h4>
<p>状态: {{ wsStatus }}</p>
<p>订阅数量: {{ subscriberCount }}</p>
</div>
<div class="debug-section">
<h4>测试消息</h4>
<button @click="testQuestionMessage" class="test-btn">测试 Question 消息</button>
<button @click="testNormalMessage" class="test-btn">测试普通消息</button>
<button @click="testMalformedMessage" class="test-btn">测试格式错误消息</button>
</div>
<div class="debug-section">
<h4>最近接收的消息</h4>
<div class="message-log">
<div v-for="(msg, index) in recentMessages" :key="index" class="message-item">
<div class="message-timestamp">{{ msg.timestamp }}</div>
<div class="message-content">{{ JSON.stringify(msg.data, null, 2) }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useWebSocket, type WebSocketBaseMessage } from '@/composables/useWebSocket'
import { logger } from '@/utils/logger'
const { subscribe, unsubscribe, sendRaw, getConnectionInfo } = useWebSocket()
// 状态
const wsStatus = ref('')
const subscriberCount = ref(0)
const recentMessages = ref<Array<{timestamp: string, data: any}>>([])
// 订阅ID
let debugSubscriptionId: string
// 更新状态
const updateStatus = () => {
const connInfo = getConnectionInfo()
wsStatus.value = connInfo.status
subscriberCount.value = connInfo.subscriberCount
}
// 处理接收到的消息
const handleDebugMessage = (message: WebSocketBaseMessage) => {
logger.info('[WebSocket调试] 收到消息:', message)
// 添加到最近消息列表
recentMessages.value.unshift({
timestamp: new Date().toLocaleTimeString(),
data: message
})
// 保持最近10条消息
if (recentMessages.value.length > 10) {
recentMessages.value = recentMessages.value.slice(0, 10)
}
updateStatus()
}
// 测试发送Question消息
const testQuestionMessage = () => {
const message = {
id: "debug_test_" + Date.now(),
type: "message",
data: {
type: "Question",
message_id: "q_" + Date.now(),
title: "调试测试问题",
message: "这是一个来自调试面板的测试问题,请选择是否继续?"
}
}
logger.info('[WebSocket调试] 发送Question消息:', message)
sendRaw('message', message.data)
}
// 测试发送普通消息
const testNormalMessage = () => {
const message = {
id: "debug_normal_" + Date.now(),
type: "message",
data: {
action: "test_action",
status: "running",
content: "这是一个来自调试面板的普通消息"
}
}
logger.info('[WebSocket调试] 发送普通消息:', message)
sendRaw('message', message.data)
}
// 测试发送格式错误的消息
const testMalformedMessage = () => {
const message = {
id: "debug_malformed_" + Date.now(),
type: "message",
data: {
type: "Question",
// 故意缺少 message_id
title: "格式错误的问题",
message: "这个消息缺少message_id字段测试容错处理"
}
}
logger.info('[WebSocket调试] 发送格式错误消息:', message)
sendRaw('message', message.data)
}
// 组件挂载
onMounted(() => {
logger.info('[WebSocket调试] 调试面板挂载')
// 订阅所有类型的消息进行调试
debugSubscriptionId = subscribe({}, handleDebugMessage)
updateStatus()
// 定期更新状态
const statusTimer = setInterval(updateStatus, 2000)
// 组件卸载时清理定时器
onUnmounted(() => {
clearInterval(statusTimer)
})
})
// 组件卸载
onUnmounted(() => {
logger.info('[WebSocket调试] 调试面板卸载')
if (debugSubscriptionId) {
unsubscribe(debugSubscriptionId)
}
})
</script>
<style scoped>
.websocket-debug {
position: fixed;
top: 80px;
right: 20px;
width: 350px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
font-size: 12px;
max-height: 600px;
overflow-y: auto;
}
.debug-section {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.debug-section:last-child {
border-bottom: none;
}
h3 {
margin: 0 0 15px 0;
font-size: 14px;
color: #333;
}
h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: #666;
font-weight: 600;
}
p {
margin: 4px 0;
color: #555;
}
.test-btn {
background: #007bff;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
margin: 2px;
}
.test-btn:hover {
background: #0056b3;
}
.message-log {
max-height: 200px;
overflow-y: auto;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
}
.message-item {
margin-bottom: 8px;
padding: 6px;
background: white;
border-radius: 3px;
border-left: 3px solid #007bff;
}
.message-item:last-child {
margin-bottom: 0;
}
.message-timestamp {
font-size: 10px;
color: #666;
margin-bottom: 4px;
}
.message-content {
font-family: monospace;
font-size: 10px;
color: #333;
white-space: pre-wrap;
word-break: break-word;
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.websocket-debug {
background: #2a2a2a;
border-color: #444;
color: white;
}
.debug-section {
border-bottom-color: #444;
}
h3, h4 {
color: #e8e8e8;
}
p {
color: #ccc;
}
.message-log {
background: #333;
border-color: #555;
}
.message-item {
background: #444;
}
.message-content {
color: #e8e8e8;
}
}
</style>

View File

@@ -0,0 +1,359 @@
<template>
<div style="display: none">
<!-- 这是一个隐藏的监听组件不需要UI -->
</div>
<!-- 简单的自定义对话框 -->
<div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ dialogData.title }}</h3>
</div>
<div class="dialog-content">
<p>{{ dialogData.message }}</p>
</div>
<div class="dialog-actions">
<button
v-for="(option, index) in dialogData.options"
:key="index"
class="dialog-button"
@click="handleChoice(index)"
>
{{ option }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useWebSocket, type WebSocketBaseMessage } from '@/composables/useWebSocket'
import { logger } from '@/utils/logger'
// WebSocket hook
const { subscribe, unsubscribe, sendRaw } = useWebSocket()
// 对话框状态
const showDialog = ref(false)
const dialogData = ref({
title: '',
message: '',
options: ['确定', '取消'],
messageId: ''
})
// 存储订阅ID用于取消订阅
let subscriptionId: string
// 发送用户选择结果到后端
const sendResponse = (messageId: string, choice: boolean) => {
const response = {
message_id: messageId,
choice: choice,
}
logger.info('[WebSocket消息监听器] 发送用户选择结果:', response)
// 发送响应消息到后端
sendRaw('Response', response)
}
// 处理用户选择
const handleChoice = (choiceIndex: number) => {
const choice = choiceIndex === 0 // 第一个选项为true其他为false
sendResponse(dialogData.value.messageId, choice)
showDialog.value = false
}
// 显示问题对话框
const showQuestion = (questionData: any) => {
const title = questionData.title || '操作提示'
const message = questionData.message || ''
const options = questionData.options || ['确定', '取消']
const messageId = questionData.message_id || 'fallback_' + Date.now()
logger.info('[WebSocket消息监听器] 显示自定义对话框:', questionData)
// 设置对话框数据
dialogData.value = {
title,
message,
options,
messageId
}
showDialog.value = true
// 在下一个tick自动聚焦第一个按钮
nextTick(() => {
const firstButton = document.querySelector('.dialog-button:first-child') as HTMLButtonElement
if (firstButton) {
firstButton.focus()
}
})
}
// 消息处理函数
const handleMessage = (message: WebSocketBaseMessage) => {
try {
logger.info('[WebSocket消息监听器] 收到Message类型消息:', message)
logger.info('[WebSocket消息监听器] 消息详情 - type:', message.type, 'id:', message.id, 'data:', message.data)
// 解析消息数据
if (message.data) {
console.log('[WebSocket消息监听器] 消息数据:', message.data)
// 根据具体的消息内容进行处理
if (typeof message.data === 'object') {
// 处理对象类型的数据
handleObjectMessage(message.data)
} else if (typeof message.data === 'string') {
// 处理字符串类型的数据
handleStringMessage(message.data)
} else {
// 处理其他类型的数据
handleOtherMessage(message.data)
}
} else {
logger.warn('[WebSocket消息监听器] 收到空数据的消息')
}
// 这里可以添加具体的业务逻辑
// 例如:更新状态、触发事件、显示通知等
} catch (error) {
logger.error('[WebSocket消息监听器] 处理消息时发生错误:', error)
}
}
// 处理对象类型的消息
const handleObjectMessage = (data: any) => {
logger.info('[WebSocket消息监听器] 处理对象消息:', data)
// 检查是否为Question类型的消息
logger.info('[WebSocket消息监听器] 检查消息类型 - data.type:', data.type, 'data.message_id:', data.message_id)
if (data.type === 'Question') {
logger.info('[WebSocket消息监听器] 发现Question类型消息')
if (data.message_id) {
logger.info('[WebSocket消息监听器] message_id存在显示选择弹窗')
showQuestion(data)
return
} else {
logger.warn('[WebSocket消息监听器] Question消息缺少message_id字段:', data)
// 即使缺少message_id也尝试显示弹窗使用当前时间戳作为ID
const fallbackId = 'fallback_' + Date.now()
logger.info('[WebSocket消息监听器] 使用备用ID显示弹窗:', fallbackId)
showQuestion({
...data,
message_id: fallbackId
})
return
}
}
// 根据对象的属性进行不同处理
if (data.action) {
logger.info('[WebSocket消息监听器] 消息动作:', data.action)
}
if (data.status) {
logger.info('[WebSocket消息监听器] 消息状态:', data.status)
}
if (data.content) {
logger.info('[WebSocket消息监听器] 消息内容:', data.content)
}
// 可以根据具体需求添加更多处理逻辑
}
// 处理字符串类型的消息
const handleStringMessage = (data: string) => {
logger.info('[WebSocket消息监听器] 处理字符串消息:', data)
try {
// 尝试解析JSON字符串
const parsed = JSON.parse(data)
logger.info('[WebSocket消息监听器] 解析后的JSON:', parsed)
handleObjectMessage(parsed)
} catch (error) {
// 不是JSON格式作为普通字符串处理
logger.info('[WebSocket消息监听器] 普通字符串消息:', data)
}
}
// 处理其他类型的消息
const handleOtherMessage = (data: any) => {
logger.info('[WebSocket消息监听器] 处理其他类型消息:', typeof data, data)
}
// 组件挂载时订阅消息
onMounted(() => {
logger.info('[WebSocket消息监听器~~] 组件挂载开始监听Message类型的消息')
// 使用新的 subscribe API订阅 Message 类型的消息注意大写M
subscriptionId = subscribe({ type: 'Message' }, handleMessage)
logger.info('[WebSocket消息监听器~~] 订阅ID:', subscriptionId)
logger.info('[WebSocket消息监听器~~] 订阅过滤器:', { type: 'Message' })
})
// 组件卸载时取消订阅
onUnmounted(() => {
logger.info('[WebSocket消息监听器~~] 组件卸载停止监听Message类型的消息')
// 使用新的 unsubscribe API
if (subscriptionId) {
unsubscribe(subscriptionId)
}
})
</script>
<style scoped>
/* 对话框遮罩层 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
/* 对话框容器 */
.dialog-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 300px;
max-width: 500px;
width: 90%;
animation: dialogAppear 0.2s ease-out;
}
/* 对话框头部 */
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
/* 对话框内容 */
.dialog-content {
padding: 20px;
}
.dialog-content p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #666;
word-break: break-word;
}
/* 按钮区域 */
.dialog-actions {
padding: 12px 20px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 按钮样式 */
.dialog-button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #333;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
min-width: 60px;
}
.dialog-button:hover {
background: #f5f5f5;
border-color: #999;
}
.dialog-button:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
.dialog-button:first-child {
background: #007bff;
color: white;
border-color: #007bff;
}
.dialog-button:first-child:hover {
background: #0056b3;
border-color: #0056b3;
}
/* 出现动画 */
@keyframes dialogAppear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.dialog-container {
background: #2d2d2d;
color: #fff;
}
.dialog-header {
border-bottom-color: #444;
}
.dialog-header h3 {
color: #fff;
}
.dialog-content p {
color: #ccc;
}
.dialog-button {
background: #444;
color: #fff;
border-color: #555;
}
.dialog-button:hover {
background: #555;
border-color: #666;
}
.dialog-button:first-child {
background: #0d6efd;
border-color: #0d6efd;
}
.dialog-button:first-child:hover {
background: #0b5ed7;
border-color: #0b5ed7;
}
}
</style>

View File

@@ -0,0 +1 @@
.log-entry.error .log-message {

View File

@@ -0,0 +1,654 @@
<template>
<div class="test-page">
<h3 class="page-title">🔧 消息弹窗测试</h3>
<div class="test-section">
<h4>测试消息弹窗</h4>
<div class="test-controls">
<button class="test-btn primary" @click="triggerQuestionModal" :disabled="isTesting">
{{ isTesting ? '测试中...' : '触发Question弹窗' }}
</button>
<button class="test-btn secondary" @click="triggerCustomModal" :disabled="isTesting">
自定义消息测试
</button>
<button class="test-btn warning" @click="directTriggerModal" :disabled="isTesting">
直接触发测试
</button>
</div>
<div class="test-info">
<p>点击按钮测试全屏消息选择弹窗功能</p>
<p>最后响应: {{ lastResponse || '暂无' }}</p>
<p>连接状态: <span :class="connectionStatusClass">{{ connectionStatus }}</span></p>
</div>
</div>
<div class="test-section">
<h4>自定义测试消息</h4>
<div class="custom-form">
<div class="form-group">
<label>标题:</label>
<input
v-model="customMessage.title"
type="text"
placeholder="请输入弹窗标题"
class="form-input"
/>
</div>
<div class="form-group">
<label>消息内容:</label>
<textarea
v-model="customMessage.message"
placeholder="请输入消息内容"
class="form-textarea"
rows="3"
></textarea>
</div>
<button
class="test-btn primary"
@click="sendCustomMessage"
:disabled="!customMessage.title || !customMessage.message"
>
发送自定义消息
</button>
</div>
</div>
<div class="test-section">
<h4>测试历史</h4>
<div class="test-history">
<div v-for="(test, index) in testHistory" :key="index" class="history-item">
<div class="history-time">{{ test.time }}</div>
<div class="history-content">{{ test.title }} - {{ test.result }}</div>
</div>
<div v-if="testHistory.length === 0" class="no-history">暂无测试历史</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
import { logger } from '@/utils/logger'
const { subscribe, unsubscribe, sendRaw, getConnectionInfo } = useWebSocket()
// 测试状态
const isTesting = ref(false)
const lastResponse = ref('')
const testHistory = ref<Array<{ time: string; title: string; result: string }>>([])
const connectionStatus = ref('检查中...')
const connectionStatusClass = ref('status-checking')
// 自定义消息
const customMessage = ref({
title: '操作确认',
message: '请确认是否继续执行此操作?',
})
// 更新连接状态
const updateConnectionStatus = () => {
try {
const connInfo = getConnectionInfo()
connectionStatus.value = connInfo.status
switch (connInfo.status) {
case '已连接':
connectionStatusClass.value = 'status-connected'
break
case '连接中':
connectionStatusClass.value = 'status-connecting'
break
case '已断开':
connectionStatusClass.value = 'status-disconnected'
break
case '连接错误':
connectionStatusClass.value = 'status-error'
break
default:
connectionStatusClass.value = 'status-unknown'
}
} catch (error) {
connectionStatus.value = '获取失败'
connectionStatusClass.value = 'status-error'
}
}
// 存储订阅ID用于监听响应
let responseSubscriptionId: string
// 生成唯一ID
const generateId = () => {
return 'test-' + Math.random().toString(36).substr(2, 9)
}
// 格式化时间
const formatTime = () => {
return new Date().toLocaleTimeString()
}
// 添加测试历史
const addTestHistory = (title: string, result: string) => {
testHistory.value.unshift({
time: formatTime(),
title,
result,
})
// 保持最多10条历史记录
if (testHistory.value.length > 10) {
testHistory.value = testHistory.value.slice(0, 10)
}
}
// 直接触发弹窗(备用方法)
const directTriggerModal = () => {
isTesting.value = true
try {
// 直接触发浏览器的confirm对话框作为备用测试
const result = confirm('这是直接触发的测试弹窗。\n\n如果WebSocket消息弹窗无法正常工作这个方法可以用来验证基本功能。\n\n点击"确定"继续,点击"取消"退出。')
lastResponse.value = result ? '用户选择: 确认 (直接触发)' : '用户选择: 取消 (直接触发)'
addTestHistory('直接触发测试', result ? '确认' : '取消')
logger.info('[调试工具] 直接触发测试完成,结果:', result)
} catch (error: any) {
logger.error('[调试工具] 直接触发测试失败:', error)
lastResponse.value = '直接触发失败: ' + (error?.message || '未知错误')
}
setTimeout(() => {
isTesting.value = false
}, 1000)
}
// 发送WebSocket消息来模拟接收消息
const simulateMessage = (messageData: any) => {
logger.info('[调试工具] 发送模拟消息:', messageData)
// 检查连接状态
const connInfo = getConnectionInfo()
if (connInfo.status !== '已连接') {
logger.warn('[调试工具] WebSocket未连接无法发送消息')
lastResponse.value = '发送失败: WebSocket未连接'
return
}
try {
// 使用sendRaw直接发送Message类型的消息
sendRaw('Message', messageData)
logger.info('[调试工具] 消息已发送到WebSocket')
lastResponse.value = '消息已发送,等待弹窗显示...'
} catch (error: any) {
logger.error('[调试工具] 发送消息失败:', error)
lastResponse.value = '发送失败: ' + (error?.message || '未知错误')
}
}
// 触发标准Question弹窗
const triggerQuestionModal = () => {
isTesting.value = true
const testMessageData = {
message_id: generateId(),
type: 'Question',
title: '测试提示',
message: '这是一个测试消息,请选择您的操作。',
}
logger.info('[调试工具] 发送测试Question消息:', testMessageData)
// 直接模拟接收消息
simulateMessage(testMessageData)
lastResponse.value = '已发送测试Question消息'
addTestHistory('标准Question测试', '已发送')
setTimeout(() => {
isTesting.value = false
}, 1000)
}
// 触发自定义弹窗
const triggerCustomModal = () => {
isTesting.value = true
const testMessageData = {
message_id: generateId(),
type: 'Question',
title: '自定义测试',
message:
'这是一个自定义的测试消息,用于验证弹窗的不同内容显示。您可以测试长文本、特殊字符等情况。',
}
logger.info('[调试工具] 发送自定义测试消息:', testMessageData)
simulateMessage(testMessageData)
lastResponse.value = '已发送自定义测试消息'
addTestHistory('自定义内容测试', '已发送')
setTimeout(() => {
isTesting.value = false
}, 1000)
}
// 发送完全自定义的消息
const sendCustomMessage = () => {
if (!customMessage.value.title || !customMessage.value.message) {
return
}
isTesting.value = true
const testMessageData = {
message_id: generateId(),
type: 'Question',
title: customMessage.value.title,
message: customMessage.value.message,
}
logger.info('[调试工具] 发送用户自定义消息:', testMessageData)
simulateMessage(testMessageData)
lastResponse.value = `已发送自定义消息: ${customMessage.value.title}`
addTestHistory(`自定义: ${customMessage.value.title}`, '已发送')
setTimeout(() => {
isTesting.value = false
}, 1000)
}
// 监听响应消息
const handleResponseMessage = (message: any) => {
logger.info('[调试工具] 收到响应消息:', message)
if (message.data && message.data.choice !== undefined) {
const choice = message.data.choice ? '确认' : '取消'
lastResponse.value = `用户选择: ${choice}`
addTestHistory('用户响应', choice)
}
}
// 组件挂载时订阅响应消息
onMounted(() => {
logger.info('[调试工具] 初始化消息测试页面')
// 订阅Response类型的消息来监听用户的选择结果
responseSubscriptionId = subscribe({ type: 'Response' }, handleResponseMessage)
// 初始化连接状态
updateConnectionStatus()
// 定期更新连接状态
const statusTimer = setInterval(updateConnectionStatus, 2000)
logger.info('[调试工具] 已订阅Response消息订阅ID:', responseSubscriptionId)
// 清理定时器
onUnmounted(() => {
clearInterval(statusTimer)
})
})
// 组件卸载时清理订阅
onUnmounted(() => {
if (responseSubscriptionId) {
unsubscribe(responseSubscriptionId)
logger.info('[调试工具] 已取消Response消息订阅')
}
})
</script>
<style scoped>
.test-page {
color: #fff;
}
.page-title {
margin: 0 0 16px 0;
font-size: 14px;
color: #4caf50;
}
.test-section {
margin-bottom: 20px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.test-section h4 {
margin: 0 0 12px 0;
font-size: 12px;
color: #e0e0e0;
}
.test-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.test-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.test-btn.primary {
background: #4caf50;
color: white;
}
.test-btn.primary:hover:not(:disabled) {
background: #45a049;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
}
.test-btn.secondary {
background: #2196f3;
color: white;
}
.test-btn.secondary:hover:not(:disabled) {
background: #1976d2;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.3);
}
.test-btn.warning {
background: #ff9800;
color: white;
}
.test-btn.warning:hover:not(:disabled) {
background: #f57c00;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.3);
}
.test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.test-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.test-info {
font-size: 10px;
color: #bbb;
}
.test-info p {
margin: 4px 0;
}
/* 连接状态样式 */
.status-connected {
color: #4caf50;
font-weight: 600;
}
.status-connecting {
color: #ff9800;
font-weight: 600;
}
.status-disconnected {
color: #f44336;
font-weight: 600;
}
.status-error {
color: #e91e63;
font-weight: 600;
}
.status-checking,
.status-unknown {
color: #9e9e9e;
font-weight: 600;
}
.custom-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-size: 10px;
color: #ccc;
font-weight: 500;
}
.form-input,
.form-textarea {
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 11px;
transition: all 0.2s ease;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #4caf50;
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.form-input::placeholder,
.form-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.form-textarea {
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.test-history {
max-height: 120px;
overflow-y: auto;
border-radius: 4px;
}
.test-history::-webkit-scrollbar {
width: 4px;
}
.test-history::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.test-history::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.test-history::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.history-item {
display: flex;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 10px;
transition: background-color 0.2s ease;
border-radius: 3px;
margin-bottom: 2px;
}
.history-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.history-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.history-time {
color: #888;
min-width: 60px;
font-weight: 500;
}
.history-content {
color: #ccc;
flex: 1;
margin-left: 8px;
}
.no-history {
text-align: center;
color: #666;
font-size: 10px;
padding: 16px 0;
font-style: italic;
}
/* 暗色主题专用样式增强 */
@media (prefers-color-scheme: dark) {
.test-page {
color: #e8e8e8;
}
.page-title {
color: #66bb6a;
text-shadow: 0 0 8px rgba(102, 187, 106, 0.3);
}
.test-section {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
}
.test-section h4 {
color: #f0f0f0;
}
.test-btn.primary {
background: linear-gradient(135deg, #4caf50 0%, #66bb6a 100%);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.test-btn.primary:hover:not(:disabled) {
background: linear-gradient(135deg, #45a049 0%, #5cb85c 100%);
border-color: rgba(76, 175, 80, 0.5);
}
.test-btn.secondary {
background: linear-gradient(135deg, #2196f3 0%, #42a5f5 100%);
border: 1px solid rgba(33, 150, 243, 0.3);
}
.test-btn.secondary:hover:not(:disabled) {
background: linear-gradient(135deg, #1976d2 0%, #1e88e5 100%);
border-color: rgba(33, 150, 243, 0.5);
}
.form-input,
.form-textarea {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.15);
}
.form-input:focus,
.form-textarea:focus {
background: rgba(0, 0, 0, 0.3);
border-color: #66bb6a;
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2);
}
.history-item {
background: rgba(255, 255, 255, 0.02);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.history-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.history-time {
color: #aaa;
}
.history-content {
color: #ddd;
}
}
/* 高对比度模式适配 */
@media (prefers-contrast: high) {
.test-section {
border-width: 2px;
border-color: rgba(255, 255, 255, 0.3);
}
.test-btn {
border: 2px solid currentColor;
font-weight: 600;
}
.form-input,
.form-textarea {
border-width: 2px;
}
.form-input:focus,
.form-textarea:focus {
border-width: 2px;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.3);
}
}
/* 减少动画模式适配 */
@media (prefers-reduced-motion: reduce) {
.test-btn,
.form-input,
.form-textarea,
.history-item {
transition: none;
}
.test-btn:hover:not(:disabled) {
transform: none;
}
.page-title {
text-shadow: none;
}
}
</style>

View File

@@ -43,12 +43,14 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import RouteInfoPage from './RouteInfoPage.vue'
import EnvironmentPage from './EnvironmentPage.vue'
import QuickNavPage from './QuickNavPage.vue'
import MessageTestPage from './MessageTestPage.vue'
// 调试页面配置
const tabs = [
{ key: 'route', title: '路由', icon: '🛣️', component: RouteInfoPage },
{ key: 'env', title: '环境', icon: '⚙️', component: EnvironmentPage },
{ key: 'nav', title: '导航', icon: '🚀', component: QuickNavPage },
{ key: 'message', title: '消息', icon: '💬', component: MessageTestPage },
]
// 开发环境检测

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import { OpenAPI } from '@/api'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
@@ -15,11 +14,13 @@ import { logger } from '@/utils/logger'
// 导入镜像管理器
import { mirrorManager } from '@/utils/mirrorManager'
// 导入WebSocket消息监听组件
import WebSocketMessageListener from '@/components/WebSocketMessageListener.vue'
import { API_ENDPOINTS } from '@/config/mirrors'
// 配置dayjs中文本地化
dayjs.locale('zh-cn')
import { API_ENDPOINTS } from '@/config/mirrors'
// 配置API基础URL
OpenAPI.BASE = API_ENDPOINTS.local
@@ -28,11 +29,14 @@ logger.info('前端应用开始初始化')
logger.info(`API基础URL: ${OpenAPI.BASE}`)
// 初始化镜像管理器(异步)
mirrorManager.initialize().then(() => {
logger.info('镜像管理器初始化完成')
}).catch((error) => {
logger.error('镜像管理器初始化失败:', error)
})
mirrorManager
.initialize()
.then(() => {
logger.info('镜像管理器初始化完成')
})
.catch(error => {
logger.error('镜像管理器初始化失败:', error)
})
// 创建应用实例
const app = createApp(App)
@@ -66,4 +70,7 @@ app.config.errorHandler = (err, instance, info) => {
// 挂载应用
app.mount('#app')
// 注册WebSocket消息监听组件
app.component('WebSocketMessageListener', WebSocketMessageListener)
logger.info('前端应用初始化完成')

View File

@@ -0,0 +1,77 @@
// 调度中心调试工具
export function debugScheduler() {
console.log('=== 调度中心调试信息 ===')
// 检查WebSocket连接状态
const wsStorage = (window as any)[Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')]
if (wsStorage) {
console.log('WebSocket状态:', wsStorage.status.value)
console.log('WebSocket连接ID:', wsStorage.connectionId)
console.log('订阅数量:', wsStorage.subscriptions.value.size)
console.log('缓存标记数量:', wsStorage.cacheMarkers.value.size)
console.log('缓存消息数量:', wsStorage.cachedMessages.value.length)
// 列出所有订阅
console.log('当前订阅:')
wsStorage.subscriptions.value.forEach((sub, id) => {
console.log(` - ${id}: type=${sub.filter.type}, id=${sub.filter.id}`)
})
} else {
console.log('WebSocket存储未初始化')
}
// 检查调度中心状态
const scheduler = document.querySelector('[data-scheduler-debug]')
if (scheduler) {
console.log('调度中心组件已挂载')
} else {
console.log('调度中心组件未找到')
}
}
// 测试WebSocket连接
export function testWebSocketConnection() {
console.log('=== 测试WebSocket连接 ===')
try {
const ws = new WebSocket('ws://localhost:36163/api/core/ws')
ws.onopen = () => {
console.log('✅ WebSocket连接成功')
ws.send(JSON.stringify({
type: 'Signal',
data: { Connect: true, connectionId: 'test-connection' }
}))
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('📩 收到消息:', message)
}
ws.onerror = (error) => {
console.log('❌ WebSocket错误:', error)
}
ws.onclose = (event) => {
console.log('🔌 WebSocket连接关闭:', event.code, event.reason)
}
// 5秒后关闭测试连接
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.close()
console.log('🔌 测试连接已关闭')
}
}, 5000)
} catch (error) {
console.log('❌ 无法创建WebSocket连接:', error)
}
}
// 在控制台中暴露调试函数
if (typeof window !== 'undefined') {
(window as any).debugScheduler = debugScheduler;
(window as any).testWebSocketConnection = testWebSocketConnection;
}

View File

@@ -16,7 +16,11 @@
</a-breadcrumb>
</div>
<a-space size="middle">
// 如果已有连接,先断开
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
generalWebsocketId.value = nulla-space size="middle">
<a-button
type="primary"
ghost
@@ -390,6 +394,7 @@ const scriptName = ref('')
// 通用配置相关
const generalConfigLoading = ref(false)
const generalSubscriptionId = ref<string | null>(null)
const generalWebsocketId = ref<string | null>(null)
const showGeneralConfigMask = ref(false)
let generalConfigTimeout: number | null = null
@@ -599,12 +604,14 @@ const handleGeneralConfig = async () => {
console.debug('订阅 websocketId:', wsId)
// 订阅 websocket
subscribe(wsId, {
onMessage: (wsMessage: any) => {
const subscriptionId = subscribe(
{ id: wsId },
(wsMessage: any) => {
if (wsMessage.type === 'error') {
console.error(`用户 ${formData.userName} 通用配置错误:`, wsMessage.data)
message.error(`通用配置连接失败: ${wsMessage.data}`)
unsubscribe(wsId)
unsubscribe(subscriptionId)
generalSubscriptionId.value = null
generalWebsocketId.value = null
showGeneralConfigMask.value = false
return
@@ -612,13 +619,15 @@ const handleGeneralConfig = async () => {
if (wsMessage.data && wsMessage.data.Accomplish) {
message.success(`用户 ${formData.userName} 的配置已完成`)
unsubscribe(wsId)
unsubscribe(subscriptionId)
generalSubscriptionId.value = null
generalWebsocketId.value = null
showGeneralConfigMask.value = false
}
},
})
}
)
generalSubscriptionId.value = subscriptionId
generalWebsocketId.value = wsId
showGeneralConfigMask.value = true
message.success(`已开始配置用户 ${formData.userName} 的通用设置`)
@@ -626,9 +635,9 @@ const handleGeneralConfig = async () => {
// 设置 30 分钟超时自动断开
generalConfigTimeout = window.setTimeout(
() => {
if (generalWebsocketId.value) {
const id = generalWebsocketId.value
unsubscribe(id)
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
generalWebsocketId.value = null
showGeneralConfigMask.value = false
message.info(`用户 ${formData.userName} 的配置会话已超时断开`)
@@ -658,7 +667,10 @@ const handleSaveGeneralConfig = async () => {
const response = await Service.stopTaskApiDispatchStopPost({ taskId: websocketId })
if (response && response.code === 200) {
unsubscribe(websocketId)
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
}
generalWebsocketId.value = null
showGeneralConfigMask.value = false
if (generalConfigTimeout) {
@@ -720,8 +732,9 @@ const handleWebhookChange = () => {
}
const handleCancel = () => {
if (generalWebsocketId.value) {
unsubscribe(generalWebsocketId.value)
if (generalSubscriptionId.value) {
unsubscribe(generalSubscriptionId.value)
generalSubscriptionId.value = null
generalWebsocketId.value = null
showGeneralConfigMask.value = false
if (generalConfigTimeout) {

View File

@@ -166,6 +166,7 @@ const scriptName = ref('')
// MAA配置相关
const maaConfigLoading = ref(false)
const maaSubscriptionId = ref<string | null>(null)
const maaWebsocketId = ref<string | null>(null)
const showMAAConfigMask = ref(false)
let maaConfigTimeout: number | null = null
@@ -763,8 +764,9 @@ const handleMAAConfig = async () => {
maaConfigLoading.value = true
// 如果已有连接,先断开
if (maaWebsocketId.value) {
unsubscribe(maaWebsocketId.value)
if (maaSubscriptionId.value) {
unsubscribe(maaSubscriptionId.value)
maaSubscriptionId.value = null
maaWebsocketId.value = null
showMAAConfigMask.value = false
if (maaConfigTimeout) {
@@ -783,15 +785,17 @@ const handleMAAConfig = async () => {
const wsId = response.websocketId
// 订阅 websocket
subscribe(wsId, {
onMessage: (wsMessage: any) => {
const subscriptionId = subscribe(
{ id: wsId },
(wsMessage: any) => {
if (wsMessage.type === 'error') {
console.error(
`用户 ${formData.Info?.Name || formData.userName} MAA配置错误:`,
wsMessage.data
)
message.error(`MAA配置连接失败: ${wsMessage.data}`)
unsubscribe(wsId)
unsubscribe(subscriptionId)
maaSubscriptionId.value = null
maaWebsocketId.value = null
showMAAConfigMask.value = false
return
@@ -799,13 +803,15 @@ const handleMAAConfig = async () => {
if (wsMessage.data && wsMessage.data.Accomplish) {
message.success(`用户 ${formData.Info?.Name || formData.userName} 的配置已完成`)
unsubscribe(wsId)
unsubscribe(subscriptionId)
maaSubscriptionId.value = null
maaWebsocketId.value = null
showMAAConfigMask.value = false
}
},
})
}
)
maaSubscriptionId.value = subscriptionId
maaWebsocketId.value = wsId
showMAAConfigMask.value = true
message.success(`已开始配置用户 ${formData.Info?.Name || formData.userName} 的MAA设置`)
@@ -813,9 +819,9 @@ const handleMAAConfig = async () => {
// 设置 30 分钟超时自动断开
maaConfigTimeout = window.setTimeout(
() => {
if (maaWebsocketId.value) {
const id = maaWebsocketId.value
unsubscribe(id)
if (maaSubscriptionId.value) {
unsubscribe(maaSubscriptionId.value)
maaSubscriptionId.value = null
maaWebsocketId.value = null
showMAAConfigMask.value = false
message.info(`用户 ${formData.Info?.Name || formData.userName} 的配置会话已超时断开`)
@@ -845,7 +851,10 @@ const handleSaveMAAConfig = async () => {
const response = await Service.stopTaskApiDispatchStopPost({ taskId: websocketId })
if (response && response.code === 200) {
unsubscribe(websocketId)
if (maaSubscriptionId.value) {
unsubscribe(maaSubscriptionId.value)
maaSubscriptionId.value = null
}
maaWebsocketId.value = null
showMAAConfigMask.value = false
if (maaConfigTimeout) {
@@ -970,8 +979,9 @@ const addCustomStageRemain = (stageName: string) => {
}
const handleCancel = () => {
if (maaWebsocketId.value) {
unsubscribe(maaWebsocketId.value)
if (maaSubscriptionId.value) {
unsubscribe(maaSubscriptionId.value)
maaSubscriptionId.value = null
maaWebsocketId.value = null
}
router.push('/scripts')

View File

@@ -41,7 +41,7 @@
<!-- 空状态 -->
<!-- 增加 loadedOnce 条件避免初始渲染时闪烁 -->
<div v-if="!loading && loadedOnce && scripts.length === 0" class="empty-state">
<div v-if="!addLoading && loadedOnce && scripts.length === 0" class="empty-state">
<div class="empty-content">
<div class="empty-image-container">
<img src="@/assets/NoData.png" alt="暂无数据" class="empty-image" />
@@ -287,7 +287,7 @@ const showMAAConfigMask = ref(false) // 控制MAA配置遮罩层的显示
const currentConfigScript = ref<Script | null>(null) // 当前正在配置的脚本
// WebSocket连接管理
const activeConnections = ref<Map<string, string>>(new Map()) // scriptId -> websocketId
const activeConnections = ref<Map<string, { subscriptionId: string; websocketId: string }>>(new Map()) // scriptId -> { subscriptionId, websocketId }
// 解析模板描述的markdown
const parseMarkdown = (text: string) => {
@@ -548,8 +548,9 @@ const handleStartMAAConfig = async (script: Script) => {
currentConfigScript.value = script
// 订阅WebSocket消息
subscribe(response.websocketId, {
onMessage: (wsMessage: any) => {
const subscriptionId = subscribe(
{ id: response.websocketId },
(wsMessage: any) => {
// 处理错误消息
if (wsMessage.type === 'error') {
console.error(`脚本 ${script.name} 连接错误:`, wsMessage.data)
@@ -569,20 +570,23 @@ const handleStartMAAConfig = async (script: Script) => {
showMAAConfigMask.value = false
currentConfigScript.value = null
}
},
})
}
)
// 记录连接和websocketId
activeConnections.value.set(script.id, response.websocketId)
// 记录连接和subscriptionId
activeConnections.value.set(script.id, {
subscriptionId,
websocketId: response.websocketId
})
message.success(`已启动 ${script.name} 的MAA配置`)
// 设置自动断开连接的定时器30分钟后
setTimeout(
() => {
if (activeConnections.value.has(script.id)) {
const wsId = activeConnections.value.get(script.id)
if (wsId) {
unsubscribe(wsId)
const connection = activeConnections.value.get(script.id)
if (connection) {
unsubscribe(connection.subscriptionId)
}
activeConnections.value.delete(script.id)
// 超时时隐藏遮罩
@@ -604,20 +608,20 @@ const handleStartMAAConfig = async (script: Script) => {
const handleSaveMAAConfig = async (script: Script) => {
try {
const websocketId = activeConnections.value.get(script.id)
if (!websocketId) {
const connection = activeConnections.value.get(script.id)
if (!connection) {
message.error('未找到活动的配置会话')
return
}
// 调用停止配置任务API
const response = await Service.stopTaskApiDispatchStopPost({
taskId: websocketId,
taskId: connection.websocketId,
})
if (response.code === 200) {
// 取消订阅
unsubscribe(websocketId)
unsubscribe(connection.subscriptionId)
activeConnections.value.delete(script.id)
// 隐藏遮罩

View File

@@ -50,7 +50,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons-vue'
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
import type { ComboBoxItem } from '@/api/models/ComboBoxItem'
@@ -88,11 +88,6 @@ const localSelectedMode = ref(props.selectedMode)
// 模式选项
const modeOptions = TASK_MODE_OPTIONS
// 计算属性
const canStart = computed(() => {
return !!(localSelectedTaskId.value && localSelectedMode.value) && !props.disabled
})
// 监听 props 变化,同步到本地状态
watch(
() => props.selectedTaskId,

View File

@@ -120,7 +120,6 @@
import { onMounted, onUnmounted } from 'vue'
import { LockOutlined } from '@ant-design/icons-vue'
import {
getPowerActionText,
POWER_ACTION_TEXT,
TAB_STATUS_COLOR,
} from './schedulerConstants'
@@ -137,8 +136,6 @@ const {
taskOptionsLoading,
taskOptions,
powerAction,
powerCountdownVisible,
powerCountdownData,
messageModalVisible,
currentMessage,
messageResponse,
@@ -160,7 +157,6 @@ const {
// 电源操作
onPowerActionChange,
cancelPowerAction,
// 消息操作
sendMessageResponse,
@@ -189,6 +185,13 @@ const onSchedulerTabEdit = (targetKey: string | MouseEvent, action: 'add' | 'rem
onMounted(() => {
initialize() // 初始化TaskManager订阅
loadTaskOptions()
// 开发环境下导入调试工具
if (process.env.NODE_ENV === 'development') {
import('@/utils/scheduler-debug').then(() => {
console.log('调度中心调试工具已加载,使用 debugScheduler() 和 testWebSocketConnection() 进行调试')
})
}
})
onUnmounted(() => {

View File

@@ -78,6 +78,7 @@ export interface SchedulerTab {
selectedTaskId: string | null
selectedMode: TaskCreateIn.mode | null
websocketId: string | null
subscriptionId?: string | null
taskQueue: QueueItem[]
userQueue: QueueItem[]
logs: LogEntry[]

View File

@@ -230,8 +230,8 @@ export function useSchedulerLogic() {
if (idx === -1) return
// 清理 WebSocket 订阅
if (tab.websocketId) {
ws.unsubscribe(tab.websocketId)
if (tab.subscriptionId) {
ws.unsubscribe(tab.subscriptionId)
}
// 清理日志引用
@@ -310,9 +310,13 @@ export function useSchedulerLogic() {
const subscribeToTask = (tab: SchedulerTab) => {
if (!tab.websocketId) return
ws.subscribe(tab.websocketId, {
onMessage: (message) => handleWebSocketMessage(tab, message)
})
const subscriptionId = ws.subscribe(
{ id: tab.websocketId },
(message) => handleWebSocketMessage(tab, message)
)
// 将订阅ID保存到tab中以便后续取消订阅
tab.subscriptionId = subscriptionId
}
const handleWebSocketMessage = (tab: SchedulerTab, wsMessage: any) => {
@@ -548,8 +552,12 @@ export function useSchedulerLogic() {
console.log('[Scheduler] 已强制更新schedulerTabs当前tabs状态:', schedulerTabs.value)
}
if (tab.subscriptionId) {
ws.unsubscribe(tab.subscriptionId)
tab.subscriptionId = null
}
if (tab.websocketId) {
ws.unsubscribe(tab.websocketId)
tab.websocketId = null
}
@@ -823,8 +831,8 @@ export function useSchedulerLogic() {
// 不再清理或重置导出的处理函数,保持使用者注册的处理逻辑永久有效
schedulerTabs.value.forEach(tab => {
if (tab.websocketId) {
ws.unsubscribe(tab.websocketId)
if (tab.subscriptionId) {
ws.unsubscribe(tab.subscriptionId)
}
})
saveTabsToStorage(schedulerTabs.value)