feat(tools): 新增数据补数功能模块

- 实现补数任务面板组件,支持监测点ID输入、时间范围选择和时间步长设置
- 添加任务状态卡片组件,实时展示任务执行进度和结果统计
- 集成参数规则表格组件,显示后端配置的模板规则信息
- 实现补数API接口服务,包括预估写入量、创建任务和查询状态功能
- 添加磁盘监控策略对话框组件,支持全局监控配置管理
- 完成补数功能页面布局设计,集成左右双栏界面结构
- 实现任务轮询机制,自动更新任务执行状态直到完成
- 添加表单验证逻辑,确保输入参数符合业务规则要求
This commit is contained in:
2026-04-30 09:04:52 +08:00
parent 398a2cf1dc
commit 0dc0e4ecdc
7 changed files with 1649 additions and 0 deletions

View 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')
}

View 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
}
}

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>