- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
996 lines
30 KiB
Vue
996 lines
30 KiB
Vue
<template>
|
||
<div class="job-management">
|
||
<a-card :bordered="false" class="search-card">
|
||
<a-form layout="inline" :model="queryParams">
|
||
<a-form-item label="任务名称">
|
||
<a-input
|
||
v-model:value="queryParams.jobName"
|
||
placeholder="请输入任务名称"
|
||
allow-clear
|
||
style="width: 180px"
|
||
@pressEnter="handleSearch"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="状态">
|
||
<a-select
|
||
v-model:value="queryParams.status"
|
||
placeholder="全部"
|
||
allow-clear
|
||
style="width: 120px"
|
||
>
|
||
<a-select-option :value="1">启用</a-select-option>
|
||
<a-select-option :value="0">停用</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-space>
|
||
<a-button type="primary" @click="handleSearch">
|
||
<template #icon><SearchOutlined /></template>
|
||
查询
|
||
</a-button>
|
||
<a-button @click="handleReset">
|
||
<template #icon><ReloadOutlined /></template>
|
||
重置
|
||
</a-button>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-card>
|
||
|
||
<a-card :bordered="false" class="table-card">
|
||
<div class="table-wrapper" ref="wrapperRef">
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="jobList"
|
||
:loading="tableLoading"
|
||
:pagination="paginationConfig"
|
||
row-key="id"
|
||
:scroll="{ x: 'max-content', y: tableHeight }"
|
||
@change="handleTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'jobName'">
|
||
<span>{{ record.jobName }}</span>
|
||
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'frequency'">
|
||
<div class="freq-cell">
|
||
<span>{{ record.cronDescription || record.cronExpression }}</span>
|
||
<code class="freq-cron">{{ record.cronExpression }}</code>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'params'">
|
||
<a-tooltip :title="formatParams(record.params, true)">
|
||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||
</a-tooltip>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-switch
|
||
:checked="record.status === 1"
|
||
:loading="statusLoadingId === record.id"
|
||
checked-children="启用"
|
||
un-checked-children="停用"
|
||
@change="(checked: boolean) => handleStatusChange(record, checked)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'lastRunAt'">
|
||
{{ formatDateTime(record.lastRunAt) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'lastRunStatus'">
|
||
<a-tag v-if="record.lastRunStatus" :color="runStatusColor(record.lastRunStatus)">
|
||
{{ runStatusText(record.lastRunStatus) }}
|
||
</a-tag>
|
||
<span v-else>-</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'lastRunCostMs'">
|
||
{{ record.lastRunCostMs === null || record.lastRunCostMs === undefined ? '-' : `${record.lastRunCostMs} ms` }}
|
||
</template>
|
||
<template v-else-if="column.key === 'nextRunTime'">
|
||
{{ formatDateTime(record.nextRunTime) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'retry'">
|
||
{{ `${record.retryTimes ?? 0} 次 / ${record.retryInterval ?? 0} 秒` }}
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleEdit(record)">
|
||
<template #icon><EditOutlined /></template>
|
||
编辑
|
||
</a-button>
|
||
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleRun(record)">
|
||
<template #icon><PlayCircleOutlined /></template>
|
||
立即执行
|
||
</a-button>
|
||
<a-button type="link" size="small" @click="handleOpenLogs(record)">
|
||
<template #icon><ProfileOutlined /></template>
|
||
日志
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</a-card>
|
||
|
||
<a-modal
|
||
v-model:open="editVisible"
|
||
title="编辑任务"
|
||
:confirm-loading="saving"
|
||
:mask-closable="false"
|
||
width="720px"
|
||
@ok="handleSave"
|
||
@cancel="handleCancelEdit"
|
||
>
|
||
<a-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
:label-col="{ span: 6 }"
|
||
:wrapper-col="{ span: 16 }"
|
||
>
|
||
<a-form-item label="任务名称">
|
||
<span>{{ currentJob?.jobName || '-' }}</span>
|
||
</a-form-item>
|
||
|
||
<a-divider orientation="left" class="form-divider">执行频率</a-divider>
|
||
|
||
<a-form-item label="频率类型">
|
||
<a-select v-model:value="formData.freqType" :options="freqTypeOptions" style="width: 100%" />
|
||
</a-form-item>
|
||
|
||
<a-form-item v-if="formData.freqType === 'minute'" label="间隔">
|
||
<a-input-number
|
||
v-model:value="formData.interval"
|
||
:min="1"
|
||
:max="59"
|
||
addon-after="分钟"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<template v-if="formData.freqType === 'hour'">
|
||
<a-form-item label="间隔">
|
||
<a-input-number
|
||
v-model:value="formData.interval"
|
||
:min="1"
|
||
:max="23"
|
||
addon-after="小时"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="第几分钟">
|
||
<a-input-number
|
||
v-model:value="formData.minute"
|
||
:min="0"
|
||
:max="59"
|
||
addon-after="分"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</template>
|
||
|
||
<a-form-item v-if="formData.freqType === 'week'" label="星期">
|
||
<a-checkbox-group v-model:value="formData.weekdays" :options="weekdayOptions" />
|
||
</a-form-item>
|
||
|
||
<a-form-item v-if="formData.freqType === 'month'" label="日期">
|
||
<a-select
|
||
v-model:value="formData.days"
|
||
mode="multiple"
|
||
:options="dayOptions"
|
||
:max-tag-count="6"
|
||
placeholder="请选择每月的执行日期"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item v-if="hasTimeList" label="执行时间">
|
||
<div class="time-list">
|
||
<div v-for="(_, index) in formData.times" :key="index" class="time-row">
|
||
<a-time-picker v-model:value="formData.times[index]" format="HH:mm" :allow-clear="false" />
|
||
<a-button
|
||
type="text"
|
||
danger
|
||
:disabled="formData.times.length <= 1"
|
||
@click="removeTime(index)"
|
||
>
|
||
<template #icon><MinusCircleOutlined /></template>
|
||
</a-button>
|
||
</div>
|
||
<a-button type="dashed" block :disabled="formData.times.length >= 10" @click="addTime">
|
||
<template #icon><PlusOutlined /></template>
|
||
添加时间
|
||
</a-button>
|
||
</div>
|
||
</a-form-item>
|
||
|
||
<a-form-item v-if="formData.freqType === 'custom'" label="cron 表达式">
|
||
<a-input v-model:value="formData.expression" placeholder="如 0 3 * * *(分 时 日 月 周)" :maxlength="64" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="执行预览">
|
||
<a-spin :spinning="previewLoading" size="small">
|
||
<div class="freq-preview">
|
||
<a-alert v-if="preview.error" type="warning" :message="preview.error" show-icon />
|
||
<template v-else>
|
||
<div class="preview-line">
|
||
<span class="preview-label">表达式</span>
|
||
<code>{{ preview.cronExpression || '-' }}</code>
|
||
</div>
|
||
<div class="preview-line">
|
||
<span class="preview-label">说明</span>
|
||
<span>{{ preview.description || '-' }}</span>
|
||
</div>
|
||
<div class="preview-line">
|
||
<span class="preview-label">下次执行</span>
|
||
<span v-if="!preview.nextRuns.length">-</span>
|
||
<ol v-else class="next-list">
|
||
<li v-for="(time, index) in preview.nextRuns" :key="index">{{ formatDateTime(time) }}</li>
|
||
</ol>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</a-spin>
|
||
</a-form-item>
|
||
|
||
<a-divider v-if="paramFields.length" orientation="left" class="form-divider">任务参数</a-divider>
|
||
|
||
<a-form-item
|
||
v-for="field in paramFields"
|
||
:key="field.key"
|
||
:label="field.label"
|
||
:name="['params', field.key]"
|
||
:rules="getParamRules(field)"
|
||
>
|
||
<a-input-number
|
||
v-if="field.type === 'number'"
|
||
v-model:value="formData.params[field.key]"
|
||
:min="field.min"
|
||
:max="field.max"
|
||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||
style="width: 100%"
|
||
/>
|
||
<a-input
|
||
v-else
|
||
v-model:value="formData.params[field.key]"
|
||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
|
||
|
||
<a-form-item label="重试次数" name="retryTimes">
|
||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||
</a-form-item>
|
||
<a-form-item label="重试间隔(秒)" name="retryInterval">
|
||
<a-input-number v-model:value="formData.retryInterval" :min="10" :max="3600" style="width: 100%" />
|
||
</a-form-item>
|
||
<a-form-item label="备注" name="remark">
|
||
<a-textarea
|
||
v-model:value="formData.remark"
|
||
placeholder="请输入备注"
|
||
:maxlength="255"
|
||
show-count
|
||
:rows="3"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<a-drawer
|
||
v-model:open="logVisible"
|
||
:title="`执行日志 - ${currentJob?.jobName || ''}`"
|
||
width="900"
|
||
>
|
||
<div class="log-toolbar">
|
||
<a-select
|
||
v-model:value="logQuery.status"
|
||
placeholder="全部状态"
|
||
allow-clear
|
||
style="width: 140px"
|
||
@change="handleLogSearch"
|
||
>
|
||
<a-select-option :value="1">成功</a-select-option>
|
||
<a-select-option :value="2">失败</a-select-option>
|
||
<a-select-option :value="3">跳过</a-select-option>
|
||
</a-select>
|
||
<a-button danger @click="handleClearLogs">
|
||
<template #icon><DeleteOutlined /></template>
|
||
清理日志
|
||
</a-button>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="logColumns"
|
||
:data-source="logList"
|
||
:loading="logLoading"
|
||
:pagination="logPaginationConfig"
|
||
:expandable="logExpandable"
|
||
row-key="id"
|
||
size="small"
|
||
@change="handleLogTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'createdAt'">
|
||
{{ formatDateTime(record.createdAt) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'triggerType'">
|
||
{{ triggerTypeText(record.triggerType) }}
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-tag :color="runStatusColor(record.status)">{{ runStatusText(record.status) }}</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'costMs'">
|
||
{{ record.costMs === null || record.costMs === undefined ? '-' : `${record.costMs} ms` }}
|
||
</template>
|
||
<template v-else-if="column.key === 'message'">
|
||
<a-tooltip :title="record.message || ''">
|
||
<span class="ellipsis-text">{{ record.message || '-' }}</span>
|
||
</a-tooltip>
|
||
</template>
|
||
</template>
|
||
<template #expandedRowRender="{ record }">
|
||
<pre class="error-stack">{{ record.errorStack }}</pre>
|
||
</template>
|
||
</a-table>
|
||
</a-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import {
|
||
SearchOutlined,
|
||
ReloadOutlined,
|
||
EditOutlined,
|
||
PlayCircleOutlined,
|
||
ProfileOutlined,
|
||
DeleteOutlined,
|
||
PlusOutlined,
|
||
MinusCircleOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import dayjs, { type Dayjs } from 'dayjs'
|
||
import {
|
||
jobApi,
|
||
type JobItem,
|
||
type JobParamField,
|
||
type JobLogItem,
|
||
type FreqType,
|
||
type FreqConfig,
|
||
} from '@/api/job'
|
||
import { useTableScroll } from '@/composables/useTableScroll'
|
||
|
||
const { wrapperRef, tableHeight } = useTableScroll()
|
||
|
||
const tableLoading = ref(false)
|
||
const saving = ref(false)
|
||
const statusLoadingId = ref<number | null>(null)
|
||
const jobList = ref<JobItem[]>([])
|
||
const total = ref(0)
|
||
|
||
const queryParams = reactive({
|
||
page: 1,
|
||
pageSize: 10,
|
||
jobName: '',
|
||
status: undefined as number | undefined,
|
||
})
|
||
|
||
const columns = [
|
||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
||
{ title: '执行频率', key: 'frequency', width: 200 },
|
||
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
||
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||
{ title: '上次状态', key: 'lastRunStatus', width: 100, align: 'center' as const },
|
||
{ title: '耗时', key: 'lastRunCostMs', width: 100 },
|
||
{ title: '下次执行时间', key: 'nextRunTime', width: 170 },
|
||
{ title: '重试配置', key: 'retry', width: 140 },
|
||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
|
||
]
|
||
|
||
const paginationConfig = reactive({
|
||
current: queryParams.page,
|
||
pageSize: queryParams.pageSize,
|
||
total: total.value,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
pageSizeOptions: ['10', '20', '50', '100'],
|
||
showTotal: (t: number) => `共 ${t} 条记录`,
|
||
})
|
||
|
||
const editVisible = ref(false)
|
||
const currentJob = ref<JobItem | null>(null)
|
||
const paramFields = ref<JobParamField[]>([])
|
||
const formRef = ref()
|
||
|
||
/** HH:mm 文本 → Dayjs(TimePicker 取值),不依赖 dayjs 自定义解析插件 */
|
||
function toTimeValue(text: string): Dayjs {
|
||
const [hour, minute] = text.split(':')
|
||
return dayjs().hour(Number(hour)).minute(Number(minute)).second(0).millisecond(0)
|
||
}
|
||
|
||
/** 人性化频率表单:字段按 freqType 取用,提交时由 buildFreqConfig 组装成频率配置 */
|
||
const formData = reactive({
|
||
freqType: 'day' as FreqType,
|
||
interval: 5,
|
||
minute: 0,
|
||
times: [toTimeValue('08:00')] as Dayjs[],
|
||
weekdays: [1] as number[],
|
||
days: [1] as number[],
|
||
expression: '',
|
||
params: {} as Record<string, any>,
|
||
retryTimes: 0,
|
||
retryInterval: 60,
|
||
remark: '',
|
||
})
|
||
|
||
const freqTypeOptions: { value: FreqType; label: string }[] = [
|
||
{ value: 'minute', label: '每 N 分钟' },
|
||
{ value: 'hour', label: '每 N 小时' },
|
||
{ value: 'day', label: '每天' },
|
||
{ value: 'week', label: '每周' },
|
||
{ value: 'month', label: '每月' },
|
||
{ value: 'custom', label: '自定义(cron 表达式)' },
|
||
]
|
||
|
||
const weekdayOptions = [
|
||
{ value: 1, label: '周一' },
|
||
{ value: 2, label: '周二' },
|
||
{ value: 3, label: '周三' },
|
||
{ value: 4, label: '周四' },
|
||
{ value: 5, label: '周五' },
|
||
{ value: 6, label: '周六' },
|
||
{ value: 0, label: '周日' },
|
||
]
|
||
|
||
const dayOptions = Array.from({ length: 31 }, (_, index) => ({
|
||
value: index + 1,
|
||
label: `${index + 1} 日`,
|
||
}))
|
||
|
||
/** day/week/month 需要执行时间点列表 */
|
||
const hasTimeList = computed(() => ['day', 'week', 'month'].includes(formData.freqType))
|
||
|
||
/** 执行预览状态(由后端计算 cron / 中文描述 / 未来执行时间) */
|
||
const previewLoading = ref(false)
|
||
const preview = reactive({
|
||
valid: true,
|
||
error: '',
|
||
cronExpression: '',
|
||
description: '',
|
||
nextRuns: [] as string[],
|
||
})
|
||
|
||
const formRules = {
|
||
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
||
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
||
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
||
}
|
||
|
||
/** 表单 → 频率配置(提交给后端的结构) */
|
||
function buildFreqConfig(): FreqConfig {
|
||
switch (formData.freqType) {
|
||
case 'minute':
|
||
return { interval: formData.interval }
|
||
case 'hour':
|
||
return { interval: formData.interval, minute: formData.minute }
|
||
case 'day':
|
||
return { times: timeTexts() }
|
||
case 'week':
|
||
return { weekdays: [...formData.weekdays].sort((a, b) => a - b), times: timeTexts() }
|
||
case 'month':
|
||
return { days: [...formData.days].sort((a, b) => a - b), times: timeTexts() }
|
||
default:
|
||
return { expression: formData.expression.trim() }
|
||
}
|
||
}
|
||
|
||
function timeTexts(): string[] {
|
||
return formData.times.map(item => item.format('HH:mm'))
|
||
}
|
||
|
||
/** 频率配置 → 表单(编辑回填) */
|
||
function applyFreqConfig(job: JobItem) {
|
||
const config: FreqConfig = job.freqConfig || {}
|
||
const type: FreqType = job.freqType || 'custom'
|
||
|
||
formData.freqType = type
|
||
formData.interval = config.interval ?? (type === 'hour' ? 1 : 5)
|
||
formData.minute = config.minute ?? 0
|
||
formData.times = (config.times?.length ? config.times : ['08:00']).map(item => toTimeValue(item))
|
||
formData.weekdays = config.weekdays?.length ? [...config.weekdays] : [1]
|
||
formData.days = config.days?.length ? [...config.days] : [1]
|
||
formData.expression = config.expression || job.cronExpression || ''
|
||
}
|
||
|
||
/** 后端校验之外的本地必填提示,避免空表单直接提交 */
|
||
function validateFreqConfig(): string {
|
||
if (hasTimeList.value && !formData.times.length) return '请至少添加 1 个执行时间'
|
||
if (formData.freqType === 'week' && !formData.weekdays.length) return '请至少选择 1 个星期'
|
||
if (formData.freqType === 'month' && !formData.days.length) return '请至少选择 1 个日期'
|
||
if (formData.freqType === 'custom' && !formData.expression.trim()) return '请输入 cron 表达式'
|
||
return ''
|
||
}
|
||
|
||
function addTime() {
|
||
if (formData.times.length >= 10) return
|
||
formData.times.push(toTimeValue('12:00'))
|
||
}
|
||
|
||
function removeTime(index: number) {
|
||
if (formData.times.length <= 1) return
|
||
formData.times.splice(index, 1)
|
||
}
|
||
|
||
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||
/** 已预览过(且成功过)的频率签名,避免打开弹窗时重复请求 */
|
||
let previewSignature = ''
|
||
|
||
async function fetchPreview() {
|
||
if (!editVisible.value) return
|
||
|
||
const signature = JSON.stringify({ type: formData.freqType, config: buildFreqConfig() })
|
||
if (signature === previewSignature) return
|
||
|
||
previewLoading.value = true
|
||
try {
|
||
const res: any = await jobApi.previewCron({
|
||
freqType: formData.freqType,
|
||
freqConfig: buildFreqConfig(),
|
||
})
|
||
if (res.code === 200) {
|
||
preview.valid = !!res.data.valid
|
||
preview.error = res.data.error || ''
|
||
preview.cronExpression = res.data.cronExpression || ''
|
||
preview.description = res.data.description || ''
|
||
preview.nextRuns = res.data.nextRuns || []
|
||
previewSignature = signature
|
||
}
|
||
} catch (error) {
|
||
// 预览失败不打断编辑,保存时后端仍会兜底校验
|
||
previewSignature = ''
|
||
} finally {
|
||
previewLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 频率任一字段变化 → 防抖后重新预览 */
|
||
watch(
|
||
() => JSON.stringify({ type: formData.freqType, config: buildFreqConfig() }),
|
||
() => {
|
||
clearTimeout(previewTimer)
|
||
previewTimer = setTimeout(fetchPreview, 300)
|
||
}
|
||
)
|
||
|
||
function resetPreview() {
|
||
preview.valid = true
|
||
preview.error = ''
|
||
preview.cronExpression = ''
|
||
preview.description = ''
|
||
preview.nextRuns = []
|
||
}
|
||
|
||
const logVisible = ref(false)
|
||
const logLoading = ref(false)
|
||
const logList = ref<JobLogItem[]>([])
|
||
const logTotal = ref(0)
|
||
|
||
const logQuery = reactive({
|
||
page: 1,
|
||
pageSize: 10,
|
||
status: undefined as number | undefined,
|
||
triggerType: undefined as number | undefined,
|
||
})
|
||
|
||
const logColumns = [
|
||
{ title: '执行时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||
{ title: '触发方式', dataIndex: 'triggerType', key: 'triggerType', width: 90 },
|
||
{ title: '状态', key: 'status', width: 80, align: 'center' as const },
|
||
{ title: '尝试', dataIndex: 'attempt', key: 'attempt', width: 70 },
|
||
{ title: '耗时', key: 'costMs', width: 90 },
|
||
{ title: '摘要', dataIndex: 'message', key: 'message' },
|
||
]
|
||
|
||
const logExpandable = {
|
||
rowExpandable: (record: JobLogItem) => !!record.errorStack,
|
||
}
|
||
|
||
const logPaginationConfig = reactive({
|
||
current: logQuery.page,
|
||
pageSize: logQuery.pageSize,
|
||
total: logTotal.value,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
pageSizeOptions: ['10', '20', '50'],
|
||
showTotal: (t: number) => `共 ${t} 条记录`,
|
||
})
|
||
|
||
function formatDateTime(time?: string | null): string {
|
||
if (!time) return '-'
|
||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
|
||
function formatParams(params?: Record<string, any>, full = false): string {
|
||
if (!params || !Object.keys(params).length) return '-'
|
||
const text = Object.entries(params)
|
||
.map(([key, value]) => `${key}=${value === null || value === undefined ? '' : value}`)
|
||
.join(full ? '\n' : ', ')
|
||
return text || '-'
|
||
}
|
||
|
||
function runStatusText(status: number): string {
|
||
return ({ 1: '成功', 2: '失败', 3: '跳过' } as Record<number, string>)[status] || '-'
|
||
}
|
||
|
||
function runStatusColor(status: number): string {
|
||
return ({ 1: 'green', 2: 'red', 3: 'orange' } as Record<number, string>)[status] || 'default'
|
||
}
|
||
|
||
function triggerTypeText(type: number): string {
|
||
return ({ 1: '定时', 2: '手动', 3: '重试' } as Record<number, string>)[type] || '-'
|
||
}
|
||
|
||
function getParamRules(field: JobParamField) {
|
||
if (field.type !== 'number') return []
|
||
return [
|
||
{
|
||
validator: (_rule: any, value: any) => {
|
||
if (value === undefined || value === null || value === '') return Promise.resolve()
|
||
if (field.min !== undefined && value < field.min) {
|
||
return Promise.reject(new Error(`${field.label}不能小于 ${field.min}`))
|
||
}
|
||
if (field.max !== undefined && value > field.max) {
|
||
return Promise.reject(new Error(`${field.label}不能大于 ${field.max}`))
|
||
}
|
||
return Promise.resolve()
|
||
},
|
||
trigger: 'change',
|
||
},
|
||
]
|
||
}
|
||
|
||
async function fetchJobList() {
|
||
tableLoading.value = true
|
||
try {
|
||
const res: any = await jobApi.getJobList({ ...queryParams })
|
||
if (res.code === 200) {
|
||
jobList.value = res.data.list
|
||
total.value = res.data.total
|
||
paginationConfig.total = res.data.total
|
||
}
|
||
} catch (error) {
|
||
message.error('获取任务列表失败')
|
||
} finally {
|
||
tableLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
queryParams.page = 1
|
||
paginationConfig.current = 1
|
||
fetchJobList()
|
||
}
|
||
|
||
function handleReset() {
|
||
queryParams.jobName = ''
|
||
queryParams.status = undefined
|
||
handleSearch()
|
||
}
|
||
|
||
function handleTableChange(pag: any) {
|
||
queryParams.page = pag.current
|
||
queryParams.pageSize = pag.pageSize
|
||
paginationConfig.current = pag.current
|
||
paginationConfig.pageSize = pag.pageSize
|
||
fetchJobList()
|
||
}
|
||
|
||
async function handleStatusChange(record: JobItem, checked: boolean) {
|
||
const status: 0 | 1 = checked ? 1 : 0
|
||
statusLoadingId.value = record.id
|
||
try {
|
||
const res: any = await jobApi.changeJobStatus(record.id, status)
|
||
if (res.code === 200) {
|
||
record.status = status
|
||
message.success(status === 1 ? '已启用' : '已停用')
|
||
fetchJobList()
|
||
} else {
|
||
message.error(res.message || '状态更新失败')
|
||
}
|
||
} catch (error) {
|
||
// 失败时 record.status 未变更,受控开关自动回滚
|
||
} finally {
|
||
statusLoadingId.value = null
|
||
}
|
||
}
|
||
|
||
function handleEdit(record: JobItem) {
|
||
currentJob.value = record
|
||
paramFields.value = record.paramFields || []
|
||
applyFreqConfig(record)
|
||
formData.params = { ...(record.params || {}) }
|
||
formData.retryTimes = record.retryTimes ?? 0
|
||
formData.retryInterval = record.retryInterval ?? 60
|
||
formData.remark = record.remark || ''
|
||
resetPreview()
|
||
previewSignature = ''
|
||
editVisible.value = true
|
||
nextTick(() => formRef.value?.clearValidate())
|
||
fetchPreview()
|
||
}
|
||
|
||
function handleCancelEdit() {
|
||
editVisible.value = false
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!currentJob.value) return
|
||
try {
|
||
await formRef.value.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
const freqError = validateFreqConfig()
|
||
if (freqError) {
|
||
message.warning(freqError)
|
||
return
|
||
}
|
||
|
||
if (!preview.valid) {
|
||
message.warning(preview.error || '频率配置不合法')
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||
freqType: formData.freqType,
|
||
freqConfig: buildFreqConfig(),
|
||
params: { ...formData.params },
|
||
retryTimes: formData.retryTimes,
|
||
retryInterval: formData.retryInterval,
|
||
remark: formData.remark,
|
||
})
|
||
if (res.code === 200) {
|
||
message.success('保存成功')
|
||
editVisible.value = false
|
||
fetchJobList()
|
||
} else {
|
||
message.error(res.message || '保存失败')
|
||
}
|
||
} catch (error) {
|
||
// 保存失败保持弹窗打开,后端 message 由响应拦截器提示
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function handleRun(record: JobItem) {
|
||
Modal.confirm({
|
||
title: '立即执行确认',
|
||
content: `确定立即执行任务「${record.jobName}」吗?`,
|
||
okText: '确定',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
const res: any = await jobApi.runJob(record.id)
|
||
if (res.code === 200) {
|
||
message.success(res.data?.message || '执行完成')
|
||
fetchJobList()
|
||
if (logVisible.value && currentJob.value?.id === record.id) {
|
||
fetchJobLogs()
|
||
}
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function handleOpenLogs(record: JobItem) {
|
||
currentJob.value = record
|
||
logQuery.page = 1
|
||
logQuery.pageSize = 10
|
||
logQuery.status = undefined
|
||
logQuery.triggerType = undefined
|
||
logPaginationConfig.current = 1
|
||
logPaginationConfig.pageSize = 10
|
||
logVisible.value = true
|
||
fetchJobLogs()
|
||
}
|
||
|
||
async function fetchJobLogs() {
|
||
if (!currentJob.value) return
|
||
logLoading.value = true
|
||
try {
|
||
const res: any = await jobApi.getJobLogs(currentJob.value.id, {
|
||
page: logQuery.page,
|
||
pageSize: logQuery.pageSize,
|
||
status: logQuery.status,
|
||
triggerType: logQuery.triggerType,
|
||
})
|
||
if (res.code === 200) {
|
||
logList.value = res.data.list
|
||
logTotal.value = res.data.total
|
||
logPaginationConfig.total = res.data.total
|
||
}
|
||
} catch (error) {
|
||
message.error('获取执行日志失败')
|
||
} finally {
|
||
logLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleLogSearch() {
|
||
logQuery.page = 1
|
||
logPaginationConfig.current = 1
|
||
fetchJobLogs()
|
||
}
|
||
|
||
function handleLogTableChange(pag: any) {
|
||
logQuery.page = pag.current
|
||
logQuery.pageSize = pag.pageSize
|
||
logPaginationConfig.current = pag.current
|
||
logPaginationConfig.pageSize = pag.pageSize
|
||
fetchJobLogs()
|
||
}
|
||
|
||
function handleClearLogs() {
|
||
Modal.confirm({
|
||
title: '清理日志确认',
|
||
content: '确定清理 90 天前的执行日志吗?此操作不可恢复。',
|
||
okText: '确定',
|
||
cancelText: '取消',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
const res: any = await jobApi.clearJobLogs(90)
|
||
if (res.code === 200) {
|
||
message.success(`已清理 ${res.data?.deleted ?? 0} 条日志`)
|
||
handleLogSearch()
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchJobList()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearTimeout(previewTimer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.job-management {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
padding: 20px;
|
||
box-sizing: border-box;
|
||
|
||
.search-card {
|
||
flex-shrink: 0;
|
||
margin-bottom: 16px;
|
||
|
||
:deep(.ant-card-body) {
|
||
padding-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
.table-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
|
||
:deep(.ant-card-body) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.table-wrapper {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.ant-table) {
|
||
font-size: 13px;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.ellipsis-text {
|
||
max-width: 160px;
|
||
display: inline-block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
vertical-align: bottom;
|
||
}
|
||
|
||
.log-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.freq-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
line-height: 20px;
|
||
|
||
.freq-cron {
|
||
font-size: 12px;
|
||
color: #8c8c8c;
|
||
}
|
||
}
|
||
|
||
.error-stack {
|
||
margin: 0;
|
||
padding: 8px 12px;
|
||
background: #fafafa;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: #cf1322;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
/* 弹窗内容被 teleport 到 body,样式不能嵌套在 .job-management 下 */
|
||
.form-divider {
|
||
margin: 4px 0 16px;
|
||
font-size: 13px;
|
||
color: #595959;
|
||
}
|
||
|
||
.time-list {
|
||
width: 100%;
|
||
|
||
.time-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
}
|
||
|
||
.freq-preview {
|
||
padding: 8px 12px;
|
||
background: #fafafa;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
line-height: 22px;
|
||
|
||
.preview-line {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.preview-label {
|
||
flex-shrink: 0;
|
||
width: 56px;
|
||
color: #8c8c8c;
|
||
}
|
||
|
||
code {
|
||
color: #c41d7f;
|
||
}
|
||
|
||
.next-list {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
}
|
||
}
|
||
</style>
|