fix: 重整调度中心的UI
This commit is contained in:
@@ -1,89 +1,101 @@
|
||||
<template>
|
||||
<div class="log-panel">
|
||||
<a-card class="section-card" :bordered="false">
|
||||
<template #title>
|
||||
<div class="section-header">
|
||||
<h3>日志</h3>
|
||||
<div class="log-controls">
|
||||
<a-space size="small">
|
||||
<a-button @click="clearLogs" :disabled="logs.length === 0" size="small">
|
||||
清空日志
|
||||
</a-button>
|
||||
<a-button @click="scrollToBottom" :disabled="logs.length === 0" size="small">
|
||||
滚动到底部
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="log-content" :ref="setLogRef" @scroll="onScroll">
|
||||
<div v-if="logs.length === 0" class="empty-state-mini">
|
||||
<a-empty description="暂无日志信息" />
|
||||
</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 class="section-header">
|
||||
<h3>日志</h3>
|
||||
<div class="log-controls">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
@click="toggleLogMode"
|
||||
size="small"
|
||||
:type="logMode === 'follow' ? 'primary' : 'default'"
|
||||
>
|
||||
{{ logMode === 'follow' ? '跟随模式' : '自由浏览' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="log-content" ref="logContentRef" @scroll="onScroll">
|
||||
<div v-if="!logContent" class="empty-state-mini">
|
||||
<a-empty description="暂无日志信息" />
|
||||
</div>
|
||||
<pre v-else class="log-text">{{ logContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import type { LogEntry } from './schedulerConstants'
|
||||
import { ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
logs: LogEntry[]
|
||||
logContent: string
|
||||
tabKey: string
|
||||
isLogAtBottom: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'scroll', isAtBottom: boolean): void
|
||||
|
||||
(e: 'setRef', el: HTMLElement | null, key: string): void
|
||||
|
||||
(e: 'clearLogs'): void
|
||||
}
|
||||
|
||||
// 日志显示模式类型
|
||||
type LogMode = 'follow' | 'browse'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const setLogRef = (el: HTMLElement | null) => {
|
||||
emit('setRef', el, props.tabKey)
|
||||
}
|
||||
const logContentRef = ref<HTMLElement | null>(null)
|
||||
// 默认为跟随模式
|
||||
const logMode = ref<LogMode>('follow')
|
||||
|
||||
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 toggleLogMode = () => {
|
||||
logMode.value = logMode.value === 'follow' ? 'browse' : 'follow'
|
||||
// 切换到跟随模式时,自动滚动到底部
|
||||
if (logMode.value === 'follow') {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
})
|
||||
if (logContentRef.value) {
|
||||
logContentRef.value.scrollTop = logContentRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
emit('clearLogs')
|
||||
const onScroll = () => {
|
||||
if (logContentRef.value) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContentRef.value
|
||||
const isAtBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 1
|
||||
emit('scroll', isAtBottom)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听日志变化,根据模式决定是否自动滚动
|
||||
watch(
|
||||
() => props.logContent,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
// 跟随模式下自动滚动到底部
|
||||
if (logMode.value === 'follow' && logContentRef.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 组件挂载时设置引用
|
||||
onMounted(() => {
|
||||
if (logContentRef.value) {
|
||||
emit('setRef', logContentRef.value, props.tabKey)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载前清理引用
|
||||
onUnmounted(() => {
|
||||
emit('setRef', null, props.tabKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -91,24 +103,11 @@ const clearLogs = () => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: var(--ant-color-bg-container);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-card :deep(.ant-card-head) {
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
padding: 0 16px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.section-card :deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
height: calc(100% - 52px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -116,6 +115,9 @@ const clearLogs = () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
@@ -126,143 +128,63 @@ const clearLogs = () => {
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: var(--ant-color-bg-container);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--ant-color-text-secondary);
|
||||
margin-right: 12px;
|
||||
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: 4px 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: 4px 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: 4px solid var(--ant-color-success);
|
||||
}
|
||||
|
||||
.log-success .log-message {
|
||||
color: var(--ant-color-success-text);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-card {
|
||||
.log-panel {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.section-card :deep(.ant-card-head) {
|
||||
background: var(--ant-color-bg-layout, #141414);
|
||||
.section-header {
|
||||
border-bottom: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.section-card :deep(.ant-card-body) {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--ant-color-text-secondary, #bfbfbf);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
.log-text {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
background-color: rgba(255, 77, 79, 0.1);
|
||||
border-left: 4px 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: 4px 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: 4px solid var(--ant-color-success, #52c41a);
|
||||
}
|
||||
|
||||
.log-success .log-message {
|
||||
color: var(--ant-color-success, #73d13d);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-content {
|
||||
.log-panel {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section-card :deep(.ant-card-head) {
|
||||
padding: 0 16px;
|
||||
.log-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,25 +11,30 @@
|
||||
<div v-if="items.length === 0" class="empty-state-mini">
|
||||
<a-empty :description="emptyText" />
|
||||
</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 === '运行' }"
|
||||
<div v-else class="queue-tree">
|
||||
<a-tree
|
||||
:tree-data="treeData"
|
||||
:expanded-keys="expandedKeys"
|
||||
@expand="handleExpand"
|
||||
:show-line="showLine"
|
||||
:selectable="false"
|
||||
:show-icon="false"
|
||||
class="queue-tree-view"
|
||||
>
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<a-tag :color="getStatusColor(item.status)" size="small">
|
||||
{{ item.status }}
|
||||
<template #title="{ title, status, type }">
|
||||
<div class="tree-item-content">
|
||||
<span class="item-name">{{ title }}</span>
|
||||
<a-tag
|
||||
v-if="status"
|
||||
:color="getStatusColor(status)"
|
||||
size="small"
|
||||
class="status-tag"
|
||||
>
|
||||
{{ status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<p class="item-name">{{ item.name }}</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-tree>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -37,7 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getQueueStatusColor, type QueueItem } from './schedulerConstants'
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { getQueueStatusColor, type QueueItem } from './schedulerConstants';
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -50,7 +56,69 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
emptyText: '暂无数据',
|
||||
})
|
||||
|
||||
const getStatusColor = (status: string) => getQueueStatusColor(status)
|
||||
// 树形数据结构
|
||||
const treeData = computed(() => {
|
||||
// 为任务队列构建树形结构
|
||||
if (props.type === 'task') {
|
||||
return props.items.map((item, index) => ({
|
||||
key: `task-${index}`,
|
||||
title: item.name,
|
||||
status: item.status,
|
||||
type: 'task'
|
||||
}));
|
||||
}
|
||||
// 为用户队列构建树形结构
|
||||
else {
|
||||
// 按照名称前缀分组
|
||||
const groups: Record<string, any[]> = {};
|
||||
props.items.forEach((item, index) => {
|
||||
const prefix = item.name.split('-')[0] || '默认分组';
|
||||
if (!groups[prefix]) {
|
||||
groups[prefix] = [];
|
||||
}
|
||||
groups[prefix].push({
|
||||
key: `user-${index}`,
|
||||
title: item.name,
|
||||
status: item.status,
|
||||
type: 'user'
|
||||
});
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
return Object.entries(groups).map(([groupName, groupItems]) => {
|
||||
if (groupItems.length === 1) {
|
||||
// 如果组内只有一个项目,直接返回该项目
|
||||
return groupItems[0];
|
||||
} else {
|
||||
// 如果组内有多个项目,创建一个父节点
|
||||
return {
|
||||
key: `group-${groupName}`,
|
||||
title: groupName,
|
||||
type: 'group',
|
||||
children: groupItems
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
const showLine = computed(() => ({ showLeafIcon: false }));
|
||||
|
||||
const getStatusColor = (status: string) => getQueueStatusColor(status);
|
||||
|
||||
// 处理展开/收起事件
|
||||
const handleExpand = (keys: string[]) => {
|
||||
expandedKeys.value = keys;
|
||||
};
|
||||
|
||||
// 监听items变化,自动展开所有节点
|
||||
watch(() => props.items, () => {
|
||||
// 默认展开所有节点
|
||||
expandedKeys.value = treeData.value
|
||||
.filter(node => node.children)
|
||||
.map(node => node.key as string);
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -104,45 +172,49 @@ const getStatusColor = (status: string) => getQueueStatusColor(status)
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.queue-cards {
|
||||
.queue-tree {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.queue-tree-view {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.queue-tree-view :deep(.ant-tree-treenode) {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.queue-tree-view :deep(.ant-tree-node-content-wrapper) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--ant-color-bg-layout);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
.queue-tree-view :deep(.ant-tree-node-content-wrapper:hover) {
|
||||
background-color: var(--ant-color-fill-secondary);
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
box-shadow: 0 4px 12px var(--ant-color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--ant-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
.tree-item-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding-top: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text);
|
||||
word-break: break-word;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@@ -165,18 +237,8 @@ const getStatusColor = (status: string) => getQueueStatusColor(status)
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
background-color: var(--ant-color-bg-layout, #141414);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--ant-color-primary, #1890ff);
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
.queue-tree-view :deep(.ant-tree-node-content-wrapper:hover) {
|
||||
background-color: var(--ant-color-fill);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="task-control">
|
||||
<a-card class="control-card" :bordered="false">
|
||||
<div class="control-card">
|
||||
<div class="control-row">
|
||||
<a-space size="middle">
|
||||
<a-select
|
||||
@@ -31,21 +31,26 @@
|
||||
<div class="control-spacer"></div>
|
||||
<a-space size="middle">
|
||||
<a-button
|
||||
v-if="status !== '运行'"
|
||||
type="primary"
|
||||
@click="onStart"
|
||||
:icon="h(PlayCircleOutlined)"
|
||||
:disabled="!canStart"
|
||||
type="primary"
|
||||
:disabled="disabled || !localSelectedTaskId || !localSelectedMode"
|
||||
size="large"
|
||||
>
|
||||
开始任务
|
||||
<template #icon><PlayCircleOutlined /></template>
|
||||
开始执行
|
||||
</a-button>
|
||||
<a-button v-else danger @click="onStop" :icon="h(StopOutlined)" size="large">
|
||||
中止任务
|
||||
<a-button
|
||||
@click="onStop"
|
||||
:disabled="status !== '运行'"
|
||||
danger
|
||||
size="large"
|
||||
>
|
||||
<template #icon><StopOutlined /></template>
|
||||
停止任务
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -136,56 +141,41 @@ const filterTaskOption = (input: string, option: any) => {
|
||||
<style scoped>
|
||||
.task-control {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.control-card :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--ant-color-bg-container);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.control-card {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.control-row {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式 - 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.control-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-card :deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.control-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
230
frontend/src/views/scheduler/TaskOverviewPanel.vue
Normal file
230
frontend/src/views/scheduler/TaskOverviewPanel.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="overview-panel">
|
||||
<div class="section-header">
|
||||
<h3>任务总览</h3>
|
||||
</div>
|
||||
<div class="overview-content">
|
||||
<div v-if="treeData.length === 0" class="empty-state-mini">
|
||||
<a-empty description="暂无任务" />
|
||||
</div>
|
||||
<div v-else class="overview-tree">
|
||||
<a-tree
|
||||
:tree-data="treeData"
|
||||
:expanded-keys="expandedKeys"
|
||||
@expand="handleExpand"
|
||||
:show-line="showLine"
|
||||
:selectable="false"
|
||||
:show-icon="false"
|
||||
class="overview-tree-view"
|
||||
>
|
||||
<template #title="{ title, status, type }">
|
||||
<div class="tree-item-content">
|
||||
<span class="item-name">{{ title }}</span>
|
||||
<a-tag
|
||||
v-if="status"
|
||||
:color="getStatusColor(status)"
|
||||
size="small"
|
||||
class="status-tag"
|
||||
>
|
||||
{{ status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</a-tree>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { getQueueStatusColor, type QueueItem } from './schedulerConstants';
|
||||
|
||||
interface Props {
|
||||
taskQueue: QueueItem[];
|
||||
userQueue: QueueItem[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 树形数据结构
|
||||
const treeData = computed(() => {
|
||||
// 构建脚本节点
|
||||
const scriptNodes = props.taskQueue.map((task, index) => {
|
||||
// 查找相关的用户
|
||||
const relatedUsers = props.userQueue.filter(user =>
|
||||
user.name.startsWith(`${task.name}-`)
|
||||
);
|
||||
|
||||
// 构建用户子节点
|
||||
const userChildren = relatedUsers.map((user, userIndex) => ({
|
||||
key: `user-${index}-${userIndex}`,
|
||||
title: user.name.replace(`${task.name}-`, ''), // 移除前缀以避免重复
|
||||
status: user.status,
|
||||
type: 'user'
|
||||
}));
|
||||
|
||||
// 构建脚本节点
|
||||
const scriptNode: any = {
|
||||
key: `script-${index}`,
|
||||
title: task.name,
|
||||
status: task.status,
|
||||
type: 'script'
|
||||
};
|
||||
|
||||
// 如果有相关用户,添加为子节点
|
||||
if (userChildren.length > 0) {
|
||||
scriptNode.children = userChildren;
|
||||
}
|
||||
|
||||
return scriptNode;
|
||||
});
|
||||
|
||||
return scriptNodes;
|
||||
});
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
const showLine = computed(() => ({ showLeafIcon: false }));
|
||||
|
||||
const getStatusColor = (status: string) => getQueueStatusColor(status);
|
||||
|
||||
// 处理展开/收起事件
|
||||
const handleExpand = (keys: string[]) => {
|
||||
expandedKeys.value = keys;
|
||||
};
|
||||
|
||||
// 监听任务队列变化,自动展开所有节点
|
||||
watch(() => props.taskQueue, (newTaskQueue) => {
|
||||
// 默认展开所有脚本节点
|
||||
expandedKeys.value = newTaskQueue.map((_, index) => `script-${index}`);
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听用户队列变化,更新展开状态
|
||||
watch(() => props.userQueue, () => {
|
||||
// 重新计算展开状态,确保新增的节点能正确显示
|
||||
expandedKeys.value = props.taskQueue.map((_, index) => `script-${index}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--ant-color-bg-container);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-heading);
|
||||
}
|
||||
|
||||
.overview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overview-tree {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overview-tree-view {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overview-tree-view :deep(.ant-tree-treenode) {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.overview-tree-view :deep(.ant-tree-node-content-wrapper) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.overview-tree-view :deep(.ant-tree-node-content-wrapper:hover) {
|
||||
background-color: var(--ant-color-fill-secondary);
|
||||
}
|
||||
|
||||
.tree-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text);
|
||||
word-break: break-word;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.overview-panel {
|
||||
background: var(--ant-color-bg-container, #1f1f1f);
|
||||
border: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
border-bottom: 1px solid var(--ant-color-border, #424242);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
color: var(--ant-color-text-heading, #ffffff);
|
||||
}
|
||||
|
||||
.overview-tree-view :deep(.ant-tree-node-content-wrapper:hover) {
|
||||
background-color: var(--ant-color-fill);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: var(--ant-color-text, #ffffff);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overview-panel {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -53,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<!-- 任务控制与状态内容 -->
|
||||
<div class="task-unified-card">
|
||||
<div class="task-unified-card" :class="`status-${tab.status}`">
|
||||
<!-- 任务控制栏 -->
|
||||
<SchedulerTaskControl
|
||||
v-model:selectedTaskId="tab.selectedTaskId"
|
||||
@@ -67,39 +67,23 @@
|
||||
/>
|
||||
|
||||
<!-- 状态展示区域 -->
|
||||
<a-row :gutter="16" class="status-row">
|
||||
<!-- 任务队列栏 -->
|
||||
<a-col :span="4">
|
||||
<SchedulerQueuePanel
|
||||
title="任务队列"
|
||||
:items="tab.taskQueue"
|
||||
type="task"
|
||||
empty-text="暂无任务队列"
|
||||
<div class="status-container">
|
||||
<div class="overview-panel-container">
|
||||
<TaskOverviewPanel
|
||||
:task-queue="tab.taskQueue"
|
||||
:user-queue="tab.userQueue"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<!-- 用户队列栏 -->
|
||||
<a-col :span="4">
|
||||
<SchedulerQueuePanel
|
||||
title="用户队列"
|
||||
:items="tab.userQueue"
|
||||
type="user"
|
||||
empty-text="暂无用户队列"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<!-- 日志栏 -->
|
||||
<a-col :span="16">
|
||||
</div>
|
||||
<div class="log-panel-container">
|
||||
<SchedulerLogPanel
|
||||
:logs="tab.logs"
|
||||
:log-content="tab.lastLogContent"
|
||||
:tab-key="tab.key"
|
||||
:is-log-at-bottom="tab.isLogAtBottom"
|
||||
@scroll="onLogScroll(tab)"
|
||||
@set-ref="setLogRef"
|
||||
@clear-logs="clearTabLogs(tab)"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
@@ -165,8 +149,8 @@ import {
|
||||
} from './schedulerConstants'
|
||||
import { useSchedulerLogic } from './useSchedulerLogic'
|
||||
import SchedulerTaskControl from './SchedulerTaskControl.vue'
|
||||
import SchedulerQueuePanel from './SchedulerQueuePanel.vue'
|
||||
import SchedulerLogPanel from './SchedulerLogPanel.vue'
|
||||
import TaskOverviewPanel from './TaskOverviewPanel.vue'
|
||||
|
||||
// 使用业务逻辑层
|
||||
const {
|
||||
@@ -219,10 +203,6 @@ const onSchedulerTabEdit = (targetKey: string | MouseEvent, action: 'add' | 'rem
|
||||
}
|
||||
}
|
||||
|
||||
// 清空指定标签页的日志
|
||||
const clearTabLogs = (tab: SchedulerTab) => {
|
||||
tab.logs.splice(0)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
@@ -241,7 +221,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
background-color: var(--ant-color-bg-layout);
|
||||
}
|
||||
|
||||
@@ -300,6 +279,55 @@ onUnmounted(() => {
|
||||
background-color: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
/* 任务卡片统一容器 */
|
||||
.task-unified-card {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
height: calc(100vh - 230px); /* 动态计算高度 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 根据状态变化的样式 */
|
||||
.task-unified-card.status-新建 {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.task-unified-card.status-运行 {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.task-unified-card.status-结束 {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 状态容器 */
|
||||
.status-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 任务总览面板容器 */
|
||||
.overview-panel-container {
|
||||
flex: 0 0 33.333333%; /* 占据1/3宽度 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 日志面板容器 */
|
||||
.log-panel-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 响应式 - 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.scheduler-main {
|
||||
@@ -320,5 +348,15 @@ onUnmounted(() => {
|
||||
.power-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview-panel-container,
|
||||
.log-panel-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ 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 type { QueueItem } from './schedulerConstants'
|
||||
import {
|
||||
getPowerActionText,
|
||||
LOG_MAX_LENGTH,
|
||||
@@ -333,13 +334,41 @@ export function useSchedulerLogic() {
|
||||
}
|
||||
|
||||
const handleUpdateMessage = (tab: SchedulerTab, data: any) => {
|
||||
// 处理task_dict初始化消息
|
||||
if (data.task_dict && Array.isArray(data.task_dict)) {
|
||||
// 初始化任务队列
|
||||
const newTaskQueue = data.task_dict.map((item: any) => ({
|
||||
name: item.name || '未知任务',
|
||||
status: '等待',
|
||||
}));
|
||||
|
||||
// 初始化用户队列(仅包含运行状态下的用户)
|
||||
const newUserQueue: QueueItem[] = [];
|
||||
data.task_dict.forEach((taskItem: any) => {
|
||||
if (taskItem.user_list && Array.isArray(taskItem.user_list)) {
|
||||
taskItem.user_list.forEach((user: any) => {
|
||||
// 只有在用户状态为运行时才添加到用户队列中
|
||||
if (user.status === '运行') {
|
||||
newUserQueue.push({
|
||||
name: `${taskItem.name}-${user.name}`,
|
||||
status: user.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
tab.taskQueue.splice(0, tab.taskQueue.length, ...newTaskQueue);
|
||||
tab.userQueue.splice(0, tab.userQueue.length, ...newUserQueue);
|
||||
}
|
||||
|
||||
// 更新任务队列
|
||||
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)
|
||||
}));
|
||||
tab.taskQueue.splice(0, tab.taskQueue.length, ...newTaskQueue);
|
||||
}
|
||||
|
||||
// 更新用户队列
|
||||
@@ -347,19 +376,20 @@ export function useSchedulerLogic() {
|
||||
const newUserQueue = data.user_list.map((item: any) => ({
|
||||
name: item.name || '未知用户',
|
||||
status: item.status || '未知',
|
||||
}))
|
||||
tab.userQueue.splice(0, tab.userQueue.length, ...newUserQueue)
|
||||
}));
|
||||
tab.userQueue.splice(0, tab.userQueue.length, ...newUserQueue);
|
||||
}
|
||||
|
||||
// 处理日志
|
||||
// 处理日志 - 直接显示完整日志内容,覆盖上次显示的内容
|
||||
if (data.log) {
|
||||
if (typeof data.log === 'string') {
|
||||
addLog(tab, data.log, 'info')
|
||||
// 直接替换日志内容,不添加时间戳,不保留历史记录
|
||||
tab.lastLogContent = data.log;
|
||||
} 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')
|
||||
if (data.log.Error) tab.lastLogContent = data.log.Error;
|
||||
else if (data.log.Warning) tab.lastLogContent = data.log.Warning;
|
||||
else if (data.log.Info) tab.lastLogContent = data.log.Info;
|
||||
else tab.lastLogContent = JSON.stringify(data.log);
|
||||
}
|
||||
}
|
||||
saveTabsToStorage(schedulerTabs.value)
|
||||
@@ -409,33 +439,6 @@ export function useSchedulerLogic() {
|
||||
}
|
||||
}
|
||||
|
||||
// 日志管理
|
||||
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
|
||||
@@ -589,7 +592,6 @@ export function useSchedulerLogic() {
|
||||
stopTask,
|
||||
|
||||
// 日志操作
|
||||
addLog,
|
||||
onLogScroll,
|
||||
setLogRef,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user