feat: 重构勘察业务为明细模式并新增定时任务人性化配置

- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
This commit is contained in:
andyesfly committed 2026-10-17 10:08:42 +08:00
1 parent f72644c24f
commit 22d27a940d
29 files changed
+1908 -576

No files matched your search

+360 -14
View File
@@ -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>