fix: 重整调度中心的UI

This commit is contained in:
DLmaster361
2025-09-20 17:02:51 +08:00
parent 199907eb26
commit a5e09bc489
8 changed files with 916 additions and 335 deletions

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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,

331
package-lock.json generated Normal file
View File

@@ -0,0 +1,331 @@
{
"name": "AUTO-MAS",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"vue-draggable-next": "^2.3.0",
"vuedraggable": "^4.1.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT",
"peer": true
},
"node_modules/@vue/compiler-core": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz",
"integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/shared": "3.5.21",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz",
"integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz",
"integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/compiler-core": "3.5.21",
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-ssr": "3.5.21",
"@vue/shared": "3.5.21",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.18",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz",
"integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz",
"integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz",
"integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz",
"integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.21",
"@vue/runtime-core": "3.5.21",
"@vue/shared": "3.5.21",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz",
"integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.21",
"@vue/shared": "3.5.21"
},
"peerDependencies": {
"vue": "3.5.21"
}
},
"node_modules/@vue/shared": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz",
"integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==",
"license": "MIT",
"peer": true
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC",
"peer": true
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vue": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
"@vue/runtime-dom": "3.5.21",
"@vue/server-renderer": "3.5.21",
"@vue/shared": "3.5.21"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-draggable-next": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz",
"integrity": "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w==",
"license": "MIT",
"peerDependencies": {
"sortablejs": "^1.14.0",
"vue": "^3.5.17"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
}
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"vue-draggable-next": "^2.3.0",
"vuedraggable": "^4.1.0"
}
}