feat(tools): 新增数据补数功能模块
- 实现补数任务面板组件,支持监测点ID输入、时间范围选择和时间步长设置 - 添加任务状态卡片组件,实时展示任务执行进度和结果统计 - 集成参数规则表格组件,显示后端配置的模板规则信息 - 实现补数API接口服务,包括预估写入量、创建任务和查询状态功能 - 添加磁盘监控策略对话框组件,支持全局监控配置管理 - 完成补数功能页面布局设计,集成左右双栏界面结构 - 实现任务轮询机制,自动更新任务执行状态直到完成 - 添加表单验证逻辑,确保输入参数符合业务规则要求
This commit is contained in:
98
frontend/src/api/tools/addData/index.ts
Normal file
98
frontend/src/api/tools/addData/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import http from '@/api'
|
||||
import type { ResultData } from '@/api/interface'
|
||||
import type { AddData } from './interface'
|
||||
|
||||
type AddDataRequestMethod = 'get' | 'post'
|
||||
|
||||
const ADD_DATA_ROUTE_PATHS = ['/addData', '/api/addData'] as const
|
||||
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
|
||||
|
||||
const resolveDevProxyTarget = () => {
|
||||
const proxyConfig = import.meta.env.VITE_PROXY
|
||||
if (!Array.isArray(proxyConfig)) return ''
|
||||
|
||||
const matchedProxy = proxyConfig.find(item => Array.isArray(item) && item[0] === '/api')
|
||||
if (!matchedProxy?.[1]) return ''
|
||||
|
||||
return String(matchedProxy[1]).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const buildAddDataRequestPaths = (path: string) => {
|
||||
const requestPaths = new Set<string>()
|
||||
const devProxyTarget = resolveDevProxyTarget()
|
||||
|
||||
for (const routePath of ADD_DATA_ROUTE_PATHS) {
|
||||
if (ADD_DATA_BASE_URL === '/api' && routePath.startsWith('/api/')) {
|
||||
if (devProxyTarget) {
|
||||
requestPaths.add(`${devProxyTarget}${routePath}${path}`)
|
||||
}
|
||||
|
||||
requestPaths.add(`${window.location.origin}${routePath}${path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
requestPaths.add(`${routePath}${path}`)
|
||||
}
|
||||
|
||||
return Array.from(requestPaths)
|
||||
}
|
||||
|
||||
const isFallbackableAddDataError = (error: unknown) => {
|
||||
const responseCode = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
|
||||
const responseMessage = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : ''
|
||||
const normalizedMessage = responseMessage.toLowerCase()
|
||||
|
||||
// 部分部署环境会把未命中的 addData 路由转到旧的操作分发入口,
|
||||
// 前端在识别到“unknown operate”或典型路由错误时回退到备用前缀重试一次。
|
||||
return (
|
||||
responseCode === '404' ||
|
||||
normalizedMessage.includes('unknown operate') ||
|
||||
normalizedMessage.includes('not found') ||
|
||||
normalizedMessage.includes('no handler found')
|
||||
)
|
||||
}
|
||||
|
||||
const requestAddData = async <T>(
|
||||
method: AddDataRequestMethod,
|
||||
path: string,
|
||||
params?: object
|
||||
): Promise<ResultData<T>> => {
|
||||
let lastError: unknown
|
||||
const requestPaths = buildAddDataRequestPaths(path)
|
||||
|
||||
for (let index = 0; index < requestPaths.length; index += 1) {
|
||||
const requestPath = requestPaths[index]
|
||||
|
||||
try {
|
||||
if (method === 'get') {
|
||||
return await http.get<T>(requestPath)
|
||||
}
|
||||
|
||||
return await http.post<T>(requestPath, params)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (index === requestPaths.length - 1 || !isFallbackableAddDataError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export const getAddDataPreview = (params: AddData.TaskRequestParams) => {
|
||||
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
|
||||
}
|
||||
|
||||
export const createAddDataTask = (params: AddData.TaskRequestParams) => {
|
||||
return requestAddData<AddData.CreateTaskResponse>('post', '/task/create', params)
|
||||
}
|
||||
|
||||
export const getAddDataTaskStatus = (taskId: string | number) => {
|
||||
return requestAddData<AddData.TaskStatusResponse>('get', `/task/status/${taskId}`)
|
||||
}
|
||||
|
||||
export const getAddDataTemplateList = () => {
|
||||
return requestAddData<AddData.TemplateItem[]>('get', '/template/list')
|
||||
}
|
||||
109
frontend/src/api/tools/addData/interface/index.ts
Normal file
109
frontend/src/api/tools/addData/interface/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export namespace AddData {
|
||||
export type LineMode = 'single' | 'multiple'
|
||||
export type IntervalMinutes = 1 | 3 | 5 | 10
|
||||
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
|
||||
|
||||
export interface TaskRequestParams {
|
||||
lineIds: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
intervalMinutes: IntervalMinutes
|
||||
}
|
||||
|
||||
export interface TaskFormModel {
|
||||
lineMode: LineMode
|
||||
lineIds: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
intervalMinutes: IntervalMinutes
|
||||
}
|
||||
|
||||
export interface PreviewTableStat {
|
||||
tableName?: string
|
||||
timePointCount?: number | string
|
||||
phaseCount?: number | string
|
||||
rowCount?: number | string
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
lineCount?: number | string
|
||||
intervalMinutes?: number | string
|
||||
totalRowCount?: number | string
|
||||
tableStats?: PreviewTableStat[]
|
||||
}
|
||||
|
||||
export interface CreateTaskResponse {
|
||||
taskId?: string | number
|
||||
status?: TaskStatus
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
taskId?: string
|
||||
status?: TaskStatus
|
||||
currentTableName?: string
|
||||
currentBatchInfo?: string
|
||||
insertedCount?: number | string
|
||||
skippedCount?: number | string
|
||||
failedCount?: number | string
|
||||
failureReason?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
hourlyTimeResults?: string[]
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
parameterName?: string
|
||||
tableName?: string
|
||||
phaseDisplay?: string
|
||||
phaseCodes?: string[]
|
||||
display?: boolean
|
||||
showQualified?: boolean
|
||||
maxValueRule?: string
|
||||
minValueRule?: string
|
||||
averageValueRule?: string
|
||||
cp95ValueRule?: string
|
||||
decimalScale?: number | string
|
||||
}
|
||||
|
||||
export interface PreviewTableSummary {
|
||||
tableName: string
|
||||
timePointCount: number
|
||||
phaseCount: number
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
export interface NormalizedPreview {
|
||||
lineCount: number
|
||||
intervalMinutes: number
|
||||
totalRowCount: number
|
||||
tableStats: PreviewTableSummary[]
|
||||
}
|
||||
|
||||
export interface NormalizedTaskStatus {
|
||||
taskId: string
|
||||
status: TaskStatus
|
||||
currentTableName: string
|
||||
currentBatchInfo: string
|
||||
insertedCount: number
|
||||
skippedCount: number
|
||||
failedCount: number
|
||||
failureReason: string
|
||||
hourlyTimeResults: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
export interface NormalizedTemplateItem {
|
||||
parameterName: string
|
||||
tableName: string
|
||||
phaseDisplay: string
|
||||
phaseCodesText: string
|
||||
displayText: string
|
||||
showQualifiedText: string
|
||||
maxValueRule: string
|
||||
minValueRule: string
|
||||
averageValueRule: string
|
||||
cp95ValueRule: string
|
||||
decimalScaleText: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<el-dialog class="disk-monitor-dialog" :model-value="props.visible" title="全局策略" width="880px" @close="closeDialog">
|
||||
<div class="dialog-section dialog-section--plain">
|
||||
<el-alert
|
||||
class="policy-alert"
|
||||
title="通知规则"
|
||||
description="预警按状态变化通知,告警每次命中都通知"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">基础配置</div>
|
||||
<el-form label-width="130px" class="policy-form">
|
||||
<el-form-item label="启用监控">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.monitorEnabled"
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="value => patchPolicy({ monitorEnabled: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="启动即监控">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.runOnAppStart"
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="value => patchPolicy({ runOnAppStart: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每日执行时间">
|
||||
<el-time-picker
|
||||
:model-value="props.modelValue.dailyRunTime"
|
||||
:disabled="props.disabled"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
@update:model-value="value => patchPolicy({ dailyRunTime: typeof value === 'string' ? value : '' })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" plain :loading="props.runLoading" :disabled="props.disabled" @click="emit('run')">
|
||||
立即执行监控
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="props.saveLoading" :disabled="props.disabled" @click="emit('confirm')">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorPolicyDialog'
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
modelValue: DiskMonitor.PolicyItem
|
||||
disabled?: boolean
|
||||
saveLoading?: boolean
|
||||
runLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
saveLoading: false,
|
||||
runLoading: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
'update:modelValue': [value: DiskMonitor.PolicyItem]
|
||||
confirm: []
|
||||
run: []
|
||||
}>()
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const patchPolicy = (patch: Partial<DiskMonitor.PolicyItem>) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
...patch
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-dialog :deep(.el-dialog__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-section--plain {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.policy-alert {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item__content) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-time-picker) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal file
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal file
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<section class="card add-data-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="section-title">补数任务</div>
|
||||
<div class="section-description">
|
||||
手工输入监测点 ID,先预估本次写入规模,再发起异步补数任务。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<el-alert
|
||||
class="task-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="时间步长仅影响 10 张基础实时类表,data_flicker / data_fluc 固定 10 分钟,data_plt 固定 2 小时。"
|
||||
/>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="108px" class="task-form">
|
||||
<div class="form-row form-row-first">
|
||||
<el-form-item class="form-item-line-ids" label="监测点 ID" prop="lineIds">
|
||||
<div class="line-id-input-group">
|
||||
<el-input
|
||||
v-model="lineIdsText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
resize="vertical"
|
||||
:placeholder="lineIdsPlaceholder"
|
||||
/>
|
||||
<div class="line-id-actions">
|
||||
<el-button type="primary" plain :icon="CirclePlus" @click="handleOpenGuidDialog">
|
||||
新增监测点
|
||||
</el-button>
|
||||
<el-button type="primary" plain :icon="Delete" @click="handleClearLineIds">
|
||||
清空监测点
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-second">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="localForm.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择开始时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker
|
||||
v-model="localForm.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-third">
|
||||
<el-form-item class="form-item-interval" label="时间步长" prop="intervalMinutes">
|
||||
<el-radio-group v-model="localForm.intervalMinutes">
|
||||
<el-radio-button v-for="item in intervalOptions" :key="item" :label="item">
|
||||
{{ item }} 分钟
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" plain :icon="Histogram" :loading="previewLoading" @click="emit('preview')">
|
||||
预估写入量
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:loading="submitLoading"
|
||||
:disabled="taskRunning"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
开始补数
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-header">
|
||||
<div class="preview-title">预估结果</div>
|
||||
<div class="preview-text">参数发生变化后需要重新预估,才能继续创建任务。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="preview-content">
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="监测点数量">{{ preview.lineCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间步长">{{ preview.intervalMinutes }} 分钟</el-descriptions-item>
|
||||
<el-descriptions-item label="总预计条数">{{ preview.totalRowCount }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table class="preview-table" :data="preview.tableStats" border stripe :max-height="320">
|
||||
<el-table-column prop="tableName" label="数据表" min-width="180" />
|
||||
<el-table-column prop="timePointCount" label="时间点数量" min-width="120" align="right" />
|
||||
<el-table-column prop="phaseCount" label="相别数量" min-width="120" align="right" />
|
||||
<el-table-column prop="rowCount" label="预计条数" min-width="120" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-block">暂无预估结果,请先填写参数并点击“预估写入量”。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="guidDialogVisible" title="新增 guid" width="420px" @closed="handleGuidDialogClosed">
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="guid 数量">
|
||||
<el-input-number v-model="guidCount" :min="1" :step="1" :precision="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="guidDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAppendGuids">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CirclePlus, Delete, Histogram, Promotion } from '@element-plus/icons-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTaskPanel'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
form: AddData.TaskFormModel
|
||||
preview: AddData.NormalizedPreview | null
|
||||
previewLoading: boolean
|
||||
submitLoading: boolean
|
||||
taskRunning: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: []
|
||||
submit: []
|
||||
'update:form': [form: AddData.TaskFormModel]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const syncingFromProp = ref(false)
|
||||
const guidDialogVisible = ref(false)
|
||||
const guidCount = ref(1)
|
||||
const intervalOptions: AddData.IntervalMinutes[] = [1, 3, 5, 10]
|
||||
const localForm = reactive<AddData.TaskFormModel>({
|
||||
lineMode: 'multiple',
|
||||
lineIds: [...props.form.lineIds],
|
||||
startTime: props.form.startTime,
|
||||
endTime: props.form.endTime,
|
||||
intervalMinutes: props.form.intervalMinutes
|
||||
})
|
||||
|
||||
const syncLocalForm = (form: AddData.TaskFormModel) => {
|
||||
localForm.lineMode = 'multiple'
|
||||
localForm.lineIds = [...form.lineIds]
|
||||
localForm.startTime = form.startTime
|
||||
localForm.endTime = form.endTime
|
||||
localForm.intervalMinutes = form.intervalMinutes
|
||||
}
|
||||
|
||||
const normalizeLineIds = (lineIds: string[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(lineIds || [])
|
||||
.map(item => item?.trim())
|
||||
.filter((item): item is string => Boolean(item))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const splitLineIdsText = (value: string) => {
|
||||
return value
|
||||
.split(/[\s,\uFF0C]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const isValidLineId = (value: string) => {
|
||||
const normalizedValue = value.trim()
|
||||
return Boolean(normalizedValue) && normalizedValue.length <= 32
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.form,
|
||||
value => {
|
||||
syncingFromProp.value = true
|
||||
syncLocalForm(value)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
localForm,
|
||||
value => {
|
||||
if (syncingFromProp.value) {
|
||||
syncingFromProp.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:form', {
|
||||
lineMode: 'multiple',
|
||||
lineIds: [...value.lineIds],
|
||||
startTime: value.startTime,
|
||||
endTime: value.endTime,
|
||||
intervalMinutes: value.intervalMinutes
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const setLineIds = (lineIds: string[]) => {
|
||||
localForm.lineIds = normalizeLineIds(lineIds)
|
||||
}
|
||||
|
||||
const handleClearLineIds = () => {
|
||||
setLineIds([])
|
||||
}
|
||||
|
||||
const lineIdsText = computed({
|
||||
get: () => localForm.lineIds.join(','),
|
||||
set: value => {
|
||||
setLineIds(splitLineIdsText(value))
|
||||
}
|
||||
})
|
||||
|
||||
const lineIdsPlaceholder = computed(
|
||||
() => '请输入一个或多个监测点 ID,多个值可用英文逗号、中文逗号、空格或换行分隔,单个长度不超过 32'
|
||||
)
|
||||
|
||||
const generateGuidText = () => {
|
||||
return window.crypto.randomUUID().replace(/-/g, '')
|
||||
}
|
||||
|
||||
const handleOpenGuidDialog = () => {
|
||||
guidCount.value = 1
|
||||
guidDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleGuidDialogClosed = () => {
|
||||
guidCount.value = 1
|
||||
}
|
||||
|
||||
const handleAppendGuids = () => {
|
||||
const count = Number(guidCount.value)
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
ElMessage.warning('请输入大于 0 的整数 guid 数量')
|
||||
return
|
||||
}
|
||||
|
||||
// 前端补充的 guid 仍需满足后端 lineIds 非空且长度不超过 32 的契约。
|
||||
const nextLineIds = [...localForm.lineIds, ...Array.from({ length: count }, () => generateGuidText())]
|
||||
setLineIds(nextLineIds)
|
||||
guidDialogVisible.value = false
|
||||
}
|
||||
|
||||
const formRules: FormRules<AddData.TaskFormModel> = {
|
||||
lineIds: [
|
||||
{
|
||||
validator: (_rule, value: string[], callback) => {
|
||||
const validLineIds = normalizeLineIds(value)
|
||||
if (!validLineIds.length) {
|
||||
callback(new Error('请至少输入一个监测点 ID'))
|
||||
return
|
||||
}
|
||||
|
||||
if (validLineIds.some(item => !isValidLineId(item))) {
|
||||
callback(new Error('监测点 ID 不能为空,且长度不能超过 32'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [
|
||||
{ required: true, message: '请选择结束时间', trigger: 'change' },
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value || !localForm.startTime) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
if (new Date(value).getTime() < new Date(localForm.startTime).getTime()) {
|
||||
callback(new Error('开始时间不能大于结束时间'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
intervalMinutes: [{ required: true, message: '请选择时间步长', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const validateTaskForm = async () => {
|
||||
const result = await formRef.value?.validate().catch(() => false)
|
||||
return Boolean(result)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validateTaskForm
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-alert,
|
||||
.task-form {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.form-row + .form-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-row-first {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.form-row-second {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-row-third {
|
||||
grid-template-columns: minmax(280px, auto) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-item-line-ids,
|
||||
.form-item-interval {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-item-line-ids :deep(.el-form-item__content),
|
||||
.form-item-interval :deep(.el-form-item__content) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-radio-group) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.line-id-input-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line-id-input-group :deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.line-id-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line-id-actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-table :deep(.el-table__body-wrapper) {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row,
|
||||
.form-row-second,
|
||||
.form-row-third {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.line-id-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.line-id-input-group :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.line-id-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<section class="card add-data-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="section-title">任务状态</div>
|
||||
<div class="section-description">创建任务后自动轮询状态,直到任务成功或失败。</div>
|
||||
</div>
|
||||
|
||||
<el-tag v-if="status" :type="statusMeta.type" effect="light">{{ statusMeta.label }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="card-body">
|
||||
<div v-if="status" class="status-content">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="任务 ID">{{ taskId || status.taskId || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">{{ statusMeta.label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前表名">{{ status.currentTableName || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前批次">{{ status.currentBatchInfo || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已写入数量">{{ status.insertedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已跳过数量">{{ status.skippedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败数量">{{ status.failedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因">
|
||||
<span class="failure-text">{{ status.failureReason || '--' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ status.startTime || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ status.endTime || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="hourly-block">
|
||||
<div class="hourly-title">业务时刻</div>
|
||||
<div v-if="status.hourlyTimeResults.length" class="hourly-scroll">
|
||||
<div class="hourly-list">
|
||||
<el-tag v-for="item in status.hourlyTimeResults" :key="item" effect="plain" type="info">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="hourly-empty">当前接口未返回业务时刻。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-block">暂无补数任务,创建任务后会在这里持续展示执行进度。</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTaskStatusCard'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
status: AddData.NormalizedTaskStatus | null
|
||||
taskId: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const statusMeta = computed(() => {
|
||||
const status = props.status?.status
|
||||
|
||||
if (status === 'SUCCESS') {
|
||||
return { label: '成功', type: 'success' as const }
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
return { label: '失败', type: 'danger' as const }
|
||||
}
|
||||
|
||||
if (status === 'RUNNING') {
|
||||
return { label: '执行中', type: 'warning' as const }
|
||||
}
|
||||
|
||||
if (status === 'WAITING') {
|
||||
return { label: '等待中', type: 'info' as const }
|
||||
}
|
||||
|
||||
return {
|
||||
label: status || '未知状态',
|
||||
type: 'info' as const
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
word-break: break-all;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.hourly-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hourly-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.hourly-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.hourly-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hourly-empty {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section class="table-main card add-data-template-card">
|
||||
<div class="table-header">
|
||||
<div class="header-title-group">
|
||||
<div class="section-title">参数规则</div>
|
||||
<div class="section-description">模板规则来自后端配置,用于说明参数展示字段、相别映射和统计规则。</div>
|
||||
</div>
|
||||
|
||||
<div class="header-tools">
|
||||
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-body">
|
||||
<el-table v-loading="loading" :data="rows" border stripe height="100%">
|
||||
<el-table-column prop="parameterName" label="电能质量参数名称" min-width="220" fixed="left" />
|
||||
<el-table-column prop="tableName" label="落库表" min-width="140" />
|
||||
<el-table-column prop="phaseDisplay" label="展示相别" min-width="110" align="center" />
|
||||
<el-table-column prop="phaseCodesText" label="落库相别" min-width="140" align="center" />
|
||||
<el-table-column prop="displayText" label="是否展示" min-width="110" align="center" />
|
||||
<el-table-column prop="showQualifiedText" label="是否展示合格" min-width="140" align="center" />
|
||||
<el-table-column prop="maxValueRule" label="最大值规则" min-width="160" />
|
||||
<el-table-column prop="minValueRule" label="最小值规则" min-width="160" />
|
||||
<el-table-column prop="averageValueRule" label="平均值规则" min-width="160" />
|
||||
<el-table-column prop="cp95ValueRule" label="95% 概率大值规则" min-width="180" />
|
||||
<el-table-column prop="decimalScaleText" label="小数位数" min-width="120" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTemplateTable'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
rows: AddData.NormalizedTemplateItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-template-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.header-title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-body :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
465
frontend/src/views/tools/addData/index.vue
Normal file
465
frontend/src/views/tools/addData/index.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="table-box add-data-page">
|
||||
<div class="add-data-layout">
|
||||
<div class="add-data-main-column">
|
||||
<AddDataTaskPanel
|
||||
ref="taskPanelRef"
|
||||
:form="taskForm"
|
||||
:preview="previewSummary"
|
||||
:preview-loading="loading.preview"
|
||||
:submit-loading="loading.create"
|
||||
:task-running="taskRunning"
|
||||
@update:form="handleTaskFormChange"
|
||||
@preview="handlePreview"
|
||||
@submit="handleCreateTask"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="add-data-side-panel">
|
||||
<el-tabs v-model="activeTab" class="add-data-tabs">
|
||||
<el-tab-pane label="任务状态" name="taskStatus">
|
||||
<AddDataTaskStatusCard :status="taskStatus" :task-id="currentTaskId" :loading="loading.status" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="参数规则" name="templateRules">
|
||||
<AddDataTemplateTable :rows="templateRows" :loading="loading.template" @refresh="loadTemplateList" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createAddDataTask,
|
||||
getAddDataPreview,
|
||||
getAddDataTaskStatus,
|
||||
getAddDataTemplateList
|
||||
} from '@/api/tools/addData'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
import AddDataTaskPanel from './components/AddDataTaskPanel.vue'
|
||||
import AddDataTaskStatusCard from './components/AddDataTaskStatusCard.vue'
|
||||
import AddDataTemplateTable from './components/AddDataTemplateTable.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataView'
|
||||
})
|
||||
|
||||
type AddDataTaskPanelExpose = {
|
||||
validateTaskForm: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const taskPanelRef = ref<AddDataTaskPanelExpose | null>(null)
|
||||
const activeTab = ref('taskStatus')
|
||||
const templateRows = ref<AddData.NormalizedTemplateItem[]>([])
|
||||
const previewSummary = ref<AddData.NormalizedPreview | null>(null)
|
||||
const taskStatus = ref<AddData.NormalizedTaskStatus | null>(null)
|
||||
const currentTaskId = ref('')
|
||||
const previewSignature = ref('')
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingBusy = ref(false)
|
||||
const loading = reactive({
|
||||
template: false,
|
||||
preview: false,
|
||||
create: false,
|
||||
status: false
|
||||
})
|
||||
const taskForm = reactive<AddData.TaskFormModel>({
|
||||
lineMode: 'multiple',
|
||||
lineIds: [],
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
intervalMinutes: 1
|
||||
})
|
||||
|
||||
const handleTaskFormChange = (nextForm: AddData.TaskFormModel) => {
|
||||
taskForm.lineMode = 'multiple'
|
||||
taskForm.lineIds = [...nextForm.lineIds]
|
||||
taskForm.startTime = nextForm.startTime
|
||||
taskForm.endTime = nextForm.endTime
|
||||
taskForm.intervalMinutes = nextForm.intervalMinutes
|
||||
}
|
||||
|
||||
const normalizeLineIds = (lineIds: string[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(lineIds || [])
|
||||
.map(item => item?.trim())
|
||||
.filter((item): item is string => Boolean(item))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const parseLineIds = (lineIds: string[]) => {
|
||||
return normalizeLineIds(lineIds)
|
||||
}
|
||||
|
||||
const resetPreview = () => {
|
||||
previewSummary.value = null
|
||||
previewSignature.value = ''
|
||||
}
|
||||
|
||||
const resolveNumber = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined || value === '') continue
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const resolveText = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) continue
|
||||
const text = String(value).trim()
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveDisplayRule = (value: unknown, fallback = '--') => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '显示' : '不显示'
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value ? '显示' : '不显示'
|
||||
}
|
||||
|
||||
const text = resolveText(value)
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
const normalizePreview = (data?: AddData.PreviewResponse | null): AddData.NormalizedPreview => {
|
||||
const tableStats = (Array.isArray(data?.tableStats) ? data.tableStats : [])
|
||||
.map(item => ({
|
||||
tableName: resolveText(item.tableName) || '--',
|
||||
timePointCount: resolveNumber(item.timePointCount),
|
||||
phaseCount: resolveNumber(item.phaseCount),
|
||||
rowCount: resolveNumber(item.rowCount)
|
||||
}))
|
||||
.sort((left, right) => right.rowCount - left.rowCount)
|
||||
|
||||
return {
|
||||
lineCount: resolveNumber(data?.lineCount),
|
||||
intervalMinutes: resolveNumber(data?.intervalMinutes),
|
||||
totalRowCount: resolveNumber(data?.totalRowCount),
|
||||
tableStats
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): AddData.NormalizedTaskStatus => {
|
||||
return {
|
||||
taskId: resolveText(data?.taskId),
|
||||
status: (resolveText(data?.status) || 'WAITING') as AddData.TaskStatus,
|
||||
currentTableName: resolveText(data?.currentTableName),
|
||||
currentBatchInfo: resolveText(data?.currentBatchInfo),
|
||||
insertedCount: resolveNumber(data?.insertedCount),
|
||||
skippedCount: resolveNumber(data?.skippedCount),
|
||||
failedCount: resolveNumber(data?.failedCount),
|
||||
failureReason: resolveText(data?.failureReason),
|
||||
hourlyTimeResults: Array.isArray(data?.hourlyTimeResults) ? data.hourlyTimeResults.filter(Boolean) : [],
|
||||
startTime: resolveText(data?.startTime),
|
||||
endTime: resolveText(data?.endTime)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
|
||||
const decimalScale = resolveText(item.decimalScale)
|
||||
|
||||
return {
|
||||
parameterName: resolveText(item.parameterName) || '--',
|
||||
tableName: resolveText(item.tableName) || '--',
|
||||
phaseDisplay: resolveText(item.phaseDisplay) || '--',
|
||||
phaseCodesText: Array.isArray(item.phaseCodes) && item.phaseCodes.length ? item.phaseCodes.join(' / ') : '--',
|
||||
displayText: resolveDisplayRule(item.display),
|
||||
showQualifiedText: resolveDisplayRule(item.showQualified),
|
||||
maxValueRule: resolveText(item.maxValueRule) || '--',
|
||||
minValueRule: resolveText(item.minValueRule) || '--',
|
||||
averageValueRule: resolveText(item.averageValueRule) || '--',
|
||||
cp95ValueRule: resolveText(item.cp95ValueRule) || '--',
|
||||
decimalScaleText: decimalScale ? `${decimalScale} 位小数` : '--'
|
||||
}
|
||||
}
|
||||
|
||||
const buildTaskPayload = (): AddData.TaskRequestParams => {
|
||||
return {
|
||||
lineIds: parseLineIds(taskForm.lineIds),
|
||||
startTime: taskForm.startTime,
|
||||
endTime: taskForm.endTime,
|
||||
intervalMinutes: taskForm.intervalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
const buildPreviewDependencySignature = () => {
|
||||
return buildPayloadSignature(buildTaskPayload())
|
||||
}
|
||||
|
||||
const isTerminalStatus = (status?: AddData.TaskStatus) => {
|
||||
return status === 'SUCCESS' || status === 'FAILED'
|
||||
}
|
||||
|
||||
const taskRunning = computed(() => {
|
||||
const status = taskStatus.value?.status
|
||||
return Boolean(currentTaskId.value && (status === 'WAITING' || status === 'RUNNING'))
|
||||
})
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer.value !== null) {
|
||||
window.clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplateList = async () => {
|
||||
loading.template = true
|
||||
try {
|
||||
const response = await getAddDataTemplateList()
|
||||
const rows = Array.isArray(response.data) ? response.data : []
|
||||
templateRows.value = rows.map(item => normalizeTemplateItem(item))
|
||||
} finally {
|
||||
loading.template = false
|
||||
}
|
||||
}
|
||||
|
||||
const getValidatedPayload = async () => {
|
||||
const isValid = await taskPanelRef.value?.validateTaskForm()
|
||||
if (!isValid) return null
|
||||
|
||||
const payload = buildTaskPayload()
|
||||
if (!payload.lineIds.length) {
|
||||
ElMessage.warning('请至少输入一个合法的监测点 ID')
|
||||
return null
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
|
||||
if (!taskId || pollingBusy.value) return
|
||||
|
||||
pollingBusy.value = true
|
||||
if (!silent) {
|
||||
loading.status = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getAddDataTaskStatus(taskId)
|
||||
// 状态接口是补数任务唯一的进度来源,统一在这里按正式契约归一化,避免页面层分散兼容字段。
|
||||
const normalizedStatus = normalizeTaskStatus(response.data)
|
||||
taskStatus.value = normalizedStatus
|
||||
currentTaskId.value = normalizedStatus.taskId || taskId
|
||||
|
||||
if (isTerminalStatus(normalizedStatus.status)) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
// 轮询失败时立即停止定时器,避免请求持续堆积并反复弹出相同错误。
|
||||
stopPolling()
|
||||
throw error
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.status = false
|
||||
}
|
||||
pollingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = (taskId: string) => {
|
||||
stopPolling()
|
||||
currentTaskId.value = taskId
|
||||
|
||||
// 创建任务后先立即拉一次状态,再进入固定轮询,避免页面长时间停留在初始态。
|
||||
void loadTaskStatus(taskId).catch(() => null)
|
||||
|
||||
pollTimer.value = window.setInterval(() => {
|
||||
void loadTaskStatus(taskId, true).catch(() => null)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
const payload = await getValidatedPayload()
|
||||
if (!payload) return
|
||||
|
||||
loading.preview = true
|
||||
try {
|
||||
const response = await getAddDataPreview(payload)
|
||||
// preview 是 create 前的唯一准入检查,必须严格按正式契约读取 totalRowCount 和 tableStats。
|
||||
previewSummary.value = normalizePreview(response.data)
|
||||
previewSignature.value = buildPayloadSignature(payload)
|
||||
ElMessage.success('写入规模预估完成')
|
||||
} finally {
|
||||
loading.preview = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTask = async () => {
|
||||
if (taskRunning.value) {
|
||||
ElMessage.warning('当前补数任务仍在执行,请等待结束后再创建新任务')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await getValidatedPayload()
|
||||
if (!payload) return
|
||||
|
||||
const currentSignature = buildPayloadSignature(payload)
|
||||
if (!previewSummary.value || previewSignature.value !== currentSignature) {
|
||||
ElMessage.warning('参数已变化,请先重新预估写入量')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`预计写入 ${previewSummary.value.totalRowCount} 条数据,涉及 ${payload.lineIds.length} 个监测点,确认开始补数?`,
|
||||
'开始补数',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '开始补数',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loading.create = true
|
||||
try {
|
||||
const response = await createAddDataTask(payload)
|
||||
const taskId = resolveText(response.data?.taskId)
|
||||
|
||||
taskStatus.value = normalizeTaskStatus({
|
||||
taskId,
|
||||
status: response.data?.status
|
||||
})
|
||||
|
||||
if (!taskId) {
|
||||
ElMessage.warning('任务已创建,但接口未返回 taskId,无法继续轮询状态')
|
||||
return
|
||||
}
|
||||
|
||||
startPolling(taskId)
|
||||
ElMessage.success('补数任务已创建,正在轮询状态')
|
||||
} finally {
|
||||
loading.create = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
// 预估失效判断必须与 preview/create 的正式请求参数保持同一口径,避免仅切换界面模式也误判为参数变化。
|
||||
() => buildPreviewDependencySignature(),
|
||||
() => {
|
||||
resetPreview()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTemplateList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-main-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-main-column > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__header) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.add-data-tabs :deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-tabs :deep(.el-tab-pane > *) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.add-data-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user