|
|
@@ -1,14 +1,14 @@
|
|
|
<script setup lang="ts">
|
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
|
-import { ElMessageBox } from 'element-plus'
|
|
|
-import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchFaults, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import { createLabel, deleteLabel, fetchFaults, fetchLabels, fetchPeriodDetail, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, updateLabel } from './api'
|
|
|
import PeriodModal from './components/PeriodModal.vue'
|
|
|
import AlarmMonitor from './components/AlarmMonitor.vue'
|
|
|
import Model2 from './components/Model2.vue'
|
|
|
import TimePointStrip from './components/TimePointStrip.vue'
|
|
|
import WaveChart from './components/WaveChart.vue'
|
|
|
import { STOPPED_COLOR, statusGradientColor } from './statusColor'
|
|
|
-import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Annotation, type AnnotationLabel, type Cycle, type DevicePoint, type Fault, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
|
|
|
+import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Cycle, type DevicePoint, type Fault, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveLabel, type WaveWindowFile, type WaveWindowResponse } from './types'
|
|
|
|
|
|
const DEFAULT_DEVICE_PART = '7号机组一缸'
|
|
|
const queryMeta = ref<QueryOptionsResponse | null>(null)
|
|
|
@@ -45,16 +45,130 @@ const initialized = ref(false)
|
|
|
const periodDetail = ref<PeriodDetail | null>(null)
|
|
|
const periodVisible = ref(false)
|
|
|
const periodLoading = ref(false)
|
|
|
-const annotationWidth = ref(10)
|
|
|
-const annotations = ref<Annotation[]>([])
|
|
|
-const annotationLoading = ref(false)
|
|
|
+const labels = ref<WaveLabel[]>([])
|
|
|
+const labelDialogVisible = ref(false)
|
|
|
+const labelSaving = ref(false)
|
|
|
+const LABEL_SEVERITY: Record<string, number> = { 轻微: 1, 异常: 2, 严重异常: 3 }
|
|
|
+const labelForm = reactive({
|
|
|
+ id: null as number | null,
|
|
|
+ rowKey: '',
|
|
|
+ labelType: '异常',
|
|
|
+ startTime: '',
|
|
|
+ endTime: '',
|
|
|
+ note: '',
|
|
|
+ features: null as Record<string, unknown> | string | null,
|
|
|
+ targetType: 'pks' as 'wave_file' | 'pks',
|
|
|
+ channelKey: '',
|
|
|
+ unit: '',
|
|
|
+ deviceCode: '' as string | null,
|
|
|
+ devicePart: '' as string | null,
|
|
|
+ devicePoint: '' as string | null,
|
|
|
+ metricKey: '' as string | null,
|
|
|
+ metricLabel: '' as string | null,
|
|
|
+ origin: 'manual',
|
|
|
+})
|
|
|
+
|
|
|
+const HOUR_FEATURE_KEYS = new Set([
|
|
|
+ '持续自然小时数', '有效运行小时数',
|
|
|
+ '阶段低报警小时数', '阶段低低报警小时数',
|
|
|
+ '阶段高报警小时数', '阶段高高报警小时数',
|
|
|
+])
|
|
|
+
|
|
|
+const LABEL_FEATURE_NAMES: Record<string, string> = {
|
|
|
+ 机组: '机组',
|
|
|
+ 批次: '批次',
|
|
|
+ 周期编号: '周期编号',
|
|
|
+ 级: '级',
|
|
|
+ 振动阶段: '振动阶段',
|
|
|
+ 压力阶段: '压力阶段',
|
|
|
+ 温度阶段: '温度阶段',
|
|
|
+ 阶段部位: '部位',
|
|
|
+ 阶段点位: '点位',
|
|
|
+ 开始小时: '开始',
|
|
|
+ 结束小时: '结束',
|
|
|
+ 持续自然小时数: '持续',
|
|
|
+ 有效运行小时数: '有效',
|
|
|
+ 周期基线油压: '周期基线',
|
|
|
+ 近期基线振动: '近期基线',
|
|
|
+ 正常波动带: '波动带',
|
|
|
+ 阶段开始小时油压: '开始油压',
|
|
|
+ 阶段结束小时油压: '结束油压',
|
|
|
+ 阶段最低小时油压: '最低油压',
|
|
|
+ 阶段开始小时振动: '开始振动',
|
|
|
+ 阶段结束小时振动: '结束振动',
|
|
|
+ 阶段最大振动值: '最大振动',
|
|
|
+ 阶段开始相对周期基线变化: '开始相对基线',
|
|
|
+ 阶段结束相对周期基线变化: '结束相对基线',
|
|
|
+ 阶段开始相对近期基线变化: '开始相对基线',
|
|
|
+ 阶段结束相对近期基线变化: '结束相对基线',
|
|
|
+ 阶段最大连续下降有效小时数: '最大连续下降',
|
|
|
+ 阶段最大连续异常有效小时数: '最大连续异常',
|
|
|
+ 阶段低报警小时数: '低报警',
|
|
|
+ 阶段低低报警小时数: '低低报警',
|
|
|
+ 阶段高报警小时数: '高报警',
|
|
|
+ 阶段高高报警小时数: '高高报警',
|
|
|
+ 是否形成确认等级: '确认等级',
|
|
|
+ 阶段触发依据: '触发依据',
|
|
|
+ 阶段综合评分: '综合评分',
|
|
|
+ 持续时间得分: '持续得分',
|
|
|
+ 平均振动得分: '平均得分',
|
|
|
+ 最大振动值得分: '最大得分',
|
|
|
+ 峰值偏离得分: '峰值偏离',
|
|
|
+ 最强异常侧: '最强侧',
|
|
|
+ 最强侧近期峰值基线: '最强侧基线',
|
|
|
+ 最强侧近期稳健尺度: '稳健尺度',
|
|
|
+ '阶段最大峰值/基线比例': '峰值/基线',
|
|
|
+ 峰值时刻: '峰值时刻',
|
|
|
+ 峰值缸: '峰值缸',
|
|
|
+ 最高温度: '最高温度',
|
|
|
+ 最大自身偏离: '自身偏离',
|
|
|
+ 最大级差: '最大级差',
|
|
|
+ 最大级差基线: '级差基线',
|
|
|
+ 最大级差变化: '级差变化',
|
|
|
+ 最大同级偏离: '同级偏离',
|
|
|
+ 最大同级偏低: '同级偏低',
|
|
|
+ 最大稳健z: '稳健z',
|
|
|
+ 最大同级相对1h: '同级相对1h',
|
|
|
+ 最大同级相对3h: '同级相对3h',
|
|
|
+ 最大同级相对6h: '同级相对6h',
|
|
|
+ 首次短时触发: '首次短时',
|
|
|
+ 首次持续触发: '首次持续',
|
|
|
+ 阶段最高报警阈值: '高报警阈值',
|
|
|
+ 阶段高高报警阈值: '高高报警阈值',
|
|
|
+ 方向: '方向',
|
|
|
+ 特征类型: '特征类型',
|
|
|
+ 置信度: '置信度',
|
|
|
+ 高频波动倍数: '高频倍数',
|
|
|
+ 高频波动z: '高频z',
|
|
|
+ 高频标准差: '高频标准差',
|
|
|
+ 高频基线标准差: '高频基线标准差',
|
|
|
+ 高频波动缸: '高频波动缸',
|
|
|
+ 去趋势残差标准差: '去趋势残差',
|
|
|
+ 每小时反复升降次数: '反复升降次数',
|
|
|
+ 关联通道异常: '关联通道异常',
|
|
|
+}
|
|
|
+
|
|
|
+const labelFeatureItems = computed(() => {
|
|
|
+ const features = labelForm.features
|
|
|
+ if (!features || typeof features === 'string') return []
|
|
|
+ return Object.entries(features).map(([key, value]) => {
|
|
|
+ const empty = value === null || value === undefined || value === ''
|
|
|
+ let text = empty ? '—' : String(value)
|
|
|
+ if (!empty && HOUR_FEATURE_KEYS.has(key)) text += ' 小时'
|
|
|
+ if (!empty && key === '模型预测参考') text = text.replace(/^模型预测参考:?/, '')
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ label: LABEL_FEATURE_NAMES[key] ?? key,
|
|
|
+ value: text,
|
|
|
+ full: key === '阶段触发依据' || key === '模型预测参考' || key === '关联通道异常',
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
const loggedIn = ref(false)
|
|
|
const loginLoading = ref(false)
|
|
|
const loginError = ref('')
|
|
|
const loginForm = reactive({ username: '', password: '' })
|
|
|
const waveChartRef = ref<InstanceType<typeof WaveChart> | null>(null)
|
|
|
-const recordedFileIds = ref<number[]>([])
|
|
|
-const recordingFileId = ref<number | null>(null)
|
|
|
let queryDebounce: ReturnType<typeof setTimeout> | undefined
|
|
|
let waveRequestId = 0
|
|
|
|
|
|
@@ -106,12 +220,11 @@ const windowSiteRows = computed(() => {
|
|
|
}))
|
|
|
})
|
|
|
|
|
|
-const devicePointCounts = computed<Record<string, { pre: number | null; ab: number | null }>>(() => {
|
|
|
- const map: Record<string, { pre: number | null; ab: number | null }> = {}
|
|
|
+const devicePointCounts = computed<Record<string, { ab: number | null }>>(() => {
|
|
|
+ const map: Record<string, { ab: number | null }> = {}
|
|
|
for (const point of devicePoints.value) {
|
|
|
const pointName = `${selectedDevicePart.value}${point}`
|
|
|
map[point] = {
|
|
|
- pre: queryMeta.value?.pretrainCounts?.[pointName] ?? null,
|
|
|
ab: queryMeta.value?.abnormalCounts?.[pointName] ?? null,
|
|
|
}
|
|
|
}
|
|
|
@@ -119,11 +232,8 @@ const devicePointCounts = computed<Record<string, { pre: number | null; ab: numb
|
|
|
})
|
|
|
|
|
|
function devicePointLabel(point: DevicePoint): string {
|
|
|
- const { pre, ab } = devicePointCounts.value[point] ?? { pre: null, ab: null }
|
|
|
- const inner: string[] = []
|
|
|
- if (pre != null) inner.push(String(pre))
|
|
|
- if (ab != null) inner.push(String(ab))
|
|
|
- return inner.length ? `${point} (${inner.join(' · ')})` : point
|
|
|
+ const ab = devicePointCounts.value[point]?.ab ?? null
|
|
|
+ return ab != null ? `${point} (${ab})` : point
|
|
|
}
|
|
|
const selectedOptionRows = computed<QueryOption[]>(() => (
|
|
|
queryMeta.value?.options.filter((row) => (
|
|
|
@@ -151,7 +261,6 @@ const currentCycles = computed(() => waveData.value?.cycles ?? [])
|
|
|
const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
|
|
|
const timePointsSource = ref<'database' | 'demo'>('demo')
|
|
|
const availableTypeCount = computed(() => selectedOptionRows.value.reduce((total, row) => total + row.fileCount, 0))
|
|
|
-const annotatedFileIds = computed(() => [...new Set(annotations.value.map((item) => item.waveFileId))])
|
|
|
const windowFiles = computed(() => (waveData.value?.files ?? []).filter((file) => file.devicePoint === fileListPoint.value))
|
|
|
const primaryDevicePoint = computed(() => (
|
|
|
waveData.value?.primaryPoint
|
|
|
@@ -359,7 +468,7 @@ async function loadTimePoints() {
|
|
|
Math.min(firstRunningIndex(), Math.max(0, timePoints.value.length - stripWindowSize.value)),
|
|
|
)
|
|
|
chartDirty.value = true
|
|
|
- void loadAnnotations()
|
|
|
+ void loadLabels()
|
|
|
void loadFaults()
|
|
|
} catch (error) {
|
|
|
timePoints.value = []
|
|
|
@@ -576,144 +685,248 @@ function closePeriod() {
|
|
|
periodVisible.value = false
|
|
|
}
|
|
|
|
|
|
-async function loadAnnotationConfig() {
|
|
|
- try {
|
|
|
- const result = await fetchAnnotationConfig()
|
|
|
- annotationWidth.value = result.annotationWidth
|
|
|
- } catch {
|
|
|
- annotationWidth.value = 10
|
|
|
- }
|
|
|
+function addMinutes(value: string, minutes: number): string | undefined {
|
|
|
+ const date = new Date(value.replace(' ', 'T'))
|
|
|
+ if (Number.isNaN(date.getTime())) return undefined
|
|
|
+ date.setMinutes(date.getMinutes() + minutes)
|
|
|
+ const pad = (part: number) => String(part).padStart(2, '0')
|
|
|
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
|
}
|
|
|
|
|
|
-async function loadAnnotations() {
|
|
|
- const ids = [...new Set(
|
|
|
- timePoints.value.flatMap((point) => (
|
|
|
- selectedDevicePoints.value
|
|
|
- .map((devicePoint) => point.files[devicePoint]?.id)
|
|
|
- .filter((id): id is number => id != null)
|
|
|
- )),
|
|
|
- )]
|
|
|
- if (!ids.length) {
|
|
|
- annotations.value = []
|
|
|
+function metricKeyOfSitePoint(itemName: string): string {
|
|
|
+ return itemName.replace(/^YSJ\d+_/, 'YSJ_')
|
|
|
+}
|
|
|
+
|
|
|
+async function loadLabels() {
|
|
|
+ if (!timePoints.value.length) {
|
|
|
+ labels.value = []
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const start = timePoints.value[0]?.sampleTime
|
|
|
+ const last = timePoints.value[timePoints.value.length - 1]?.sampleTime
|
|
|
+ const end = last ? addMinutes(last, 15) : undefined
|
|
|
+ const metricKeys = isPksMode.value ? selectedSitePoints.value.map(metricKeyOfSitePoint) : []
|
|
|
+ const waveChannelKeys = selectedDevicePoints.value.map((devicePoint) => (
|
|
|
+ `wave:${unitNumber.value || selectedDevicePart.value}:${selectedDevicePart.value}:${devicePoint}`
|
|
|
+ ))
|
|
|
+ if (!metricKeys.length && !waveChannelKeys.length) {
|
|
|
+ labels.value = []
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
- const result = await fetchAnnotationsByIds(ids)
|
|
|
- annotations.value = result.annotations
|
|
|
+ const requests = []
|
|
|
+ if (metricKeys.length) {
|
|
|
+ requests.push(fetchLabels({
|
|
|
+ unit: unitNumber.value ?? undefined,
|
|
|
+ deviceCode: unitNumber.value ? `${unitNumber.value}#` : undefined,
|
|
|
+ metricKeys,
|
|
|
+ start,
|
|
|
+ end,
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ if (waveChannelKeys.length) {
|
|
|
+ requests.push(fetchLabels({ channelKeys: waveChannelKeys, start, end }))
|
|
|
+ }
|
|
|
+ const results = await Promise.all(requests)
|
|
|
+ labels.value = results.flatMap((result) => result.labels)
|
|
|
} catch {
|
|
|
- annotations.value = []
|
|
|
+ labels.value = []
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-type AnnotationBlock = {
|
|
|
- waveFileId: number
|
|
|
- periodStart: number
|
|
|
- periodEnd: number
|
|
|
- sampleIndexStart: number
|
|
|
- sampleIndexEnd: number
|
|
|
+function toHourText(value: string): string {
|
|
|
+ const date = new Date(value.replace(' ', 'T'))
|
|
|
+ if (Number.isNaN(date.getTime())) return value
|
|
|
+ date.setMinutes(0, 0, 0)
|
|
|
+ const pad = (part: number) => String(part).padStart(2, '0')
|
|
|
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00:00`
|
|
|
}
|
|
|
|
|
|
-function annotationBlock(cycle: Cycle): AnnotationBlock | null {
|
|
|
- const width = Math.max(1, annotationWidth.value)
|
|
|
- const fileCycles = (waveData.value?.cycles ?? []).filter((item) => item.waveFileId === cycle.waveFileId)
|
|
|
- const maxPeriod = Math.max(1, ...fileCycles.map((item) => item.periodNo))
|
|
|
- let leftBound = 1
|
|
|
- let rightBound = maxPeriod
|
|
|
- const existing = annotations.value
|
|
|
- .filter((item) => item.waveFileId === cycle.waveFileId)
|
|
|
- .sort((a, b) => a.periodStart - b.periodStart)
|
|
|
- for (const item of existing) {
|
|
|
- if (item.periodEnd < cycle.periodNo) {
|
|
|
- leftBound = Math.max(leftBound, item.periodEnd + 1)
|
|
|
- } else if (item.periodStart > cycle.periodNo) {
|
|
|
- rightBound = Math.min(rightBound, item.periodStart - 1)
|
|
|
- } else {
|
|
|
- return null
|
|
|
+function labelTargetFor(rowKey: string) {
|
|
|
+ const isSite = isPksMode.value && selectedSitePoints.value.includes(rowKey)
|
|
|
+ if (isSite) {
|
|
|
+ const unit = unitNumber.value ?? ''
|
|
|
+ const deviceCode = unit ? `${unit}#` : ''
|
|
|
+ const metricKey = metricKeyOfSitePoint(rowKey)
|
|
|
+ const description = siteDescription(rowKey)
|
|
|
+ return {
|
|
|
+ targetType: 'pks' as const,
|
|
|
+ channelKey: `pks:${unit}:${deviceCode}:${metricKey}`,
|
|
|
+ unit,
|
|
|
+ deviceCode,
|
|
|
+ devicePart: `${unit}号机`,
|
|
|
+ devicePoint: description,
|
|
|
+ metricKey,
|
|
|
+ metricLabel: description,
|
|
|
}
|
|
|
}
|
|
|
- if (leftBound > rightBound) return null
|
|
|
- let start = Math.max(leftBound, cycle.periodNo - Math.floor((width - 1) / 2))
|
|
|
- let end = Math.min(rightBound, start + width - 1)
|
|
|
- start = Math.max(leftBound, end - width + 1)
|
|
|
- const startCycle = fileCycles.find((item) => item.periodNo === start)
|
|
|
- const endCycle = fileCycles.find((item) => item.periodNo === end)
|
|
|
+ const unit = unitNumber.value ?? ''
|
|
|
+ const devicePart = selectedDevicePart.value
|
|
|
+ return {
|
|
|
+ targetType: 'wave_file' as const,
|
|
|
+ channelKey: `wave:${unit || devicePart}:${devicePart}:${rowKey}`,
|
|
|
+ unit,
|
|
|
+ deviceCode: unit ? `${unit}#` : null,
|
|
|
+ devicePart,
|
|
|
+ devicePoint: rowKey,
|
|
|
+ metricKey: null,
|
|
|
+ metricLabel: rowKey,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function labelVisibleAt(rowKey: string, at: string): WaveLabel | undefined {
|
|
|
+ const target = new Date(at.replace(' ', 'T')).getTime()
|
|
|
+ const candidates = labels.value.filter((label) => (
|
|
|
+ label.rowKey === rowKey
|
|
|
+ && new Date(label.startTime.replace(' ', 'T')).getTime() <= target
|
|
|
+ && target < new Date(label.endTime.replace(' ', 'T')).getTime()
|
|
|
+ ))
|
|
|
+ candidates.sort((a, b) => {
|
|
|
+ const manualDiff = Number(b.origin === 'manual') - Number(a.origin === 'manual')
|
|
|
+ if (manualDiff) return manualDiff
|
|
|
+ return (LABEL_SEVERITY[b.labelType] ?? 0) - (LABEL_SEVERITY[a.labelType] ?? 0)
|
|
|
+ })
|
|
|
+ return candidates[0]
|
|
|
+}
|
|
|
+
|
|
|
+function labelInputFromForm() {
|
|
|
return {
|
|
|
- waveFileId: cycle.waveFileId,
|
|
|
- periodStart: start,
|
|
|
- periodEnd: end,
|
|
|
- sampleIndexStart: startCycle?.startSampleIndex ?? cycle.startSampleIndex,
|
|
|
- sampleIndexEnd: endCycle?.endSampleIndex ?? cycle.endSampleIndex,
|
|
|
+ target_type: labelForm.targetType,
|
|
|
+ target_id: null,
|
|
|
+ channel_key: labelForm.channelKey,
|
|
|
+ unit: labelForm.unit,
|
|
|
+ device_code: labelForm.deviceCode,
|
|
|
+ device_part: labelForm.devicePart,
|
|
|
+ device_point: labelForm.devicePoint,
|
|
|
+ metric_key: labelForm.metricKey,
|
|
|
+ metric_label: labelForm.metricLabel,
|
|
|
+ start_time: toHourText(labelForm.startTime),
|
|
|
+ end_time: toHourText(labelForm.endTime),
|
|
|
+ label_type: labelForm.labelType,
|
|
|
+ label_subtype: null,
|
|
|
+ note: labelForm.note || null,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function chooseLabel(block: AnnotationBlock): Promise<AnnotationLabel | null> {
|
|
|
+async function onLabelSingle(payload: { rowKey: string; startTime: string; endTime: string }) {
|
|
|
+ const target = labelTargetFor(payload.rowKey)
|
|
|
try {
|
|
|
- await ElMessageBox.confirm(
|
|
|
- `将在周期 ${block.periodStart} — ${block.periodEnd} 创建标注,请选择样本类型`,
|
|
|
- '创建标注',
|
|
|
- {
|
|
|
- confirmButtonText: '正常样本',
|
|
|
- cancelButtonText: '异常样本',
|
|
|
- distinguishCancelAndClose: true,
|
|
|
- type: 'info',
|
|
|
- closeOnClickModal: false,
|
|
|
- },
|
|
|
- )
|
|
|
- return '正常'
|
|
|
- } catch (action) {
|
|
|
- if (action === 'cancel') return '异常'
|
|
|
- return null
|
|
|
+ await createLabel({
|
|
|
+ target_type: target.targetType,
|
|
|
+ target_id: null,
|
|
|
+ channel_key: target.channelKey,
|
|
|
+ unit: target.unit,
|
|
|
+ device_code: target.deviceCode,
|
|
|
+ device_part: target.devicePart,
|
|
|
+ device_point: target.devicePoint,
|
|
|
+ metric_key: target.metricKey,
|
|
|
+ metric_label: target.metricLabel,
|
|
|
+ start_time: toHourText(payload.startTime),
|
|
|
+ end_time: toHourText(payload.endTime),
|
|
|
+ label_type: '异常',
|
|
|
+ label_subtype: null,
|
|
|
+ note: null,
|
|
|
+ })
|
|
|
+ await loadLabels()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '标注失败')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function onAnnotationToggle(cycle: Cycle) {
|
|
|
- if (annotationLoading.value) return
|
|
|
- const existing = annotations.value.find((item) => (
|
|
|
- item.waveFileId === cycle.waveFileId && cycle.periodNo >= item.periodStart && cycle.periodNo <= item.periodEnd
|
|
|
- ))
|
|
|
- if (existing) {
|
|
|
- try {
|
|
|
- await ElMessageBox.confirm(
|
|
|
- `取消该标注(${existing.label},周期 ${existing.periodStart} — ${existing.periodEnd})?`,
|
|
|
- '取消标注',
|
|
|
- { confirmButtonText: '取消标注', cancelButtonText: '保留', type: 'warning', closeOnClickModal: false },
|
|
|
- )
|
|
|
- } catch {
|
|
|
- return
|
|
|
- }
|
|
|
- annotationLoading.value = true
|
|
|
- try {
|
|
|
- await deleteAnnotation(existing.id)
|
|
|
- await loadAnnotations()
|
|
|
- } catch (error) {
|
|
|
- errorMessage.value = error instanceof Error ? error.message : '标注取消失败'
|
|
|
- } finally {
|
|
|
- annotationLoading.value = false
|
|
|
- }
|
|
|
+function onLabelDouble(payload: { rowKey: string; startTime: string; endTime: string; label?: WaveLabel }) {
|
|
|
+ const target = labelTargetFor(payload.rowKey)
|
|
|
+ labelForm.id = payload.label?.id ?? null
|
|
|
+ labelForm.rowKey = payload.rowKey
|
|
|
+ labelForm.labelType = payload.label?.labelType ?? '异常'
|
|
|
+ labelForm.startTime = payload.label?.startTime ?? toHourText(payload.startTime)
|
|
|
+ labelForm.endTime = payload.label?.endTime ?? toHourText(payload.endTime)
|
|
|
+ labelForm.note = payload.label?.note ?? ''
|
|
|
+ labelForm.features = payload.label?.features ?? null
|
|
|
+ labelForm.origin = payload.label?.origin ?? 'manual'
|
|
|
+ labelForm.targetType = target.targetType
|
|
|
+ labelForm.channelKey = target.channelKey
|
|
|
+ labelForm.unit = target.unit
|
|
|
+ labelForm.deviceCode = target.deviceCode
|
|
|
+ labelForm.devicePart = target.devicePart
|
|
|
+ labelForm.devicePoint = target.devicePoint
|
|
|
+ labelForm.metricKey = target.metricKey
|
|
|
+ labelForm.metricLabel = target.metricLabel
|
|
|
+ labelDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+async function saveLabel() {
|
|
|
+ if (labelSaving.value) return
|
|
|
+ if (!labelForm.startTime || !labelForm.endTime) {
|
|
|
+ ElMessage.warning('请设置开始和结束时间')
|
|
|
return
|
|
|
}
|
|
|
- const block = annotationBlock(cycle)
|
|
|
- if (!block) return
|
|
|
- const label = await chooseLabel(block)
|
|
|
- if (!label) return
|
|
|
- annotationLoading.value = true
|
|
|
+ if (toHourText(labelForm.startTime) >= toHourText(labelForm.endTime)) {
|
|
|
+ ElMessage.warning('结束时间必须大于开始时间')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ labelSaving.value = true
|
|
|
try {
|
|
|
- await createAnnotation({
|
|
|
- wave_file_id: block.waveFileId,
|
|
|
- label,
|
|
|
- period_start: block.periodStart,
|
|
|
- period_end: block.periodEnd,
|
|
|
- sample_index_start: block.sampleIndexStart,
|
|
|
- sample_index_end: block.sampleIndexEnd,
|
|
|
+ const payload = labelInputFromForm()
|
|
|
+ if (labelForm.id != null) await updateLabel(labelForm.id, payload)
|
|
|
+ else await createLabel(payload)
|
|
|
+ labelDialogVisible.value = false
|
|
|
+ await loadLabels()
|
|
|
+ ElMessage.success('标注已保存')
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
|
|
+ } finally {
|
|
|
+ labelSaving.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function removeLabel() {
|
|
|
+ if (labelForm.id == null || labelSaving.value) return
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('删除这条标注?', '删除标注', {
|
|
|
+ confirmButtonText: '删除',
|
|
|
+ cancelButtonText: '保留',
|
|
|
+ type: 'warning',
|
|
|
})
|
|
|
- await loadAnnotations()
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ labelSaving.value = true
|
|
|
+ try {
|
|
|
+ await deleteLabel(labelForm.id)
|
|
|
+ labelDialogVisible.value = false
|
|
|
+ await loadLabels()
|
|
|
+ ElMessage.success('已删除')
|
|
|
} catch (error) {
|
|
|
- errorMessage.value = error instanceof Error ? error.message : '标注创建失败'
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
|
|
} finally {
|
|
|
- annotationLoading.value = false
|
|
|
+ labelSaving.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const timelineLabelLevels = computed<(string | null)[]>(() => {
|
|
|
+ const points = timePoints.value
|
|
|
+ if (!points.length) return []
|
|
|
+ if (!labels.value.length) return points.map(() => null)
|
|
|
+ const ranges = labels.value.map((label) => ({
|
|
|
+ start: new Date(label.startTime.replace(' ', 'T')).getTime(),
|
|
|
+ end: new Date(label.endTime.replace(' ', 'T')).getTime(),
|
|
|
+ level: label.labelType,
|
|
|
+ severity: LABEL_SEVERITY[label.labelType] ?? 0,
|
|
|
+ }))
|
|
|
+ return points.map((point) => {
|
|
|
+ const target = new Date(point.sampleTime.replace(' ', 'T')).getTime()
|
|
|
+ let best: string | null = null
|
|
|
+ let bestSeverity = 0
|
|
|
+ for (const range of ranges) {
|
|
|
+ if (range.start <= target && target < range.end && range.severity > bestSeverity) {
|
|
|
+ best = range.level
|
|
|
+ bestSeverity = range.severity
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return best
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
watch(selectedDevicePart, async (current, previous) => {
|
|
|
if (!initialized.value) return
|
|
|
if (previous && previous !== current) rememberPart(previous)
|
|
|
@@ -761,61 +974,10 @@ watch(selectedSitePoints, (points) => {
|
|
|
if (isPksMode.value) scheduleTimePoints()
|
|
|
if (points.length) fileListPoint.value = SITE_TAB_LABEL
|
|
|
})
|
|
|
-watch(waveData, () => void refreshPretrainStatus(), { deep: false })
|
|
|
-
|
|
|
-async function refreshPretrainStatus() {
|
|
|
- const ids = waveData.value?.files.map((file) => file.id) ?? []
|
|
|
- if (!ids.length) {
|
|
|
- recordedFileIds.value = []
|
|
|
- return
|
|
|
- }
|
|
|
- try {
|
|
|
- const result = await fetchPretrainStatus(ids)
|
|
|
- recordedFileIds.value = result.recorded
|
|
|
- } catch {
|
|
|
- recordedFileIds.value = []
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
function locateFile(file: WaveWindowFile) {
|
|
|
waveChartRef.value?.locateFile(file.pointIndex)
|
|
|
}
|
|
|
|
|
|
-async function recordFile(file: WaveWindowFile) {
|
|
|
- if (recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
|
|
|
- recordingFileId.value = file.id
|
|
|
- try {
|
|
|
- const result = await recordPretrain({
|
|
|
- id: file.id,
|
|
|
- point_name: file.pointName,
|
|
|
- measurement_type: file.measurementType,
|
|
|
- rpm: file.rpm,
|
|
|
- sample_time: file.sampleTime,
|
|
|
- file_name: file.fileName,
|
|
|
- })
|
|
|
- if (result.recorded || result.already) {
|
|
|
- recordedFileIds.value = [...new Set([...recordedFileIds.value, file.id])]
|
|
|
- }
|
|
|
- } catch (error) {
|
|
|
- errorMessage.value = error instanceof Error ? error.message : '记录失败'
|
|
|
- } finally {
|
|
|
- recordingFileId.value = null
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-async function deleteRecordedFile(file: WaveWindowFile) {
|
|
|
- if (!recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
|
|
|
- recordingFileId.value = file.id
|
|
|
- try {
|
|
|
- await deletePretrain(file.id)
|
|
|
- recordedFileIds.value = recordedFileIds.value.filter((id) => id !== file.id)
|
|
|
- } catch (error) {
|
|
|
- errorMessage.value = error instanceof Error ? error.message : '删除失败'
|
|
|
- } finally {
|
|
|
- recordingFileId.value = null
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
async function handleLogin() {
|
|
|
if (!loginForm.username || !loginForm.password) {
|
|
|
loginError.value = '请输入账号和密码'
|
|
|
@@ -829,7 +991,6 @@ async function handleLogin() {
|
|
|
loginForm.password = ''
|
|
|
await loadOptions()
|
|
|
void loadTspluseRuler()
|
|
|
- void loadAnnotationConfig()
|
|
|
} catch (error) {
|
|
|
loginError.value = error instanceof Error ? error.message : '登录失败'
|
|
|
} finally {
|
|
|
@@ -842,14 +1003,14 @@ async function handleLogout() {
|
|
|
loggedIn.value = false
|
|
|
waveData.value = null
|
|
|
timePoints.value = []
|
|
|
- annotations.value = []
|
|
|
+ labels.value = []
|
|
|
}
|
|
|
|
|
|
function onAuthExpired() {
|
|
|
loggedIn.value = false
|
|
|
waveData.value = null
|
|
|
timePoints.value = []
|
|
|
- annotations.value = []
|
|
|
+ labels.value = []
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
@@ -858,7 +1019,6 @@ onMounted(() => {
|
|
|
if (loggedIn.value) {
|
|
|
void loadOptions()
|
|
|
void loadTspluseRuler()
|
|
|
- void loadAnnotationConfig()
|
|
|
}
|
|
|
})
|
|
|
onBeforeUnmount(() => {
|
|
|
@@ -951,9 +1111,9 @@ onBeforeUnmount(() => {
|
|
|
:value="point"
|
|
|
>
|
|
|
<span>{{ point }}</span>
|
|
|
- <template v-if="devicePointCounts[point]?.pre !== null || devicePointCounts[point]?.ab !== null">
|
|
|
+ <template v-if="devicePointCounts[point]?.ab !== null">
|
|
|
<span class="point-counts">
|
|
|
- (<span v-if="devicePointCounts[point]?.pre !== null">{{ devicePointCounts[point]?.pre }}</span><template v-if="devicePointCounts[point]?.pre !== null && devicePointCounts[point]?.ab !== null"> · </template><span v-if="devicePointCounts[point]?.ab !== null" class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
|
|
|
+ (<span class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
|
|
|
</span>
|
|
|
</template>
|
|
|
</el-option>
|
|
|
@@ -975,9 +1135,9 @@ onBeforeUnmount(() => {
|
|
|
:value="point"
|
|
|
>
|
|
|
<span>{{ point }}</span>
|
|
|
- <template v-if="devicePointCounts[point]?.pre !== null || devicePointCounts[point]?.ab !== null">
|
|
|
+ <template v-if="devicePointCounts[point]?.ab !== null">
|
|
|
<span class="point-counts">
|
|
|
- (<span v-if="devicePointCounts[point]?.pre !== null">{{ devicePointCounts[point]?.pre }}</span><template v-if="devicePointCounts[point]?.pre !== null && devicePointCounts[point]?.ab !== null"> · </template><span v-if="devicePointCounts[point]?.ab !== null" class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
|
|
|
+ (<span class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
|
|
|
</span>
|
|
|
</template>
|
|
|
</el-option>
|
|
|
@@ -1087,7 +1247,7 @@ onBeforeUnmount(() => {
|
|
|
:loading="timePointsLoading"
|
|
|
:min-time="minTime"
|
|
|
:max-time="maxTime"
|
|
|
- :annotated-file-ids="annotatedFileIds"
|
|
|
+ :label-levels="timelineLabelLevels"
|
|
|
:faults="faults"
|
|
|
:ruler="ruler"
|
|
|
:site-points="isPksMode ? selectedSitePoints : []"
|
|
|
@@ -1184,24 +1344,6 @@ onBeforeUnmount(() => {
|
|
|
<td class="file-name" :title="file.fileName">{{ file.fileName || '—' }}</td>
|
|
|
<td class="actions">
|
|
|
<el-button class="file-action" size="small" plain :disabled="!waveData" @click="locateFile(file)">定位</el-button>
|
|
|
- <el-button
|
|
|
- v-if="!recordedFileIds.includes(file.id)"
|
|
|
- class="file-action"
|
|
|
- size="small"
|
|
|
- type="primary"
|
|
|
- plain
|
|
|
- :loading="recordingFileId === file.id"
|
|
|
- @click="recordFile(file)"
|
|
|
- >记录</el-button>
|
|
|
- <el-button
|
|
|
- v-else
|
|
|
- class="file-action"
|
|
|
- size="small"
|
|
|
- type="danger"
|
|
|
- plain
|
|
|
- :loading="recordingFileId === file.id"
|
|
|
- @click="deleteRecordedFile(file)"
|
|
|
- >删除</el-button>
|
|
|
</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
@@ -1216,7 +1358,6 @@ onBeforeUnmount(() => {
|
|
|
<h2>波形预览</h2>
|
|
|
</div>
|
|
|
<div class="chart-actions">
|
|
|
- <span class="annotation-width">标度宽度:<strong>{{ annotationWidth }}</strong></span>
|
|
|
<el-radio-group v-model="chartMode" class="mode-switch" size="small">
|
|
|
<el-radio-button label="split">分图显示</el-radio-button>
|
|
|
<el-radio-button label="normalized">归一化分图</el-radio-button>
|
|
|
@@ -1238,10 +1379,11 @@ onBeforeUnmount(() => {
|
|
|
:points="selectedWindowPoints"
|
|
|
:mode="chartMode"
|
|
|
:loading="waveLoading"
|
|
|
- :annotations="annotations"
|
|
|
+ :labels="labels"
|
|
|
:site-descriptions="siteDescriptionMap"
|
|
|
@period-dblclick="openPeriod"
|
|
|
- @annotation-toggle="onAnnotationToggle"
|
|
|
+ @label-single="onLabelSingle"
|
|
|
+ @label-double="onLabelDouble"
|
|
|
/>
|
|
|
</section>
|
|
|
|
|
|
@@ -1279,5 +1421,73 @@ onBeforeUnmount(() => {
|
|
|
</el-tabs>
|
|
|
|
|
|
<PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="labelDialogVisible"
|
|
|
+ title="标注"
|
|
|
+ width="1000px"
|
|
|
+ top="5vh"
|
|
|
+ append-to-body
|
|
|
+ class="label-dialog"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <div class="label-form">
|
|
|
+ <div class="label-form-grid">
|
|
|
+ <div class="label-field">
|
|
|
+ <span class="label-field-name">点位</span>
|
|
|
+ <span class="label-field-value" :title="`${labelForm.devicePart} · ${labelForm.devicePoint}`">{{ labelForm.devicePart }} · {{ labelForm.devicePoint }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="label-field">
|
|
|
+ <span class="label-field-name">故障级别</span>
|
|
|
+ <el-radio-group v-model="labelForm.labelType" size="small">
|
|
|
+ <el-radio-button label="轻微" />
|
|
|
+ <el-radio-button label="异常" />
|
|
|
+ <el-radio-button label="严重异常" />
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ <div class="label-field">
|
|
|
+ <span class="label-field-name">开始</span>
|
|
|
+ <el-date-picker
|
|
|
+ v-model="labelForm.startTime"
|
|
|
+ type="datetime"
|
|
|
+ format="YYYY-MM-DD HH:00"
|
|
|
+ value-format="YYYY-MM-DD HH:00:00"
|
|
|
+ placeholder="按整点"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="label-field">
|
|
|
+ <span class="label-field-name">结束</span>
|
|
|
+ <el-date-picker
|
|
|
+ v-model="labelForm.endTime"
|
|
|
+ type="datetime"
|
|
|
+ format="YYYY-MM-DD HH:00"
|
|
|
+ value-format="YYYY-MM-DD HH:00:00"
|
|
|
+ placeholder="按整点"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="label-field label-field-wide">
|
|
|
+ <span class="label-field-name">备注</span>
|
|
|
+ <el-input v-model="labelForm.note" placeholder="可选" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="label-info-section">
|
|
|
+ <div class="label-info-title">算法属性</div>
|
|
|
+ <div v-if="labelFeatureItems.length" class="label-info-grid">
|
|
|
+ <template v-for="item in labelFeatureItems" :key="item.key">
|
|
|
+ <div class="label-info-label" :class="{ full: item.full }">{{ item.label }}</div>
|
|
|
+ <div class="label-info-value" :class="{ full: item.full }">{{ item.value }}</div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <pre v-else-if="labelForm.features" class="label-info-raw">{{ labelForm.features }}</pre>
|
|
|
+ <div v-else class="label-info-empty">无算法属性</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button v-if="labelForm.id != null" type="danger" plain :loading="labelSaving" @click="removeLabel">删除</el-button>
|
|
|
+ <el-button @click="labelDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="labelSaving" @click="saveLabel">保存</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|