feat(router): 更新路由配置并添加调度中心页面
- 更新路由配置,将 Scheduler 组件路径修改为 ../views/scheduler/index.vue
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
let needInitLanding = true
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { isAppInitialized } from '@/utils/config'
|
||||
|
||||
let needInitLanding = true
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -79,7 +80,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/scheduler',
|
||||
name: 'Scheduler',
|
||||
component: () => import('../views/Scheduler.vue'),
|
||||
component: () => import('../views/scheduler/index.vue'),
|
||||
meta: { title: '调度中心' },
|
||||
},
|
||||
{
|
||||
@@ -148,5 +149,4 @@ router.beforeEach(async (to, from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
export default router
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
252
frontend/src/views/scheduler/SchedulerLogPanel.vue
Normal file
252
frontend/src/views/scheduler/SchedulerLogPanel.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="log-panel">
|
||||
<div class="section-header">
|
||||
<h3>日志</h3>
|
||||
<div class="log-controls">
|
||||
<a-button size="small" @click="clearLogs" :disabled="logs.length === 0">
|
||||
清空日志
|
||||
</a-button>
|
||||
<a-button size="small" @click="scrollToBottom" :disabled="logs.length === 0">
|
||||
滚动到底部
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content" :ref="setLogRef" @scroll="onScroll">
|
||||
<div v-if="logs.length === 0" class="empty-state-mini">
|
||||
<img src="@/assets/NoData.png" alt="暂无数据" class="empty-image-mini" />
|
||||
<p class="empty-text-mini">暂无日志信息</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="`${tabKey}-${index}-${log.timestamp}`"
|
||||
:class="['log-line', `log-${log.type}`]"
|
||||
>
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import type { LogEntry } from './schedulerConstants'
|
||||
|
||||
interface Props {
|
||||
logs: LogEntry[]
|
||||
tabKey: string
|
||||
isLogAtBottom: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'scroll', isAtBottom: boolean): void
|
||||
|
||||
(e: 'setRef', el: HTMLElement | null, key: string): void
|
||||
|
||||
(e: 'clearLogs'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const setLogRef = (el: HTMLElement | null) => {
|
||||
emit('setRef', el, props.tabKey)
|
||||
}
|
||||
|
||||
const onScroll = (event: Event) => {
|
||||
const el = event.target as HTMLElement
|
||||
if (!el) return
|
||||
|
||||
const threshold = 5
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
|
||||
emit('scroll', isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-tab-key="${props.tabKey}"] .log-content`
|
||||
) as HTMLElement
|
||||
if (el) {
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
emit('clearLogs')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-heading);
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: var(--ant-color-bg-layout);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-image-mini {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
filter: var(--ant-color-scheme-dark, brightness(0.8));
|
||||
}
|
||||
|
||||
.empty-text-mini {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--ant-color-text-secondary);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.log-info {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
background-color: var(--ant-color-error-bg);
|
||||
border-left: 3px solid var(--ant-color-error);
|
||||
}
|
||||
|
||||
.log-error .log-message {
|
||||
color: var(--ant-color-error-text);
|
||||
}
|
||||
|
||||
.log-warning {
|
||||
background-color: var(--ant-color-warning-bg);
|
||||
border-left: 3px solid var(--ant-color-warning);
|
||||
}
|
||||
|
||||
.log-warning .log-message {
|
||||
color: var(--ant-color-warning-text);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
background-color: var(--ant-color-success-bg);
|
||||
border-left: 3px solid var(--ant-color-success);
|
||||
}
|
||||
|
||||
.log-success .log-message {
|
||||
color: var(--ant-color-success-text);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-header h3 {
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background: var(--ant-color-bg-layout, #141414);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
color: var(--ant-color-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.empty-image-mini {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.empty-text-mini {
|
||||
color: var(--ant-color-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--ant-color-text-secondary, #bfbfbf);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
background-color: rgba(255, 77, 79, 0.1);
|
||||
border-left: 3px solid var(--ant-color-error, #ff4d4f);
|
||||
}
|
||||
|
||||
.log-error .log-message {
|
||||
color: var(--ant-color-error, #ff7875);
|
||||
}
|
||||
|
||||
.log-warning {
|
||||
background-color: rgba(250, 173, 20, 0.1);
|
||||
border-left: 3px solid var(--ant-color-warning, #faad14);
|
||||
}
|
||||
|
||||
.log-warning .log-message {
|
||||
color: var(--ant-color-warning, #ffc53d);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
border-left: 3px solid var(--ant-color-success, #52c41a);
|
||||
}
|
||||
|
||||
.log-success .log-message {
|
||||
color: var(--ant-color-success, #73d13d);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
177
frontend/src/views/scheduler/SchedulerQueuePanel.vue
Normal file
177
frontend/src/views/scheduler/SchedulerQueuePanel.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="queue-panel">
|
||||
<div class="section-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<a-badge :count="items.length" :overflow-count="99" />
|
||||
</div>
|
||||
<div class="queue-content">
|
||||
<div v-if="items.length === 0" class="empty-state-mini">
|
||||
<img src="@/assets/NoData.png" alt="暂无数据" class="empty-image-mini" />
|
||||
<p class="empty-text-mini">{{ emptyText }}</p>
|
||||
</div>
|
||||
<div v-else class="queue-cards">
|
||||
<a-card
|
||||
v-for="(item, index) in items"
|
||||
:key="`${type}-${index}`"
|
||||
size="small"
|
||||
class="queue-card"
|
||||
:class="{ 'running-card': item.status === '运行' }"
|
||||
>
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<a-tag :color="getStatusColor(item.status)" size="small">
|
||||
{{ item.status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<p class="item-name">{{ item.name }}</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getQueueStatusColor, type QueueItem } from './schedulerConstants'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
items: QueueItem[]
|
||||
type: 'task' | 'user'
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
emptyText: '暂无数据',
|
||||
})
|
||||
|
||||
const getStatusColor = (status: string) => getQueueStatusColor(status)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-heading);
|
||||
}
|
||||
|
||||
.queue-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-image-mini {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
filter: var(--ant-color-scheme-dark, brightness(0.8));
|
||||
}
|
||||
|
||||
.empty-text-mini {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.queue-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
border-color: var(--ant-color-border);
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
box-shadow: 0 2px 8px var(--ant-color-shadow);
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--ant-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-header h3 {
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
color: var(--ant-color-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.empty-image-mini {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.empty-text-mini {
|
||||
color: var(--ant-color-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
border-color: var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--ant-color-primary, #1890ff);
|
||||
box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
frontend/src/views/scheduler/SchedulerTaskControl.vue
Normal file
163
frontend/src/views/scheduler/SchedulerTaskControl.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="task-control">
|
||||
<div class="control-row">
|
||||
<a-select
|
||||
v-model:value="localSelectedTaskId"
|
||||
placeholder="选择任务项"
|
||||
style="width: 200px"
|
||||
:loading="taskOptionsLoading"
|
||||
:options="taskOptions"
|
||||
show-search
|
||||
:filter-option="filterTaskOption"
|
||||
:disabled="disabled"
|
||||
@change="onTaskChange"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="localSelectedMode"
|
||||
placeholder="选择模式"
|
||||
style="width: 120px"
|
||||
:disabled="disabled"
|
||||
@change="onModeChange"
|
||||
>
|
||||
<a-select-option v-for="option in modeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="control-spacer"></div>
|
||||
<a-button
|
||||
v-if="status !== '运行'"
|
||||
type="primary"
|
||||
@click="onStart"
|
||||
:icon="h(PlayCircleOutlined)"
|
||||
:disabled="!canStart"
|
||||
>
|
||||
开始任务
|
||||
</a-button>
|
||||
<a-button v-else danger @click="onStop" :icon="h(StopOutlined)"> 中止任务</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, 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'
|
||||
import { type SchedulerStatus, TASK_MODE_OPTIONS } from './schedulerConstants'
|
||||
|
||||
interface Props {
|
||||
selectedTaskId: string | null
|
||||
selectedMode: TaskCreateIn.mode | null
|
||||
taskOptions: ComboBoxItem[]
|
||||
taskOptionsLoading: boolean
|
||||
status: SchedulerStatus
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:selectedTaskId', value: string | null): void
|
||||
|
||||
(e: 'update:selectedMode', value: TaskCreateIn.mode | null): void
|
||||
|
||||
(e: 'start'): void
|
||||
|
||||
(e: 'stop'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 本地状态,用于双向绑定
|
||||
const localSelectedTaskId = ref(props.selectedTaskId)
|
||||
const localSelectedMode = ref(props.selectedMode)
|
||||
|
||||
// 模式选项
|
||||
const modeOptions = TASK_MODE_OPTIONS
|
||||
|
||||
// 计算属性
|
||||
const canStart = computed(() => {
|
||||
return !!(localSelectedTaskId.value && localSelectedMode.value) && !props.disabled
|
||||
})
|
||||
|
||||
// 监听 props 变化,同步到本地状态
|
||||
watch(
|
||||
() => props.selectedTaskId,
|
||||
newVal => {
|
||||
localSelectedTaskId.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedMode,
|
||||
newVal => {
|
||||
localSelectedMode.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 事件处理
|
||||
const onTaskChange = (value: string) => {
|
||||
emit('update:selectedTaskId', value)
|
||||
}
|
||||
|
||||
const onModeChange = (value: TaskCreateIn.mode) => {
|
||||
emit('update:selectedMode', value)
|
||||
}
|
||||
|
||||
const onStart = () => {
|
||||
emit('start')
|
||||
}
|
||||
|
||||
const onStop = () => {
|
||||
emit('stop')
|
||||
}
|
||||
|
||||
// 任务选项过滤
|
||||
const filterTaskOption = (input: string, option: any) => {
|
||||
return (option?.label || '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-control {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--ant-color-bg-layout);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.control-row {
|
||||
background: var(--ant-color-bg-layout, #141414);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
}
|
||||
|
||||
.control-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.control-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
470
frontend/src/views/scheduler/index.vue
Normal file
470
frontend/src/views/scheduler/index.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div class="scheduler-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="scheduler-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">调度中心</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="power-label">任务完成后电源操作:</span>
|
||||
<a-select
|
||||
v-model:value="powerAction"
|
||||
style="width: 140px"
|
||||
:disabled="!canChangePowerAction"
|
||||
@change="onPowerActionChange"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="(text, signal) in POWER_ACTION_TEXT"
|
||||
:key="signal"
|
||||
:value="signal"
|
||||
>
|
||||
{{ text }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度台标签页 -->
|
||||
<div class="scheduler-tabs">
|
||||
<a-tabs
|
||||
v-model:activeKey="activeSchedulerTab"
|
||||
type="editable-card"
|
||||
@edit="onSchedulerTabEdit"
|
||||
>
|
||||
<a-tab-pane
|
||||
v-for="tab in schedulerTabs"
|
||||
:key="tab.key"
|
||||
:closable="tab.closable && tab.status !== '运行'"
|
||||
:data-tab-key="tab.key"
|
||||
>
|
||||
<template #tab>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<a-tag :color="TAB_STATUS_COLOR[tab.status]" size="small" class="tab-status">
|
||||
{{ tab.status }}
|
||||
</a-tag>
|
||||
<a-tooltip v-if="tab.status === '运行'" title="运行中的调度台无法删除" placement="top">
|
||||
<span class="tab-lock-icon">🔒</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- 任务控制与状态内容 -->
|
||||
<div class="task-unified-card">
|
||||
<!-- 任务控制栏 -->
|
||||
<SchedulerTaskControl
|
||||
v-model:selectedTaskId="tab.selectedTaskId"
|
||||
v-model:selectedMode="tab.selectedMode"
|
||||
:taskOptions="taskOptions"
|
||||
:taskOptionsLoading="taskOptionsLoading"
|
||||
:status="tab.status"
|
||||
:disabled="tab.status === '运行'"
|
||||
@start="startTask(tab)"
|
||||
@stop="stopTask(tab)"
|
||||
/>
|
||||
|
||||
<!-- 状态展示区域 -->
|
||||
<a-row :gutter="16" class="status-row">
|
||||
<!-- 任务队列栏 -->
|
||||
<a-col :span="4">
|
||||
<SchedulerQueuePanel
|
||||
title="任务队列"
|
||||
:items="tab.taskQueue"
|
||||
type="task"
|
||||
empty-text="暂无任务队列"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<!-- 用户队列栏 -->
|
||||
<a-col :span="4">
|
||||
<SchedulerQueuePanel
|
||||
title="用户队列"
|
||||
:items="tab.userQueue"
|
||||
type="user"
|
||||
empty-text="暂无用户队列"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<!-- 日志栏 -->
|
||||
<a-col :span="16">
|
||||
<SchedulerLogPanel
|
||||
:logs="tab.logs"
|
||||
:tab-key="tab.key"
|
||||
:is-log-at-bottom="tab.isLogAtBottom"
|
||||
@scroll="onLogScroll(tab, $event)"
|
||||
@set-ref="setLogRef"
|
||||
@clear-logs="clearTabLogs(tab)"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 消息对话框 -->
|
||||
<a-modal
|
||||
v-model:open="messageModalVisible"
|
||||
:title="currentMessage?.title || '系统消息'"
|
||||
@ok="sendMessageResponse"
|
||||
@cancel="cancelMessage"
|
||||
>
|
||||
<div v-if="currentMessage">
|
||||
<p>{{ currentMessage.content }}</p>
|
||||
<a-input
|
||||
v-if="currentMessage.needInput"
|
||||
v-model:value="messageResponse"
|
||||
placeholder="请输入回复内容"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 电源操作倒计时模态框 -->
|
||||
<a-modal
|
||||
v-model:open="powerCountdownVisible"
|
||||
title="电源操作确认"
|
||||
:closable="false"
|
||||
:maskClosable="false"
|
||||
@cancel="cancelPowerAction"
|
||||
>
|
||||
<template #footer>
|
||||
<a-button @click="cancelPowerAction">取消</a-button>
|
||||
</template>
|
||||
<div class="power-countdown">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<div>
|
||||
<p>
|
||||
所有任务已完成,系统将在 <strong>{{ powerCountdown }}</strong> 秒后执行:<strong>{{
|
||||
getPowerActionText(powerAction)
|
||||
}}</strong>
|
||||
</p>
|
||||
<a-progress :percent="(10 - powerCountdown) * 10" :show-info="false" />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
getPowerActionText,
|
||||
POWER_ACTION_TEXT,
|
||||
type SchedulerTab,
|
||||
TAB_STATUS_COLOR,
|
||||
} from './schedulerConstants'
|
||||
import { useSchedulerLogic } from './useSchedulerLogic'
|
||||
import SchedulerTaskControl from './SchedulerTaskControl.vue'
|
||||
import SchedulerQueuePanel from './SchedulerQueuePanel.vue'
|
||||
import SchedulerLogPanel from './SchedulerLogPanel.vue'
|
||||
|
||||
// 使用业务逻辑层
|
||||
const {
|
||||
// 状态
|
||||
schedulerTabs,
|
||||
activeSchedulerTab,
|
||||
taskOptionsLoading,
|
||||
taskOptions,
|
||||
powerAction,
|
||||
powerCountdownVisible,
|
||||
powerCountdown,
|
||||
messageModalVisible,
|
||||
currentMessage,
|
||||
messageResponse,
|
||||
|
||||
// 计算属性
|
||||
canChangePowerAction,
|
||||
|
||||
// Tab 管理
|
||||
addSchedulerTab,
|
||||
removeSchedulerTab,
|
||||
|
||||
// 任务操作
|
||||
startTask,
|
||||
stopTask,
|
||||
|
||||
// 日志操作
|
||||
onLogScroll,
|
||||
setLogRef,
|
||||
|
||||
// 电源操作
|
||||
onPowerActionChange,
|
||||
cancelPowerAction,
|
||||
|
||||
// 消息操作
|
||||
sendMessageResponse,
|
||||
cancelMessage,
|
||||
|
||||
// 初始化与清理
|
||||
loadTaskOptions,
|
||||
cleanup,
|
||||
} = useSchedulerLogic()
|
||||
|
||||
// Tab 操作
|
||||
const onSchedulerTabEdit = (targetKey: string | MouseEvent, action: 'add' | 'remove') => {
|
||||
if (action === 'add') {
|
||||
addSchedulerTab()
|
||||
} else if (action === 'remove' && typeof targetKey === 'string') {
|
||||
removeSchedulerTab(targetKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空指定标签页的日志
|
||||
const clearTabLogs = (tab: SchedulerTab) => {
|
||||
tab.logs.splice(0)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadTaskOptions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面容器 */
|
||||
.scheduler-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
/* 页面头部样式 */
|
||||
.scheduler-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 4px 24px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--ant-color-bg-layout);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--ant-color-text);
|
||||
background: linear-gradient(135deg, var(--ant-color-primary), var(--ant-color-primary-hover));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.power-label {
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
/* 标签页样式 */
|
||||
.scheduler-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tab) {
|
||||
background-color: var(--ant-color-bg-layout);
|
||||
border-color: var(--ant-color-border);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tab-active) {
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
margin-right: 8px;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.tab-status {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tab-lock-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 统一卡片样式 */
|
||||
.task-unified-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-row :deep(.ant-col) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 电源倒计时样式 */
|
||||
.power-countdown {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--ant-color-warning);
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.power-countdown p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.power-countdown strong {
|
||||
color: var(--ant-color-text-heading);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.scheduler-page {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.scheduler-header {
|
||||
background-color: var(--ant-color-bg-layout, #141414);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.power-label {
|
||||
color: var(--ant-color-text-secondary, #bfbfbf);
|
||||
}
|
||||
|
||||
.scheduler-tabs {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs) {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-content-holder) {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tabpane) {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tab) {
|
||||
background-color: var(--ant-color-bg-layout, #141414);
|
||||
border-color: var(--ant-color-border, #424242);
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.scheduler-tabs :deep(.ant-tabs-tab-active) {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.task-unified-card {
|
||||
background-color: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--ant-color-warning, #faad14);
|
||||
}
|
||||
|
||||
.power-countdown p {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.power-countdown strong {
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.status-row :deep(.ant-col:nth-child(1)) {
|
||||
span: 6;
|
||||
}
|
||||
|
||||
.status-row :deep(.ant-col:nth-child(2)) {
|
||||
span: 6;
|
||||
}
|
||||
|
||||
.status-row :deep(.ant-col:nth-child(3)) {
|
||||
span: 12;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scheduler-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-row :deep(.ant-col) {
|
||||
width: 100% !important;
|
||||
flex: none;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/views/scheduler/schedulerConstants.ts
Normal file
80
frontend/src/views/scheduler/schedulerConstants.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
|
||||
import { PowerIn } from '@/api/models/PowerIn'
|
||||
|
||||
// 调度台状态
|
||||
export type SchedulerStatus = '新建' | '运行' | '结束'
|
||||
|
||||
// 状态颜色映射
|
||||
export const TAB_STATUS_COLOR: Record<SchedulerStatus, string> = {
|
||||
新建: 'default',
|
||||
运行: 'processing',
|
||||
结束: 'success',
|
||||
}
|
||||
|
||||
// 队列状态 -> 颜色
|
||||
export const getQueueStatusColor = (status: string): string => {
|
||||
if (/成功|完成|已完成/.test(status)) return 'green'
|
||||
if (/失败|错误|异常/.test(status)) return 'red'
|
||||
if (/等待|排队|挂起/.test(status)) return 'orange'
|
||||
if (/进行|执行|运行/.test(status)) return 'blue'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// 任务模式选项(直接复用后端枚举值)
|
||||
export const TASK_MODE_OPTIONS = [
|
||||
{ label: TaskCreateIn.mode.AutoMode, value: TaskCreateIn.mode.AutoMode },
|
||||
{ label: TaskCreateIn.mode.ManualMode, value: TaskCreateIn.mode.ManualMode },
|
||||
{ label: TaskCreateIn.mode.SettingScriptMode, value: TaskCreateIn.mode.SettingScriptMode },
|
||||
]
|
||||
|
||||
// 电源操作映射
|
||||
export const POWER_ACTION_TEXT: Record<PowerIn.signal, string> = {
|
||||
[PowerIn.signal.NO_ACTION]: '无动作',
|
||||
[PowerIn.signal.KILL_SELF]: '退出软件',
|
||||
[PowerIn.signal.SLEEP]: '睡眠',
|
||||
[PowerIn.signal.HIBERNATE]: '休眠',
|
||||
[PowerIn.signal.SHUTDOWN]: '关机',
|
||||
[PowerIn.signal.SHUTDOWN_FORCE]: '强制关机',
|
||||
}
|
||||
|
||||
export const getPowerActionText = (action: PowerIn.signal) => POWER_ACTION_TEXT[action] || '无动<E697A0><E58AA8>'
|
||||
|
||||
// 日志相关
|
||||
export const LOG_MAX_LENGTH = 2000 // 最多保留日志条数
|
||||
|
||||
export type LogType = 'info' | 'error' | 'warning' | 'success'
|
||||
|
||||
export interface QueueItem {
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
time: string
|
||||
message: string
|
||||
type: LogType
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface SchedulerTab {
|
||||
key: string
|
||||
title: string
|
||||
closable: boolean
|
||||
status: SchedulerStatus
|
||||
selectedTaskId: string | null
|
||||
selectedMode: TaskCreateIn.mode | null
|
||||
websocketId: string | null
|
||||
taskQueue: QueueItem[]
|
||||
userQueue: QueueItem[]
|
||||
logs: LogEntry[]
|
||||
isLogAtBottom: boolean
|
||||
lastLogContent: string
|
||||
}
|
||||
|
||||
export interface TaskMessage {
|
||||
title: string
|
||||
content: string
|
||||
needInput: boolean
|
||||
messageId?: string
|
||||
taskId?: string
|
||||
}
|
||||
505
frontend/src/views/scheduler/useSchedulerLogic.ts
Normal file
505
frontend/src/views/scheduler/useSchedulerLogic.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { message, Modal, notification } from 'ant-design-vue'
|
||||
import { Service } from '@/api/services/Service'
|
||||
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
|
||||
import { PowerIn } from '@/api/models/PowerIn'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import type { ComboBoxItem } from '@/api/models/ComboBoxItem'
|
||||
import {
|
||||
getPowerActionText,
|
||||
LOG_MAX_LENGTH,
|
||||
type LogEntry,
|
||||
type SchedulerTab,
|
||||
type TaskMessage,
|
||||
} from './schedulerConstants'
|
||||
|
||||
export function useSchedulerLogic() {
|
||||
// 核心状态
|
||||
const schedulerTabs = ref<SchedulerTab[]>([
|
||||
{
|
||||
key: 'main',
|
||||
title: '主调度台',
|
||||
closable: false,
|
||||
status: '新建',
|
||||
selectedTaskId: null,
|
||||
selectedMode: TaskCreateIn.mode.AutoMode,
|
||||
websocketId: null,
|
||||
taskQueue: [],
|
||||
userQueue: [],
|
||||
logs: [],
|
||||
isLogAtBottom: true,
|
||||
lastLogContent: '',
|
||||
},
|
||||
])
|
||||
|
||||
const activeSchedulerTab = ref('main')
|
||||
const logRefs = ref(new Map<string, HTMLElement>())
|
||||
let tabCounter = 1
|
||||
|
||||
// 任务选项
|
||||
const taskOptionsLoading = ref(false)
|
||||
const taskOptions = ref<ComboBoxItem[]>([])
|
||||
|
||||
// 电源操作
|
||||
const powerAction = ref<PowerIn.signal>(PowerIn.signal.NO_ACTION)
|
||||
const powerCountdownVisible = ref(false)
|
||||
const powerCountdown = ref(10)
|
||||
let powerCountdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 消息弹窗
|
||||
const messageModalVisible = ref(false)
|
||||
const currentMessage = ref<TaskMessage | null>(null)
|
||||
const messageResponse = ref('')
|
||||
|
||||
// WebSocket 实例
|
||||
const ws = useWebSocket()
|
||||
|
||||
// 计算属性
|
||||
const canChangePowerAction = computed(() => {
|
||||
return !schedulerTabs.value.some(tab => tab.status === '运行')
|
||||
})
|
||||
|
||||
const currentTab = computed(() => {
|
||||
return schedulerTabs.value.find(tab => tab.key === activeSchedulerTab.value)
|
||||
})
|
||||
|
||||
// Tab 管理
|
||||
const addSchedulerTab = () => {
|
||||
tabCounter++
|
||||
const tab: SchedulerTab = {
|
||||
key: `tab-${tabCounter}`,
|
||||
title: `调度台${tabCounter}`,
|
||||
closable: true,
|
||||
status: '新建',
|
||||
selectedTaskId: null,
|
||||
selectedMode: TaskCreateIn.mode.AutoMode,
|
||||
websocketId: null,
|
||||
taskQueue: [],
|
||||
userQueue: [],
|
||||
logs: [],
|
||||
isLogAtBottom: true,
|
||||
lastLogContent: '',
|
||||
}
|
||||
schedulerTabs.value.push(tab)
|
||||
activeSchedulerTab.value = tab.key
|
||||
}
|
||||
|
||||
const removeSchedulerTab = (key: string) => {
|
||||
const tab = schedulerTabs.value.find(t => t.key === key)
|
||||
if (!tab) return
|
||||
|
||||
if (tab.status === '运行') {
|
||||
Modal.warning({
|
||||
title: '无法删除调度台',
|
||||
content: `调度台 "${tab.title}" 正在运行中,无法删除。请先停止当前任务。`,
|
||||
okText: '知道了',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'main') {
|
||||
message.warning('主调度台无法删除')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除调度台 "${tab.title}" 吗?删除后无法恢复。`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk() {
|
||||
const idx = schedulerTabs.value.findIndex(t => t.key === key)
|
||||
if (idx === -1) return
|
||||
|
||||
// 清理 WebSocket 订阅
|
||||
if (tab.websocketId) {
|
||||
ws.unsubscribe(tab.websocketId)
|
||||
}
|
||||
|
||||
// 清理日志引用
|
||||
logRefs.value.delete(key)
|
||||
|
||||
schedulerTabs.value.splice(idx, 1)
|
||||
|
||||
if (activeSchedulerTab.value === key) {
|
||||
const newActiveIndex = Math.max(0, idx - 1)
|
||||
activeSchedulerTab.value = schedulerTabs.value[newActiveIndex]?.key || 'main'
|
||||
}
|
||||
|
||||
message.success(`调度台 "${tab.title}" 已删除`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 任务操作
|
||||
const startTask = async (tab: SchedulerTab) => {
|
||||
if (!tab.selectedTaskId || !tab.selectedMode) {
|
||||
message.error('请选择任务项和执行模式')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await Service.addTaskApiDispatchStartPost({
|
||||
taskId: tab.selectedTaskId,
|
||||
mode: tab.selectedMode,
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
tab.status = '运行'
|
||||
tab.websocketId = response.websocketId
|
||||
|
||||
// 清空之前的状态
|
||||
tab.taskQueue.splice(0)
|
||||
tab.userQueue.splice(0)
|
||||
tab.logs.splice(0)
|
||||
tab.isLogAtBottom = true
|
||||
tab.lastLogContent = ''
|
||||
|
||||
subscribeToTask(tab)
|
||||
message.success('任务启动成功')
|
||||
} else {
|
||||
message.error(response.message || '启动任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动任务失败:', error)
|
||||
message.error('启动任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
const stopTask = async (tab: SchedulerTab) => {
|
||||
if (!tab.websocketId) return
|
||||
|
||||
try {
|
||||
await Service.stopTaskApiDispatchStopPost({ taskId: tab.websocketId })
|
||||
|
||||
if (tab.websocketId) {
|
||||
ws.unsubscribe(tab.websocketId)
|
||||
}
|
||||
|
||||
tab.status = '结束'
|
||||
tab.websocketId = null
|
||||
|
||||
message.success('任务已停止')
|
||||
checkAllTasksCompleted()
|
||||
} catch (error) {
|
||||
console.error('停止任务失败:', error)
|
||||
message.error('停止任务失败')
|
||||
|
||||
// 即使 API 调用失败也要清理本地状态
|
||||
if (tab.websocketId) {
|
||||
ws.unsubscribe(tab.websocketId)
|
||||
tab.status = '结束'
|
||||
tab.websocketId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 订阅与消息处理
|
||||
const subscribeToTask = (tab: SchedulerTab) => {
|
||||
if (!tab.websocketId) return
|
||||
|
||||
ws.subscribe(tab.websocketId, {
|
||||
onProgress: data =>
|
||||
handleWebSocketMessage(tab, { ...data, type: 'Update', id: tab.websocketId }),
|
||||
onResult: data =>
|
||||
handleWebSocketMessage(tab, { ...data, type: 'Result', id: tab.websocketId }),
|
||||
onError: data => handleWebSocketMessage(tab, { ...data, type: 'Error', id: tab.websocketId }),
|
||||
onNotify: data => handleWebSocketMessage(tab, { ...data, type: 'Info', id: tab.websocketId }),
|
||||
})
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = (tab: SchedulerTab, wsMessage: any) => {
|
||||
if (!wsMessage || typeof wsMessage !== 'object') return
|
||||
|
||||
const { id, type, data } = wsMessage
|
||||
|
||||
// 只处理与当前标签页相关的消息,除非是全局信号
|
||||
if (id && id !== tab.websocketId && type !== 'Signal') return
|
||||
|
||||
switch (type) {
|
||||
case 'Update':
|
||||
handleUpdateMessage(tab, data)
|
||||
break
|
||||
case 'Info':
|
||||
handleInfoMessage(tab, data)
|
||||
break
|
||||
case 'Message':
|
||||
handleMessageDialog(tab, data)
|
||||
break
|
||||
case 'Signal':
|
||||
handleSignalMessage(tab, data)
|
||||
break
|
||||
default:
|
||||
console.warn('未知的WebSocket消息类型:', type)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMessage = (tab: SchedulerTab, data: any) => {
|
||||
// 更新任务队列
|
||||
if (data.task_list && Array.isArray(data.task_list)) {
|
||||
const newTaskQueue = data.task_list.map((item: any) => ({
|
||||
name: item.name || '未知任务',
|
||||
status: item.status || '未知',
|
||||
}))
|
||||
tab.taskQueue.splice(0, tab.taskQueue.length, ...newTaskQueue)
|
||||
}
|
||||
|
||||
// 更新用户队列
|
||||
if (data.user_list && Array.isArray(data.user_list)) {
|
||||
const newUserQueue = data.user_list.map((item: any) => ({
|
||||
name: item.name || '未知用户',
|
||||
status: item.status || '未知',
|
||||
}))
|
||||
tab.userQueue.splice(0, tab.userQueue.length, ...newUserQueue)
|
||||
}
|
||||
|
||||
// 处理日志
|
||||
if (data.log) {
|
||||
if (typeof data.log === 'string') {
|
||||
addLog(tab, data.log, 'info')
|
||||
} else if (typeof data.log === 'object') {
|
||||
if (data.log.Error) addLog(tab, data.log.Error, 'error')
|
||||
else if (data.log.Warning) addLog(tab, data.log.Warning, 'warning')
|
||||
else if (data.log.Info) addLog(tab, data.log.Info, 'info')
|
||||
else addLog(tab, JSON.stringify(data.log), 'info')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleInfoMessage = (tab: SchedulerTab, data: any) => {
|
||||
if (data.Error) {
|
||||
notification.error({ message: '任务错误', description: data.Error })
|
||||
} else if (data.Warning) {
|
||||
notification.warning({ message: '任务警告', description: data.Warning })
|
||||
} else if (data.Info) {
|
||||
notification.info({ message: '任务信息', description: data.Info })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageDialog = (tab: SchedulerTab, data: any) => {
|
||||
if (data.title && data.content) {
|
||||
currentMessage.value = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
needInput: data.needInput || false,
|
||||
messageId: data.messageId,
|
||||
taskId: tab.websocketId || undefined,
|
||||
}
|
||||
messageModalVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignalMessage = (tab: SchedulerTab, data: any) => {
|
||||
if (data.Accomplish) {
|
||||
tab.status = '结束'
|
||||
|
||||
if (tab.websocketId) {
|
||||
ws.unsubscribe(tab.websocketId)
|
||||
tab.websocketId = null
|
||||
}
|
||||
|
||||
notification.success({ message: '任务完成', description: data.Accomplish })
|
||||
checkAllTasksCompleted()
|
||||
}
|
||||
|
||||
if (data.power && data.power !== 'NoAction') {
|
||||
powerAction.value = data.power as PowerIn.signal
|
||||
startPowerCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
// 日志管理
|
||||
const addLog = (tab: SchedulerTab, message: string, type: LogEntry['type'] = 'info') => {
|
||||
const logEntry: LogEntry = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
message,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
tab.logs.push(logEntry)
|
||||
|
||||
// 限制日志条数
|
||||
if (tab.logs.length > LOG_MAX_LENGTH) {
|
||||
tab.logs.splice(0, tab.logs.length - LOG_MAX_LENGTH)
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
if (tab.isLogAtBottom) {
|
||||
nextTick(() => {
|
||||
const el = logRefs.value.get(tab.key)
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onLogScroll = (tab: SchedulerTab) => {
|
||||
const el = logRefs.value.get(tab.key)
|
||||
if (!el) return
|
||||
|
||||
const threshold = 5
|
||||
tab.isLogAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
|
||||
}
|
||||
|
||||
const setLogRef = (el: HTMLElement | null, key: string) => {
|
||||
if (el) {
|
||||
logRefs.value.set(key, el)
|
||||
} else {
|
||||
logRefs.value.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 电源操作
|
||||
const onPowerActionChange = (value: PowerIn.signal) => {
|
||||
powerAction.value = value
|
||||
}
|
||||
|
||||
const startPowerCountdown = () => {
|
||||
if (powerAction.value === PowerIn.signal.NO_ACTION) return
|
||||
|
||||
powerCountdownVisible.value = true
|
||||
powerCountdown.value = 10
|
||||
|
||||
powerCountdownTimer = setInterval(() => {
|
||||
powerCountdown.value--
|
||||
if (powerCountdown.value <= 0) {
|
||||
if (powerCountdownTimer) {
|
||||
clearInterval(powerCountdownTimer)
|
||||
powerCountdownTimer = null
|
||||
}
|
||||
powerCountdownVisible.value = false
|
||||
executePowerAction()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const executePowerAction = async () => {
|
||||
try {
|
||||
await Service.powerTaskApiDispatchPowerPost({ signal: powerAction.value })
|
||||
message.success(`${getPowerActionText(powerAction.value)}命令已发送`)
|
||||
} catch (error) {
|
||||
console.error('执行电源操作失败:', error)
|
||||
message.error('执行电源操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPowerAction = () => {
|
||||
if (powerCountdownTimer) {
|
||||
clearInterval(powerCountdownTimer)
|
||||
powerCountdownTimer = null
|
||||
}
|
||||
powerCountdownVisible.value = false
|
||||
powerCountdown.value = 10
|
||||
// 注意:这里不重置 powerAction,保留用户选择
|
||||
}
|
||||
|
||||
const checkAllTasksCompleted = () => {
|
||||
const hasRunningTasks = schedulerTabs.value.some(tab => tab.status === '运行')
|
||||
|
||||
if (!hasRunningTasks && powerAction.value !== PowerIn.signal.NO_ACTION) {
|
||||
startPowerCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
// 消息弹窗操作
|
||||
const sendMessageResponse = () => {
|
||||
if (currentMessage.value?.taskId) {
|
||||
ws.sendRaw(
|
||||
'Response',
|
||||
{
|
||||
messageId: currentMessage.value.messageId,
|
||||
response: messageResponse.value,
|
||||
},
|
||||
currentMessage.value.taskId
|
||||
)
|
||||
}
|
||||
|
||||
messageModalVisible.value = false
|
||||
messageResponse.value = ''
|
||||
currentMessage.value = null
|
||||
}
|
||||
|
||||
const cancelMessage = () => {
|
||||
messageModalVisible.value = false
|
||||
messageResponse.value = ''
|
||||
currentMessage.value = null
|
||||
}
|
||||
|
||||
// 任务选项加载
|
||||
const loadTaskOptions = async () => {
|
||||
try {
|
||||
taskOptionsLoading.value = true
|
||||
const response = await Service.getTaskComboxApiInfoComboxTaskPost()
|
||||
if (response.code === 200) {
|
||||
taskOptions.value = response.data
|
||||
} else {
|
||||
message.error('获取任务列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
message.error('获取任务列表失败')
|
||||
} finally {
|
||||
taskOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
if (powerCountdownTimer) {
|
||||
clearInterval(powerCountdownTimer)
|
||||
}
|
||||
|
||||
schedulerTabs.value.forEach(tab => {
|
||||
if (tab.websocketId) {
|
||||
ws.unsubscribe(tab.websocketId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
schedulerTabs,
|
||||
activeSchedulerTab,
|
||||
logRefs,
|
||||
taskOptionsLoading,
|
||||
taskOptions,
|
||||
powerAction,
|
||||
powerCountdownVisible,
|
||||
powerCountdown,
|
||||
messageModalVisible,
|
||||
currentMessage,
|
||||
messageResponse,
|
||||
|
||||
// 计算属性
|
||||
canChangePowerAction,
|
||||
currentTab,
|
||||
|
||||
// Tab 管理
|
||||
addSchedulerTab,
|
||||
removeSchedulerTab,
|
||||
|
||||
// 任务操作
|
||||
startTask,
|
||||
stopTask,
|
||||
|
||||
// 日志操作
|
||||
addLog,
|
||||
onLogScroll,
|
||||
setLogRef,
|
||||
|
||||
// 电源操作
|
||||
onPowerActionChange,
|
||||
cancelPowerAction,
|
||||
|
||||
// 消息操作
|
||||
sendMessageResponse,
|
||||
cancelMessage,
|
||||
|
||||
// 初始化与清理
|
||||
loadTaskOptions,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user