feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理 - 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式 - 新增频率预览接口,支持配置转换为cron表达式与执行时间预览 - 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
1 parent
f72644c24f
commit
22d27a940d
29 files changed
+1908
-576
No files matched your search
@@ -53,6 +53,12 @@
|
||||
<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>
|
||||
@@ -111,7 +117,7 @@
|
||||
title="编辑任务"
|
||||
:confirm-loading="saving"
|
||||
:mask-closable="false"
|
||||
width="640px"
|
||||
width="720px"
|
||||
@ok="handleSave"
|
||||
@cancel="handleCancelEdit"
|
||||
>
|
||||
@@ -125,9 +131,110 @@
|
||||
<a-form-item label="任务名称">
|
||||
<span>{{ currentJob?.jobName || '-' }}</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="cron 表达式" name="cronExpression">
|
||||
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
|
||||
|
||||
<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"
|
||||
@@ -149,6 +256,9 @@
|
||||
: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>
|
||||
@@ -228,7 +338,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -237,9 +347,18 @@ import {
|
||||
PlayCircleOutlined,
|
||||
ProfileOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
||||
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()
|
||||
@@ -260,7 +379,7 @@ const queryParams = reactive({
|
||||
const columns = [
|
||||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||||
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
||||
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
|
||||
{ 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 },
|
||||
@@ -286,24 +405,174 @@ 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({
|
||||
cronExpression: '',
|
||||
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 = {
|
||||
cronExpression: [
|
||||
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
|
||||
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
|
||||
],
|
||||
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[]>([])
|
||||
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
|
||||
function handleEdit(record: JobItem) {
|
||||
currentJob.value = record
|
||||
paramFields.value = record.paramFields || []
|
||||
formData.cronExpression = record.cronExpression || ''
|
||||
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() {
|
||||
@@ -462,10 +734,22 @@ async function handleSave() {
|
||||
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, {
|
||||
cronExpression: formData.cronExpression,
|
||||
freqType: formData.freqType,
|
||||
freqConfig: buildFreqConfig(),
|
||||
params: { ...formData.params },
|
||||
retryTimes: formData.retryTimes,
|
||||
retryInterval: formData.retryInterval,
|
||||
@@ -572,6 +856,10 @@ function handleClearLogs() {
|
||||
onMounted(() => {
|
||||
fetchJobList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(previewTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -635,6 +923,17 @@ onMounted(() => {
|
||||
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;
|
||||
@@ -646,4 +945,51 @@ onMounted(() => {
|
||||
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>
|
||||
Reference in new issue
Block a user