You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

483 lines
13 KiB
Vue

<template>
<el-dialog
v-model="dialogVisible"
width="720px"
:show-close="true"
:close-on-click-modal="false"
:before-close="handleDialogClose"
destroy-on-close
class="realtime-alarm-dialog"
>
<template #header>
<div class="dialog-header">
<div>
<div class="dialog-kicker">REALTIME ALARM</div>
<div class="dialog-title">{{ currentAlarm?.alarmTitle || '实时告警待处置' }}</div>
</div>
<div class="dialog-badges">
<el-tag type="danger" effect="dark">实时告警</el-tag>
<el-tag type="warning" effect="plain">待处理 {{ pendingCount }}</el-tag>
</div>
</div>
</template>
<template v-if="currentAlarm">
<el-descriptions :column="2" border size="small" class="alarm-descriptions">
<el-descriptions-item label="点位">{{ currentAlarm.monitorName || currentAlarm.monitorId || '-' }}</el-descriptions-item>
<el-descriptions-item label="记录时间">{{ formatDateTime(currentAlarm.collectTime) }}</el-descriptions-item>
<el-descriptions-item label="异常原因">{{ currentAlarm.cause || '-' }}</el-descriptions-item>
<el-descriptions-item label="告警级别">{{ currentAlarm.alarmLevel || '-' }}</el-descriptions-item>
<el-descriptions-item label="实际值">{{ formatValue(currentAlarm.actualValue ?? currentAlarm.alarmData) }}</el-descriptions-item>
<el-descriptions-item label="阈值">{{ formatValue(currentAlarm.thresholdValue) }}</el-descriptions-item>
<el-descriptions-item :span="2" label="告警内容">{{ currentAlarm.alarmContent || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="sop-header">
<div>
<div class="sop-title">命中 SOP</div>
<div class="sop-tip">按 `monitorId + cause` 实时命中处置步骤,避免值班人员还要二次检索。</div>
</div>
<el-button text type="primary" :loading="sopLoading" @click="loadRealtimeActionSteps(currentAlarm)">刷新 SOP</el-button>
</div>
<el-skeleton :loading="sopLoading" animated>
<template #template>
<div class="skeleton-row" v-for="index in 2" :key="index">
<el-skeleton-item variant="p" style="width: 100%; height: 72px" />
</div>
</template>
<template #default>
<el-empty v-if="!actionSteps.length" description="当前告警未命中处置步骤,请补充规则措施配置" />
<div v-else class="sop-list">
<article v-for="step in actionSteps" :key="step.objId || step.stepSequence" class="sop-card">
<div class="sop-sequence">步骤 {{ step.stepSequence || '-' }}</div>
<div class="sop-desc">{{ step.description || '-' }}</div>
<div v-if="step.remark" class="sop-remark">{{ step.remark }}</div>
<div v-if="step.stepImages?.length" class="sop-image-list">
<button
v-for="image in step.stepImages"
:key="image.objId || image.imageSequence"
type="button"
class="sop-image-card"
@click="previewImage(image.imageUrl)"
>
<img :src="resolveImageUrl(image.imageUrl)" :alt="image.description || 'SOP 参考图'" />
<span>{{ image.description || `图片 ${image.imageSequence || ''}` }}</span>
</button>
</div>
</article>
</div>
</template>
</el-skeleton>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="deferCurrentAlarm">稍后处理</el-button>
<el-button @click="returnToHome">返回首页</el-button>
<el-button type="primary" :loading="handling" @click="confirmCurrentAlarm">我已处理</el-button>
</div>
</template>
</el-dialog>
<transition name="fade-slide">
<div v-if="deferredQueue.length" class="deferred-float">
<div class="deferred-title">稍后处理队列</div>
<div class="deferred-count">{{ deferredQueue.length }} 条待继续处理</div>
<el-button size="small" type="primary" @click="resumeDeferredAlarm">继续处理</el-button>
</div>
</transition>
<el-dialog v-model="imagePreviewVisible" title="SOP 参考图" width="760px" append-to-body>
<div class="image-preview-container">
<img :src="previewImageUrl" alt="SOP 参考图预览" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus';
import { parseTime } from '@/utils/ruoyi';
import { getEmsAlarmActionStepsByAlarmInfo } from '@/api/ems/base/emsAlarmActionStep';
import { handleExceptions } from '@/api/ems/record/recordAlarmData';
import { useAlarmRealtimeBus } from '@/utils/alarmRealtime';
import type { AlarmHandleResultVO, EmsAlarmActionStepVO, EmsRecordAlarmDataVO } from '@/api/ems/types';
const router = useRouter();
const dialogVisible = ref(false);
const handling = ref(false);
const sopLoading = ref(false);
const currentAlarm = ref<EmsRecordAlarmDataVO | null>(null);
const currentQueueSource = ref<'live' | 'deferred' | null>(null);
const liveQueue = ref<EmsRecordAlarmDataVO[]>([]);
const deferredQueue = ref<EmsRecordAlarmDataVO[]>([]);
const actionSteps = ref<EmsAlarmActionStepVO[]>([]);
const imagePreviewVisible = ref(false);
const previewImageUrl = ref('');
const handledKeys = reactive(new Set<string>());
const pendingCount = computed(() => liveQueue.value.length + deferredQueue.value.length + (currentAlarm.value ? 1 : 0));
const buildAlarmKey = (alarm?: EmsRecordAlarmDataVO | null) => {
if (!alarm) {
return '';
}
return String(alarm.objId ?? `${alarm.monitorId || ''}_${alarm.cause || ''}_${alarm.collectTime || ''}`);
};
const formatDateTime = (value?: string | Date | number | null) => (value ? parseTime(value, '{y}-{m}-{d} {h}:{i}:{s}') : '-');
const formatValue = (value?: string | number | null) => (value === null || value === undefined || value === '' ? '-' : value);
const resolveImageUrl = (imageUrl?: string | null) => {
if (!imageUrl) {
return '';
}
if (/^https?:\/\//.test(imageUrl)) {
return imageUrl;
}
return `${import.meta.env.VITE_APP_BASE_API || ''}${imageUrl}`;
};
const previewImage = (imageUrl?: string | null) => {
if (!imageUrl) {
return;
}
previewImageUrl.value = resolveImageUrl(imageUrl);
imagePreviewVisible.value = true;
};
const loadRealtimeActionSteps = async (alarm: EmsRecordAlarmDataVO) => {
if (!alarm.monitorId || !alarm.cause) {
actionSteps.value = [];
return;
}
sopLoading.value = true;
try {
const response = await getEmsAlarmActionStepsByAlarmInfo(alarm.monitorId, alarm.cause);
actionSteps.value = ((response as any).data ?? []) as EmsAlarmActionStepVO[];
} catch (error) {
actionSteps.value = [];
ElMessage.warning('SOP 加载失败,请稍后重试');
} finally {
sopLoading.value = false;
}
};
const openAlarmFromQueue = async (queueSource: 'live' | 'deferred') => {
if (currentAlarm.value) {
return;
}
const targetQueue = queueSource === 'live' ? liveQueue.value : deferredQueue.value;
const nextAlarm = targetQueue.shift();
if (!nextAlarm) {
return;
}
currentAlarm.value = nextAlarm;
currentQueueSource.value = queueSource;
dialogVisible.value = true;
await loadRealtimeActionSteps(nextAlarm);
};
const openNextLiveAlarm = async () => {
if (!currentAlarm.value && liveQueue.value.length) {
await openAlarmFromQueue('live');
}
};
const enqueueRealtimeAlarms = async (alarms: EmsRecordAlarmDataVO[]) => {
const newAlarms = alarms.filter((alarm) => {
const alarmKey = buildAlarmKey(alarm);
if (!alarmKey || handledKeys.has(alarmKey) || String(alarm.alarmStatus) === '0') {
return false;
}
const isDuplicate =
buildAlarmKey(currentAlarm.value) === alarmKey ||
liveQueue.value.some((item) => buildAlarmKey(item) === alarmKey) ||
deferredQueue.value.some((item) => buildAlarmKey(item) === alarmKey);
return !isDuplicate;
});
if (!newAlarms.length) {
return;
}
liveQueue.value.push(...newAlarms);
await openNextLiveAlarm();
};
const consumeHandleResult = (result?: AlarmHandleResultVO | null) => {
const updatedCount = Number(result?.updatedCount ?? 0);
const alreadyHandledCount = Number(result?.alreadyHandledCount ?? 0);
if (updatedCount > 0 || alreadyHandledCount > 0) {
return true;
}
return false;
};
const clearCurrentAlarm = async () => {
if (currentAlarm.value) {
handledKeys.add(buildAlarmKey(currentAlarm.value));
}
currentAlarm.value = null;
currentQueueSource.value = null;
actionSteps.value = [];
dialogVisible.value = false;
await openNextLiveAlarm();
};
const confirmCurrentAlarm = async () => {
if (!currentAlarm.value?.objId) {
return;
}
handling.value = true;
try {
const response = await handleExceptions(currentAlarm.value.objId);
const success = consumeHandleResult((response as any).data ?? null);
if (!success) {
ElMessage.warning((response as any).msg || '告警状态未更新,请刷新后重试');
return;
}
ElMessage.success('告警状态已回写为已处理');
await clearCurrentAlarm();
} finally {
handling.value = false;
}
};
const deferCurrentAlarm = async () => {
if (!currentAlarm.value) {
dialogVisible.value = false;
return;
}
// 稍后处理只保留在前端待办队列,不主动改写后端状态,避免把“未完成处置”误标为成功闭环。
deferredQueue.value.push(currentAlarm.value);
currentAlarm.value = null;
currentQueueSource.value = null;
actionSteps.value = [];
dialogVisible.value = false;
await openNextLiveAlarm();
};
const returnToHome = async () => {
await router.push('/index');
await deferCurrentAlarm();
};
const resumeDeferredAlarm = async () => {
if (currentAlarm.value) {
return;
}
await openAlarmFromQueue('deferred');
};
const handleDialogClose = async (done: () => void) => {
await deferCurrentAlarm();
done();
};
let stopBus: (() => void) | undefined;
onMounted(() => {
stopBus = useAlarmRealtimeBus().on((event) => {
void enqueueRealtimeAlarms(event.alarms || []);
});
});
onUnmounted(() => {
stopBus?.();
});
</script>
<style scoped>
.dialog-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.dialog-kicker {
color: var(--el-text-color-secondary);
font-size: 12px;
letter-spacing: 0.16em;
}
.dialog-title {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 700;
margin-top: 6px;
}
.dialog-badges {
display: flex;
gap: 8px;
}
.alarm-descriptions {
margin-bottom: 20px;
}
.sop-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sop-title {
font-size: 16px;
font-weight: 600;
}
.sop-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
margin-top: 4px;
}
.skeleton-row + .skeleton-row {
margin-top: 12px;
}
.sop-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sop-card {
background: linear-gradient(135deg, rgba(250, 244, 235, 0.9), rgba(255, 249, 245, 0.95));
border: 1px solid rgba(194, 154, 132, 0.22);
border-radius: 16px;
padding: 16px;
}
.sop-sequence {
color: #9a5f44;
font-size: 12px;
letter-spacing: 0.12em;
}
.sop-desc {
color: #43261c;
font-size: 15px;
font-weight: 600;
line-height: 1.7;
margin-top: 8px;
}
.sop-remark {
color: #7b665c;
font-size: 13px;
line-height: 1.7;
margin-top: 8px;
}
.sop-image-list {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
margin-top: 12px;
}
.sop-image-card {
align-items: stretch;
background: #fff;
border: 1px solid rgba(194, 154, 132, 0.24);
border-radius: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
text-align: left;
}
.sop-image-card img {
border-radius: 10px;
height: 110px;
object-fit: cover;
width: 100%;
}
.sop-image-card span {
color: #5d463f;
font-size: 12px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.deferred-float {
background: rgba(68, 30, 22, 0.96);
border-radius: 18px;
bottom: 24px;
box-shadow: 0 18px 40px rgba(33, 16, 12, 0.22);
color: #fff6ef;
padding: 16px 18px;
position: fixed;
right: 24px;
width: 240px;
z-index: 2100;
}
.deferred-title {
font-size: 14px;
font-weight: 600;
}
.deferred-count {
color: rgba(255, 244, 235, 0.8);
font-size: 12px;
line-height: 1.7;
margin: 8px 0 12px;
}
.image-preview-container {
align-items: center;
display: flex;
justify-content: center;
min-height: 320px;
}
.image-preview-container img {
max-height: 70vh;
max-width: 100%;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.2s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 768px) {
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
.dialog-badges {
flex-wrap: wrap;
}
.deferred-float {
bottom: 16px;
left: 16px;
right: 16px;
width: auto;
}
}
</style>