feat: 新增电工APP端与定时任务管理功能

- 新增电工APP独立鉴权中间件与登录注册接口
- 新增定时任务管理模块,支持任务配置、执行日志与手动触发
- 新增运维费定价与电工派单成本价目管理
- 新增电工资质证照管理与到期提醒定时任务
- 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
andyesfly committed 2026-10-02 16:08:00 +08:00
1 parent 1803ad679c
commit f4454ad014
57 files changed
+9388 -200

No files matched your search

+649
View File
@@ -0,0 +1,649 @@
<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 === '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="640px"
@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-form-item label="cron 表达式" name="cronExpression">
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
</a-form-item>
<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-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, onMounted, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
ReloadOutlined,
EditOutlined,
PlayCircleOutlined,
ProfileOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { jobApi, type JobItem, type JobParamField, type JobLogItem } 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: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
{ 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()
const formData = reactive({
cronExpression: '',
params: {} as Record<string, any>,
retryTimes: 0,
retryInterval: 60,
remark: '',
})
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' }],
}
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 || []
formData.cronExpression = record.cronExpression || ''
formData.params = { ...(record.params || {}) }
formData.retryTimes = record.retryTimes ?? 0
formData.retryInterval = record.retryInterval ?? 60
formData.remark = record.remark || ''
editVisible.value = true
nextTick(() => formRef.value?.clearValidate())
}
function handleCancelEdit() {
editVisible.value = false
}
async function handleSave() {
if (!currentJob.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const res: any = await jobApi.updateJob(currentJob.value.id, {
cronExpression: formData.cronExpression,
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()
})
</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;
}
.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;
}
}
</style>