feat(queue): 初步实现调度队列功能

- 添加队列列表获取、队列数据加载和保存功能
- 实现队列名称编辑、状态切换和删除功能
- 添加定时项和队列项的数据刷新和展示
- 优化队列界面样式,包括空状态、队列头部、配置区域等
This commit is contained in:
2025-08-12 01:34:15 +08:00
parent b4665309b9
commit c4dde028b2
6 changed files with 2661 additions and 740 deletions

View File

@@ -37,6 +37,7 @@
"@types/adm-zip": "^0.5.7",
"adm-zip": "^0.5.16",
"ant-design-vue": "4.x",
"dayjs": "^1.11.13",
"vue": "^3.5.17",
"vue-router": "4"
},

View File

@@ -0,0 +1,485 @@
<template>
<a-card title="队列项" class="queue-item-card" :loading="loading">
<template #extra>
<a-space>
<a-button type="primary" @click="addQueueItem" :loading="loading">
<template #icon>
<PlusOutlined />
</template>
添加队列项
</a-button>
<a-button @click="refreshData" :loading="loading">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
</template>
<div class="queue-items-grid">
<div
v-for="item in queueItems"
:key="item.id"
class="queue-item-card-item"
>
<div class="item-header">
<div class="item-name">{{ item.name || `项目 ${item.id}` }}</div>
<a-dropdown>
<a-button size="small" type="text">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="editQueueItem(item)">
<EditOutlined />
编辑
</a-menu-item>
<a-menu-item @click="deleteQueueItem(item.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="item-content">
<div class="item-info">
<div class="info-row">
<span class="label">状态</span>
<a-tag :color="getStatusColor(item.status)">
{{ getStatusText(item.status) }}
</a-tag>
</div>
<div class="info-row" v-if="item.script">
<span class="label">脚本</span>
<span class="value">{{ item.script }}</span>
</div>
<div class="info-row" v-if="item.plan">
<span class="label">计划</span>
<span class="value">{{ item.plan }}</span>
</div>
<div class="info-row" v-if="item.description">
<span class="label">描述</span>
<span class="value">{{ item.description }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="!queueItems.length && !loading" class="empty-state">
<a-empty description="暂无队列项数据" />
</div>
<!-- 队列项编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="editingQueueItem ? '编辑队列项' : '添加队列项'"
@ok="saveQueueItem"
@cancel="cancelEdit"
:confirm-loading="saving"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
>
<a-form-item label="项目名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入项目名称" />
</a-form-item>
<a-form-item label="关联脚本" name="script">
<a-select
v-model:value="form.script"
placeholder="请选择关联脚本"
allow-clear
:options="scriptOptions"
/>
</a-form-item>
<a-form-item label="关联计划" name="plan">
<a-select
v-model:value="form.plan"
placeholder="请选择关联计划"
allow-clear
:options="planOptions"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-select v-model:value="form.status" placeholder="请选择状态">
<a-select-option value="active">激活</a-select-option>
<a-select-option value="inactive">未激活</a-select-option>
<a-select-option value="pending">等待中</a-select-option>
<a-select-option value="running">运行中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入队列项描述(可选)"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
MoreOutlined
} from '@ant-design/icons-vue'
import { Service } from '@/api'
import type { FormInstance } from 'ant-design-vue'
// Props
interface Props {
queueId: string
queueItems: any[]
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
refresh: []
}>()
// 响应式数据
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingQueueItem = ref<any>(null)
// 选项数据
const scriptOptions = ref<Array<{ label: string; value: string }>>([])
const planOptions = ref<Array<{ label: string; value: string }>>([])
// 表单引用和数据
const formRef = ref<FormInstance>()
const form = reactive({
name: '',
script: '',
plan: '',
status: 'active',
description: ''
})
// 表单验证规则
const rules = {
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
// 计算属性 - 使用props传入的数据
const queueItems = ref(props.queueItems)
// 监听props变化
watch(() => props.queueItems, (newQueueItems) => {
queueItems.value = newQueueItems
}, { deep: true })
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
active: '激活',
inactive: '未激活',
pending: '等待中',
running: '运行中',
completed: '已完成',
failed: '失败'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
active: 'green',
inactive: 'default',
pending: 'orange',
running: 'blue',
completed: 'cyan',
failed: 'red'
}
return colorMap[status] || 'default'
}
// 加载脚本和计划选项
const loadOptions = async () => {
try {
// 加载脚本选项
const scriptsResponse = await Service.getScriptsApiScriptsGetPost({})
if (scriptsResponse.code === 200) {
scriptOptions.value = scriptsResponse.index.map((item: any) => ({
label: Object.values(item)[0] as string,
value: Object.keys(item)[0]
}))
}
// 加载计划选项
const plansResponse = await Service.getPlanApiPlanGetPost({})
if (plansResponse.code === 200) {
planOptions.value = plansResponse.index.map((item: any) => ({
label: Object.values(item)[0] as string,
value: Object.keys(item)[0]
}))
}
} catch (error) {
console.error('加载选项失败:', error)
}
}
// 刷新数据
const refreshData = () => {
emit('refresh')
}
// 添加队列项
const addQueueItem = () => {
editingQueueItem.value = null
Object.assign(form, {
name: '',
script: '',
plan: '',
status: 'active',
description: ''
})
modalVisible.value = true
}
// 编辑队列项
const editQueueItem = (item: any) => {
editingQueueItem.value = item
Object.assign(form, {
name: item.name || '',
script: item.script || '',
plan: item.plan || '',
status: item.status || 'active',
description: item.description || ''
})
modalVisible.value = true
}
// 保存队列项
const saveQueueItem = async () => {
try {
await formRef.value?.validate()
saving.value = true
if (editingQueueItem.value) {
// 更新队列项 - 根据API文档格式
const response = await Service.updateItemApiQueueItemUpdatePost({
queueId: props.queueId,
queueItemId: editingQueueItem.value.id,
data: {
Info: {
name: form.name,
script: form.script,
plan: form.plan,
status: form.status,
description: form.description
}
}
})
if (response.code === 200) {
message.success('队列项更新成功')
} else {
message.error('队列项更新失败: ' + (response.message || '未知错误'))
return
}
} else {
// 添加队列项 - 先创建,再更新
// 1. 先创建队列项只传queueId
const createResponse = await Service.addItemApiQueueItemAddPost({
queueId: props.queueId
})
// 2. 用返回的queueItemId更新队列项数据
if (createResponse.code === 200 && createResponse.queueItemId) {
const updateResponse = await Service.updateItemApiQueueItemUpdatePost({
queueId: props.queueId,
queueItemId: createResponse.queueItemId,
data: {
Info: {
name: form.name,
script: form.script,
plan: form.plan,
status: form.status,
description: form.description
}
}
})
if (updateResponse.code === 200) {
message.success('队列项添加成功')
} else {
message.error('队列项添加失败: ' + (updateResponse.message || '未知错误'))
return
}
} else {
message.error('创建队列项失败: ' + (createResponse.message || '未知错误'))
return
}
}
modalVisible.value = false
emit('refresh')
} catch (error) {
console.error('保存队列项失败:', error)
message.error('保存队列项失败: ' + (error?.message || '网络错误'))
} finally {
saving.value = false
}
}
// 取消编辑
const cancelEdit = () => {
modalVisible.value = false
editingQueueItem.value = null
}
// 删除队列项
const deleteQueueItem = async (itemId: string) => {
try {
const response = await Service.deleteItemApiQueueItemDeletePost({
queueId: props.queueId,
queueItemId: itemId
})
if (response.code === 200) {
message.success('队列项删除成功')
// 确保删除后刷新数据
emit('refresh')
} else {
message.error('删除队列项失败: ' + (response.message || '未知错误'))
}
} catch (error) {
console.error('删除队列项失败:', error)
message.error('删除队列项失败: ' + (error?.message || '网络错误'))
}
}
// 初始化
onMounted(() => {
loadOptions()
})
</script>
<style scoped>
.queue-item-card {
margin-bottom: 24px;
}
.queue-item-card :deep(.ant-card-head-title) {
font-size: 18px;
font-weight: 600;
}
/* 队列项网格样式 */
.queue-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.queue-item-card-item {
background: var(--ant-color-bg-container);
border: 1px solid var(--ant-color-border);
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
}
.queue-item-card-item:hover {
border-color: var(--ant-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.item-name {
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
flex: 1;
min-width: 0;
word-break: break-all;
}
.item-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.label {
color: var(--ant-color-text-secondary);
min-width: 50px;
flex-shrink: 0;
}
.value {
color: var(--ant-color-text);
flex: 1;
min-width: 0;
word-break: break-all;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 40px 0;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.queue-items-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
@media (max-width: 768px) {
.queue-items-grid {
grid-template-columns: 1fr;
}
.queue-item-card-item {
padding: 12px;
}
}
/* 标签样式 */
:deep(.ant-tag) {
margin: 0;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<a-card title="定时项" class="time-set-card" :loading="loading">
<template #extra>
<a-space>
<a-button type="primary" @click="addTimeSet" :loading="loading"
:disabled="!props.queueId || props.queueId.trim() === ''">
<template #icon>
<PlusOutlined />
</template>
添加定时项
</a-button>
<a-button @click="refreshData" :loading="loading">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
</template>
<a-table :columns="timeColumns" :data-source="timeSets" :pagination="false" size="middle" :scroll="{ x: 800 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'enabled'">
<a-switch v-model:checked="record.enabled" @change="updateTimeSetStatus(record)" size="small" />
</template>
<template v-else-if="column.key === 'time'">
<div class="time-display">
{{ record.time || '--:--' }}
</div>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="editTimeSet(record)">
<EditOutlined />
</a-button>
<a-popconfirm title="确定要删除这个定时项吗?" @confirm="deleteTimeSet(record.id)" ok-text="确定" cancel-text="取消">
<a-button size="small" danger>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<div v-if="!timeSets.length && !loading" class="empty-state">
<a-empty :description="!props.queueId ? '请先选择一个队列' : '暂无定时项数据'" />
</div>
<!-- 定时项编辑弹窗 -->
<a-modal v-model:open="modalVisible" :title="editingTimeSet ? '编辑定时项' : '添加定时项'" @ok="saveTimeSet"
@cancel="cancelEdit" :confirm-loading="saving">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item label="执行时间" name="time">
<a-time-picker v-model:value="form.time" format="HH:mm" placeholder="请选择执行时间" style="width: 100%" />
</a-form-item>
<a-form-item label="启用状态" name="enabled">
<a-switch v-model:checked="form.enabled" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { Service } from '@/api'
import type { FormInstance } from 'ant-design-vue'
import dayjs from 'dayjs'
// 时间处理工具函数
const parseTimeString = (timeStr: string) => {
if (!timeStr) return undefined
try {
const [hours, minutes] = timeStr.split(':').map(Number)
if (isNaN(hours) || isNaN(minutes)) return undefined
return dayjs().hour(hours).minute(minutes).second(0).millisecond(0)
} catch {
return undefined
}
}
const formatTimeValue = (timeValue: any) => {
if (!timeValue) return '00:00'
try {
if (dayjs.isDayjs(timeValue)) {
return timeValue.format('HH:mm')
}
return dayjs(timeValue).format('HH:mm')
} catch {
return '00:00'
}
}
// Props
interface Props {
queueId: string
timeSets: any[]
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
refresh: []
}>()
// 响应式数据
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingTimeSet = ref<any>(null)
// 表单引用和数据
const formRef = ref<FormInstance>()
const form = reactive({
time: undefined as any,
enabled: true,
})
// 表单验证规则
const rules = {
time: [{ required: true, message: '请选择执行时间', trigger: 'change' }]
}
// 表格列配置
const timeColumns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 80,
customRender: ({ index }: { index: number }) => index + 1
},
{
title: '执行时间',
dataIndex: 'time',
key: 'time',
width: 150
},
{
title: '启用状态',
dataIndex: 'enabled',
key: 'enabled',
width: 100
},
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right'
}
]
// 计算属性 - 使用props传入的数据
const timeSets = ref([...props.timeSets])
// 监听props变化
watch(() => props.timeSets, (newTimeSets) => {
timeSets.value = [...newTimeSets]
}, { deep: true, immediate: true })
// 刷新数据
const refreshData = () => {
emit('refresh')
}
// 添加定时项
const addTimeSet = () => {
editingTimeSet.value = null
Object.assign(form, {
time: undefined,
enabled: true,
})
modalVisible.value = true
}
// 编辑定时项
const editTimeSet = (timeSet: any) => {
editingTimeSet.value = timeSet
// 安全地处理时间值
const timeValue = parseTimeString(timeSet.time)
Object.assign(form, {
time: timeValue,
enabled: timeSet.enabled,
})
modalVisible.value = true
}
// 保存定时项
const saveTimeSet = async () => {
try {
await formRef.value?.validate()
saving.value = true
// 验证queueId是否存在
if (!props.queueId || props.queueId.trim() === '') {
message.error('队列ID为空无法添加定时项')
saving.value = false
return
}
// 处理时间格式 - 使用工具函数
console.log('form.time:', form.time, 'type:', typeof form.time, 'isDayjs:', dayjs.isDayjs(form.time))
const timeString = formatTimeValue(form.time)
console.log('timeString:', timeString)
if (editingTimeSet.value) {
// 更新定时项
const response = await Service.updateTimeSetApiQueueTimeUpdatePost({
queueId: props.queueId,
timeSetId: editingTimeSet.value.id,
data: {
Info: {
Enabled: form.enabled,
Time: timeString
}
}
})
if (response.code === 200) {
message.success('定时项更新成功')
} else {
message.error('定时项更新失败: ' + (response.message || '未知错误'))
return
}
} else {
// 添加定时项 - 先创建,再更新
const createResponse = await Service.addTimeSetApiQueueTimeAddPost({
queueId: props.queueId
})
if (createResponse.code === 200 && createResponse.timeSetId) {
const updateResponse = await Service.updateTimeSetApiQueueTimeUpdatePost({
queueId: props.queueId,
timeSetId: createResponse.timeSetId,
data: {
Info: {
Enabled: form.enabled,
Time: timeString,
}
}
})
if (updateResponse.code === 200) {
message.success('定时项添加成功')
} else {
message.error('定时项添加失败: ' + (updateResponse.message || '未知错误'))
return
}
} else {
message.error('创建定时项失败: ' + (createResponse.message || '未知错误'))
return
}
}
modalVisible.value = false
emit('refresh')
} catch (error) {
console.error('保存定时项失败:', error)
message.error('保存定时项失败: ' + (error?.message || '网络错误'))
} finally {
saving.value = false
}
}
// 取消编辑
const cancelEdit = () => {
modalVisible.value = false
editingTimeSet.value = null
}
// 更新定时项状态
const updateTimeSetStatus = async (timeSet: any) => {
try {
const response = await Service.updateTimeSetApiQueueTimeUpdatePost({
queueId: props.queueId,
timeSetId: timeSet.id,
data: {
Info: {
Enabled: timeSet.enabled
}
}
})
if (response.code === 200) {
message.success('状态更新成功')
} else {
message.error('状态更新失败: ' + (response.message || '未知错误'))
// 回滚状态
timeSet.enabled = !timeSet.enabled
}
} catch (error) {
console.error('更新状态失败:', error)
message.error('更新状态失败: ' + (error?.message || '网络错误'))
// 回滚状态
timeSet.enabled = !timeSet.enabled
}
}
// 删除定时项
const deleteTimeSet = async (timeSetId: string) => {
try {
const response = await Service.deleteTimeSetApiQueueTimeDeletePost({
queueId: props.queueId,
timeSetId
})
if (response.code === 200) {
message.success('定时项删除成功')
// 确保删除后刷新数据
emit('refresh')
} else {
message.error('删除定时项失败: ' + (response.message || '未知错误'))
}
} catch (error) {
console.error('删除定时项失败:', error)
message.error('删除定时项失败: ' + (error?.message || '网络错误'))
}
}
</script>
<style scoped>
.time-set-card {
margin-bottom: 24px;
}
.time-set-card :deep(.ant-card-head-title) {
font-size: 18px;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 40px 0;
}
/* 表格样式优化 */
:deep(.ant-table-tbody > tr > td) {
padding: 12px 16px;
}
:deep(.ant-table-thead > tr > th) {
background: var(--ant-color-fill-quaternary);
font-weight: 600;
}
/* 时间选择器样式 */
:deep(.ant-picker) {
width: 100%;
}
/* 开关样式 */
:deep(.ant-switch) {
margin: 0;
}
/* 时间显示样式 */
.time-display {
font-family: 'Courier New', monospace;
font-weight: 600;
color: var(--ant-color-text);
padding: 4px 8px;
background: var(--ant-color-fill-quaternary);
border-radius: 4px;
display: inline-block;
min-width: 60px;
text-align: center;
}
</style>

View File

@@ -29,22 +29,27 @@
<!-- 活动信息展示 -->
<div v-if="currentActivity && !loading" class="activity-info">
<div class="activity-header">
<div class="activity-name">
<!-- <CalendarOutlined class="activity-icon" />-->
<span class="activity-title">{{ currentActivity.StageName }}</span>
<a-tag color="blue" class="activity-tip">{{ currentActivity.Tip }}</a-tag>
</div>
<div class="activity-time">
<div class="time-item">
<ClockCircleOutlined class="time-icon" />
<span class="time-label">剩余时间</span>
<span class="time-value remaining">{{ getTimeRemaining(currentActivity.UtcExpireTime, currentActivity.TimeZone) }}</span>
<div class="activity-left">
<div class="activity-name">
<span class="activity-title">{{ currentActivity.StageName }}</span>
<a-tag color="blue" class="activity-tip">{{ currentActivity.Tip }}</a-tag>
</div>
<div class="time-item">
<div class="activity-end-time">
<ClockCircleOutlined class="time-icon" />
<span class="time-label">结束时间</span>
<span class="time-value">{{ formatTime(currentActivity.UtcExpireTime, currentActivity.TimeZone) }}</span>
</div>
</div>
<div class="activity-right">
<a-statistic-countdown
title="当期活动剩余时间"
:value="getCountdownValue(currentActivity.UtcExpireTime)"
format="D 天 H 时 m 分"
:value-style="getCountdownStyle(currentActivity.UtcExpireTime)"
@finish="onCountdownFinish"
/>
</div>
</div>
</div>
@@ -136,31 +141,52 @@ const formatTime = (timeString: string, timeZone: number) => {
}
}
// 计算活动剩余时间
const getTimeRemaining = (expireTime: string, timeZone: number) => {
// 获取倒计时的目标时间
const getCountdownValue = (expireTime: string) => {
try {
return new Date(expireTime).getTime()
} catch {
return Date.now()
}
}
// 获取倒计时样式 - 如果剩余时间小于2天则显示红色
const getCountdownStyle = (expireTime: string) => {
try {
const expire = new Date(expireTime)
const now = new Date()
const remaining = expire.getTime() - now.getTime()
const twoDaysInMs = 2 * 24 * 60 * 60 * 1000
if (remaining <= 0) return '已结束'
if (remaining <= twoDaysInMs) {
return {
color: '#ff4d4f',
fontWeight: 'bold',
fontSize: '18px'
}
}
const days = Math.floor(remaining / (1000 * 60 * 60 * 24))
const hours = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时`
} else {
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
return `${minutes}分钟`
return {
color: 'var(--ant-color-text)',
fontWeight: '600',
fontSize: '20px'
}
} catch {
return '未知'
return {
color: 'var(--ant-color-text)',
fontWeight: '600',
fontSize: '20px'
}
}
}
// 倒计时结束回调
const onCountdownFinish = () => {
message.warning('活动已结束')
// 重新获取数据
fetchActivityData()
}
const getMaterialImage = (dropName: string) => {
try {
return new URL(`../assets/materials/${dropName}.png`, import.meta.url).href
@@ -243,15 +269,35 @@ onMounted(() => {
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.activity-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-right {
flex-shrink: 0;
text-align: right;
}
.activity-end-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.activity-name {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
flex-wrap: wrap;
}

View File

@@ -1,12 +1,755 @@
<template>
<div class="page-container">
<h1>调度队列</h1>
<p>这里是调度队列内容的占位符</p>
<div v-if="loading" class="loading-box">
<a-spin tip="加载中,请稍候..." size="large" />
</div>
<div v-else class="queue-content">
<!-- 队列头部 -->
<div class="queue-header">
<div class="header-title">
<h1>调度队列</h1>
</div>
<a-space size="middle">
<a-button type="primary" size="large" @click="handleAddQueue">
<template #icon>
<PlusOutlined />
</template>
新建队列
</a-button>
<a-button size="large" @click="handleRefresh">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
</div>
<!-- 空状态 -->
<div v-if="!queueList.length || !currentQueueData" class="empty-state">
<div class="empty-content empty-content-fancy" @click="handleAddQueue" style="cursor: pointer">
<div class="empty-icon">
<PlusOutlined />
</div>
<h2>你还没有创建过队列</h2>
<h1>点击此处来新建队列</h1>
</div>
</div>
<!-- 队列内容 -->
<div class="queue-main-content" v-else-if="currentQueueData">
<!-- 队列选择器 -->
<div class="queue-selector">
<a-tabs
v-model:activeKey="activeQueueId"
type="editable-card"
@edit="onTabEdit"
@change="onQueueChange"
class="queue-tabs"
>
<a-tab-pane
v-for="queue in queueList"
:key="queue.id"
:tab="queue.name"
:closable="queueList.length > 1"
/>
</a-tabs>
</div>
<!-- 队列配置区域 -->
<div class="queue-config-section">
<div class="section-header">
<div class="section-title">
<div class="queue-name-editor">
<a-input
v-model:value="currentQueueName"
placeholder="请输入队列名称"
size="large"
class="queue-name-input"
@blur="onQueueNameBlur"
@pressEnter="onQueueNameBlur"
/>
</div>
</div>
<div class="section-controls">
<a-space>
<span class="status-label">状态</span>
<a-switch
v-model:checked="currentQueueEnabled"
@change="onQueueStatusChange"
checked-children="启用"
un-checked-children="禁用"
/>
</a-space>
</div>
</div>
<!-- 定时项组件 -->
<TimeSetManager
v-if="activeQueueId && currentQueueData"
:queue-id="activeQueueId"
:time-sets="currentTimeSets"
@refresh="refreshTimeSets"
/>
<!-- 队列项组件 -->
<QueueItemManager
v-if="activeQueueId && currentQueueData"
:queue-id="activeQueueId"
:queue-items="currentQueueItems"
@refresh="refreshQueueItems"
/>
</div>
</div>
</div>
<!-- 悬浮保存按钮 -->
<a-float-button
type="primary"
@click="handleSave"
class="float-button"
:style="{ right: '24px' }"
>
<template #icon>
<SaveOutlined />
</template>
</a-float-button>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons-vue'
import { Service } from '@/api'
import TimeSetManager from '@/components/queue/TimeSetManager.vue'
import QueueItemManager from '@/components/queue/QueueItemManager.vue'
// 队列列表和当前选中的队列
const queueList = ref<Array<{ id: string; name: string }>>([])
const activeQueueId = ref<string>('')
const currentQueueData = ref<Record<string, any> | null>(null)
// 当前队列的名称和状态
const currentQueueName = ref<string>('')
const currentQueueEnabled = ref<boolean>(true)
// 当前队列的定时项和队列项
const currentTimeSets = ref<any[]>([])
const currentQueueItems = ref<any[]>([])
const loading = ref(true)
// 获取队列列表
const fetchQueues = async () => {
loading.value = true
try {
const response = await Service.getQueuesApiQueueGetPost({})
if (response.code === 200) {
// 处理队列数据
console.log('API Response:', response) // 调试日志
if (response.index && response.index.length > 0) {
queueList.value = response.index.map((item: any, index: number) => {
try {
// API响应格式: {"uid": "xxx", "type": "QueueConfig"}
const queueId = item.uid
const queueName = response.data[queueId]?.Info?.Name || `队列 ${index + 1}`
console.log('Queue ID:', queueId, 'Name:', queueName, 'Type:', typeof queueId) // 调试日志
return {
id: queueId,
name: queueName
}
} catch (itemError) {
console.warn('解析队列项失败:', itemError, item)
return {
id: `queue_${index}`,
name: `队列 ${index + 1}`
}
}
})
// 如果有队列且没有选中的队列,默认选中第一个
if (queueList.value.length > 0 && !activeQueueId.value) {
activeQueueId.value = queueList.value[0].id
console.log('Selected queue ID:', activeQueueId.value) // 调试日志
// 使用nextTick确保DOM更新后再加载数据
nextTick(() => {
loadQueueData(activeQueueId.value).catch(error => {
console.error('加载队列数据失败:', error)
})
})
}
} else {
console.log('No queues found in response') // 调试日志
queueList.value = []
currentQueueData.value = null
}
} else {
console.error('API响应错误:', response)
queueList.value = []
currentQueueData.value = null
}
} catch (error) {
console.error('获取队列列表失败:', error)
queueList.value = []
currentQueueData.value = null
} finally {
loading.value = false
}
}
// 加载队列数据
const loadQueueData = async (queueId: string) => {
if (!queueId) return
try {
const response = await Service.getQueuesApiQueueGetPost({})
currentQueueData.value = response.data
// 根据API响应数据更新队列信息
if (response.data && response.data[queueId]) {
const queueData = response.data[queueId]
// 更新队列名称和状态
const currentQueue = queueList.value.find(queue => queue.id === queueId)
if (currentQueue) {
currentQueueName.value = currentQueue.name
}
currentQueueEnabled.value = queueData.enabled ?? true
// 使用nextTick确保DOM更新后再加载数据
await nextTick()
await new Promise(resolve => setTimeout(resolve, 50))
// 加载定时项和队列项数据 - 添加错误处理
try {
await refreshTimeSets()
} catch (timeError) {
console.error('刷新定时项失败:', timeError)
}
try {
await refreshQueueItems()
} catch (itemError) {
console.error('刷新队列项失败:', itemError)
}
}
} catch (error) {
console.error('加载队列数据失败:', error)
// 不显示错误消息,避免干扰用户体验
}
}
// 刷新定时项数据
const refreshTimeSets = async () => {
if (!activeQueueId.value) {
currentTimeSets.value = []
return
}
try {
// 重新从API获取最新的队列数据
const response = await Service.getQueuesApiQueueGetPost({})
if (response.code !== 200) {
console.error('获取队列数据失败:', response)
currentTimeSets.value = []
return
}
// 更新缓存的队列数据
currentQueueData.value = response.data
// 从最新的队列数据中获取定时项信息
if (response.data && response.data[activeQueueId.value]) {
const queueData = response.data[activeQueueId.value]
const timeSets: any[] = []
// 检查是否有TimeSet配置
if (queueData?.SubConfigsInfo?.TimeSet) {
const timeSetConfig = queueData.SubConfigsInfo.TimeSet
// 遍历instances数组获取所有定时项ID
if (Array.isArray(timeSetConfig.instances)) {
timeSetConfig.instances.forEach((instance: any) => {
try {
const timeSetId = instance?.uid
if (!timeSetId) return
const timeSetData = timeSetConfig[timeSetId]
if (timeSetData?.Info) {
// 解析时间字符串 "HH:mm" - 修复字段名
const originalTimeString = timeSetData.Info.Set || timeSetData.Info.Time || '00:00'
const [hours = 0, minutes = 0] = originalTimeString.split(':').map(Number)
// 创建标准化的时间字符串
const validHours = Math.max(0, Math.min(23, hours))
const validMinutes = Math.max(0, Math.min(59, minutes))
const timeString = `${validHours.toString().padStart(2, '0')}:${validMinutes.toString().padStart(2, '0')}`
timeSets.push({
id: timeSetId,
time: timeString,
enabled: Boolean(timeSetData.Info.Enabled),
description: timeSetData.Info.Description || ''
})
}
} catch (itemError) {
console.warn('解析单个定时项失败:', itemError, instance)
}
})
}
}
// 使用nextTick确保数据更新不会导致渲染问题
await nextTick()
currentTimeSets.value = [...timeSets]
console.log('刷新后的定时项数据:', timeSets) // 调试日志
} else {
currentTimeSets.value = []
}
} catch (error) {
console.error('刷新定时项列表失败:', error)
currentTimeSets.value = []
// 不显示错误消息,避免干扰用户
}
}
// 刷新队列项数据
const refreshQueueItems = async () => {
if (!activeQueueId.value) {
currentQueueItems.value = []
return
}
try {
// 重新从API获取最新的队列数据
const response = await Service.getQueuesApiQueueGetPost({})
if (response.code !== 200) {
console.error('获取队列数据失败:', response)
currentQueueItems.value = []
return
}
// 更新缓存的队列数据
currentQueueData.value = response.data
// 从最新的队列数据中获取队列项信息
if (response.data && response.data[activeQueueId.value]) {
const queueData = response.data[activeQueueId.value]
const queueItems: any[] = []
// 检查是否有QueueItem配置
if (queueData?.SubConfigsInfo?.QueueItem) {
const queueItemConfig = queueData.SubConfigsInfo.QueueItem
// 遍历instances数组获取所有队列项ID
if (Array.isArray(queueItemConfig.instances)) {
queueItemConfig.instances.forEach((instance: any) => {
try {
const queueItemId = instance?.uid
if (!queueItemId) return
const queueItemData = queueItemConfig[queueItemId]
if (queueItemData?.Info) {
queueItems.push({
id: queueItemId,
name: queueItemData.Info.name || `项目 ${queueItemId}`,
script: queueItemData.Info.script || '',
plan: queueItemData.Info.plan || '',
status: queueItemData.Info.status || 'inactive',
description: queueItemData.Info.description || ''
})
}
} catch (itemError) {
console.warn('解析单个队列项失败:', itemError, instance)
}
})
}
}
// 使用nextTick确保数据更新不会导致渲染问题
await nextTick()
currentQueueItems.value = [...queueItems]
console.log('刷新后的队列项数据:', queueItems) // 调试日志
} else {
currentQueueItems.value = []
}
} catch (error) {
console.error('刷新队列项列表失败:', error)
currentQueueItems.value = []
// 不显示错误消息,避免干扰用户
}
}
// 队列名称编辑失焦处理
const onQueueNameBlur = () => {
if (activeQueueId.value) {
const currentQueue = queueList.value.find(queue => queue.id === activeQueueId.value)
if (currentQueue) {
currentQueue.name = currentQueueName.value || `队列 ${queueList.value.indexOf(currentQueue) + 1}`
}
}
}
// 队列状态切换处理
const onQueueStatusChange = () => {
// 状态切换时只更新本地状态,不自动保存
}
// 标签页编辑处理
const onTabEdit = async (targetKey: string | MouseEvent, action: 'add' | 'remove') => {
try {
if (action === 'add') {
await handleAddQueue()
} else if (action === 'remove' && typeof targetKey === 'string') {
await handleRemoveQueue(targetKey)
}
} catch (error) {
console.error('标签页操作失败:', error)
}
}
// 添加队列
const handleAddQueue = async () => {
try {
const response = await Service.addQueueApiQueueAddPost()
if (response.code === 200 && response.queueId) {
const defaultName = `队列 ${queueList.value.length + 1}`
const newQueue = {
id: response.queueId,
name: defaultName,
}
queueList.value.push(newQueue)
activeQueueId.value = newQueue.id
// 设置默认名称到输入框中
currentQueueName.value = defaultName
currentQueueEnabled.value = true
await loadQueueData(newQueue.id)
message.success('队列创建成功')
} else {
message.error('队列创建失败: ' + (response.message || '未知错误'))
}
} catch (error) {
console.error('添加队列失败:', error)
message.error('添加队列失败: ' + (error?.message || '网络错误'))
}
}
// 删除队列
const handleRemoveQueue = async (queueId: string) => {
try {
const response = await Service.deleteQueueApiQueueDeletePost({ queueId })
if (response.code === 200) {
const index = queueList.value.findIndex(queue => queue.id === queueId)
if (index > -1) {
queueList.value.splice(index, 1)
if (activeQueueId.value === queueId) {
activeQueueId.value = queueList.value[0]?.id || ''
if (activeQueueId.value) {
await loadQueueData(activeQueueId.value)
} else {
currentQueueData.value = null
}
}
}
message.success('队列删除成功')
} else {
message.error('删除队列失败: ' + (response.message || '未知错误'))
}
} catch (error) {
console.error('删除队列失败:', error)
message.error('删除队列失败: ' + (error?.message || '网络错误'))
}
}
// 队列切换
const onQueueChange = async (queueId: string) => {
if (!queueId) return
try {
// 清空当前数据,避免渲染问题
currentTimeSets.value = []
currentQueueItems.value = []
await loadQueueData(queueId)
} catch (error) {
console.error('队列切换失败:', error)
}
}
// 手动保存处理
const handleSave = async () => {
if (!activeQueueId.value) {
message.warning('请先选择一个队列')
return
}
try {
await saveQueueData()
message.success('保存成功')
} catch (error) {
message.error('保存失败')
}
}
// 保存队列数据
const saveQueueData = async () => {
if (!activeQueueId.value) return
try {
// 构建符合API要求的数据结构
const queueData: Record<string, any> = {
name: currentQueueName.value,
enabled: currentQueueEnabled.value,
// 这里可以添加其他需要保存的队列配置
}
const response = await Service.updateQueueApiQueueUpdatePost({
queueId: activeQueueId.value,
data: queueData
})
if (response.code !== 200) {
throw new Error(response.message || '保存失败')
}
} catch (error) {
console.error('保存队列数据失败:', error)
throw error
}
}
// 刷新队列列表
const handleRefresh = async () => {
loading.value = true
await fetchQueues()
loading.value = false
}
// 初始化
onMounted(async () => {
try {
await fetchQueues()
} catch (error) {
console.error('初始化失败:', error)
loading.value = false
}
})
</script>
<style scoped>
.page-container {
padding: 20px;
/* 空状态样式 */
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-content {
text-align: center;
padding: 48px;
background: var(--ant-color-bg-container);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid var(--ant-color-border-secondary);
}
.empty-icon {
font-size: 64px;
color: var(--ant-color-text-tertiary);
margin-bottom: 24px;
}
.empty-content h3 {
font-size: 20px;
font-weight: 600;
color: var(--ant-color-text);
margin: 0 0 8px 0;
}
.empty-content p {
font-size: 14px;
color: var(--ant-color-text-secondary);
margin: 0 0 32px 0;
}
/* 队列内容区域 */
.queue-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--ant-color-bg-container);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid var(--ant-color-border-secondary);
overflow: hidden;
}
/* 队列头部样式 */
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: var(--ant-color-bg-container);
border-bottom: 1px solid var(--ant-color-border-secondary);
margin-bottom: 5px;
}
.header-title h1 {
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;
}
/* 队列选择器 */
.queue-selector {
padding: 0 32px;
background: var(--ant-color-bg-container);
border-bottom: 1px solid var(--ant-color-border-secondary);
}
/* 队列主内容 */
.queue-main-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* 队列配置区域 */
.queue-config-section {
flex: 1;
padding: 24px 32px;
overflow: auto;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
margin-bottom: 24px;
background: var(--ant-color-bg-container);
}
.section-title h3 {
margin: 0;
color: var(--ant-color-text);
font-size: 18px;
font-weight: 600;
}
.section-controls {
display: flex;
align-items: center;
}
.status-label {
color: var(--ant-color-text-secondary);
font-size: 14px;
font-weight: 500;
}
/* 队列名称编辑器样式 */
.queue-name-editor {
display: flex;
align-items: center;
}
.queue-name-input {
max-width: 300px;
font-size: 18px;
font-weight: 600;
}
.queue-name-input :deep(.ant-input) {
border: 1px solid transparent;
background: transparent;
color: var(--ant-color-text);
font-size: 18px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
}
.queue-name-input :deep(.ant-input:hover) {
border-color: var(--ant-color-border);
}
.queue-name-input :deep(.ant-input:focus) {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.loading-box {
min-height: 400px;
display: flex;
justify-content: center;
align-items: center;
}
.float-button {
width: 60px;
height: 60px;
}
.empty-content-fancy {
transition: box-shadow 0.3s, transform 0.2s;
border: none;
border-radius: 24px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 32px;
border-radius: 50%;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
margin-right: auto;
}
.empty-content-fancy h2 {
font-size: 26px;
font-weight: 700;
margin: 0 0 12px 0;
letter-spacing: 1px;
color: var(--ant-color-text);
}
.empty-content-fancy h1 {
font-size: 16px;
border-radius: 8px;
padding: 8px 16px;
margin: 0 0 12px 0;
display: inline-block;
color: var(--ant-color-text-secondary);
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.queue-config-section {
border-color: var(--ant-color-border-secondary);
border-radius: 16px;
}
.section-header {
border-color: var(--ant-color-border-secondary);
border-radius: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff