feat: 新增告警SOP预览与阈值中心优化

main
zangch@mesnac.com 3 months ago
parent a8971b0597
commit b66ef7ec6b

@ -1,6 +1,6 @@
# 页面标题
VITE_APP_TITLE = RuoYi-Vue-Plus多租户管理系统
VITE_APP_LOGO_TITLE = RuoYi-Vue-Plus
VITE_APP_TITLE = HaiWei-Plus能源管理系统
VITE_APP_LOGO_TITLE = HaiWei-Plus
# 开发环境配置
VITE_APP_ENV = 'development'

@ -56,8 +56,9 @@
<el-table-column v-if="columns[2].visible" label="步骤顺序" align="center" prop="stepSequence" width="120" />
<el-table-column v-if="columns[3].visible" label="步骤描述" align="center" prop="description" min-width="260" show-overflow-tooltip />
<el-table-column v-if="columns[4].visible" label="备注" align="center" prop="remark" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template #default="scope">
<el-button size="small" type="text" @click="handlePreview(scope.row)"></el-button>
<el-button size="small" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['ems/base:emsAlarmActionStep:edit']"></el-button>
<el-button size="small" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['ems/base:emsAlarmActionStep:remove']"></el-button>
</template>
@ -90,11 +91,47 @@
</div>
</template>
</el-dialog>
<el-dialog v-model="previewOpen" :title="previewTitle" width="820px" append-to-body>
<el-empty v-if="!previewSteps.length" description="当前规则暂无图文 SOP" />
<div v-else class="preview-steps">
<article v-for="step in previewSteps" :key="step.objId || step.stepSequence" class="preview-step-card">
<div class="preview-step-sequence">步骤 {{ step.stepSequence || '-' }}</div>
<div class="preview-step-desc">{{ step.description || '-' }}</div>
<div v-if="step.remark" class="preview-step-remark">{{ step.remark }}</div>
<div v-if="step.stepImages?.length" class="preview-step-images">
<button
v-for="image in step.stepImages"
:key="image.objId || image.imageSequence"
type="button"
class="preview-step-image"
@click="previewImage(image.imageUrl)"
>
<img :src="resolveImageUrl(image.imageUrl)" :alt="image.description || 'SOP 图片'" />
<span>{{ image.description || `图片 ${image.imageSequence || ''}` }}</span>
</button>
</div>
</article>
</div>
</el-dialog>
<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>
</div>
</template>
<script setup lang="ts">
import { listEmsAlarmActionStep, getEmsAlarmActionStep, delEmsAlarmActionStep, addEmsAlarmActionStep, updateEmsAlarmActionStep } from '@/api/ems/base/emsAlarmActionStep';
import {
listEmsAlarmActionStep,
getEmsAlarmActionStep,
getEmsAlarmActionStepsByRuleId,
delEmsAlarmActionStep,
addEmsAlarmActionStep,
updateEmsAlarmActionStep
} from '@/api/ems/base/emsAlarmActionStep';
import { getEmsRecordAlarmRuleList } from '@/api/ems/record/recordAlarmRule';
import type { EmsAlarmActionStepVO, EmsRecordAlarmRuleVO } from '@/api/ems/types';
@ -138,6 +175,11 @@ const state = reactive({
emsAlarmActionStepList: [] as EmsAlarmActionStepVO[],
title: '',
open: false,
previewOpen: false,
previewTitle: '',
previewSteps: [] as EmsAlarmActionStepVO[],
previewImageUrl: '',
imagePreviewVisible: false,
queryParams: {
pageNum: 1,
pageSize: 10,
@ -153,11 +195,29 @@ const state = reactive({
}
} as any);
const { emsAlarmActionStepList, form, ids, loading, multiple, open, queryParams, recordAlarmRuleList, rules, showSearch, single, title, total } = toRefs(state);
const { emsAlarmActionStepList, form, ids, imagePreviewVisible, loading, multiple, open, previewImageUrl, previewOpen, previewSteps, previewTitle, queryParams, recordAlarmRuleList, rules, showSearch, single, title, total } = toRefs(state);
const resolveRuleName = (ruleObjId?: string | number) =>
recordAlarmRuleList.value.find((item) => item.objId === ruleObjId)?.ruleName || (ruleObjId ? String(ruleObjId) : '-');
const resolveImageUrl = (imageUrl?: string | null) => {
if (!imageUrl) {
return '';
}
if (/^https?:\/\//.test(imageUrl)) {
return imageUrl;
}
return `${window.location.origin}${imageUrl}`;
};
const previewImage = (imageUrl?: string | null) => {
if (!imageUrl) {
return;
}
previewImageUrl.value = resolveImageUrl(imageUrl);
imagePreviewVisible.value = true;
};
const getList = () => {
loading.value = true;
listEmsAlarmActionStep(queryParams.value).then((response) => {
@ -221,6 +281,18 @@ const handleUpdate = (row) => {
});
};
const handlePreview = async (row?: EmsAlarmActionStepVO) => {
const ruleObjId = row?.ruleObjId;
if (!ruleObjId) {
proxy?.$modal.msgWarning('当前步骤缺少关联规则,无法加载图文 SOP');
return;
}
const response = await getEmsAlarmActionStepsByRuleId(ruleObjId as any);
previewSteps.value = ((response as any).data ?? []) as EmsAlarmActionStepVO[];
previewTitle.value = `图文 SOP - ${resolveRuleName(ruleObjId)}`;
previewOpen.value = true;
};
const submitForm = () => {
formRef.value?.validate((valid) => {
if (!valid) {
@ -271,3 +343,82 @@ onMounted(() => {
loadRuleOptions();
});
</script>
<style scoped>
.preview-steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.preview-step-card {
background: linear-gradient(180deg, rgba(248, 250, 255, 0.95), rgba(255, 255, 255, 0.98));
border: 1px solid rgba(205, 215, 238, 0.78);
border-radius: 16px;
padding: 16px;
}
.preview-step-sequence {
color: #5263c4;
font-size: 12px;
letter-spacing: 0.12em;
}
.preview-step-desc {
font-size: 15px;
font-weight: 600;
line-height: 1.7;
margin-top: 8px;
}
.preview-step-remark {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.7;
margin-top: 8px;
}
.preview-step-images {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
margin-top: 12px;
}
.preview-step-image {
background: #fff;
border: 1px solid rgba(205, 215, 238, 0.84);
border-radius: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
text-align: left;
}
.preview-step-image img {
border-radius: 8px;
height: 96px;
object-fit: cover;
width: 100%;
}
.preview-step-image span {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.image-preview-container {
align-items: center;
display: flex;
justify-content: center;
min-height: 320px;
}
.image-preview-container img {
max-height: 70vh;
max-width: 100%;
}
</style>

@ -1,8 +1,30 @@
<template>
<div class="p-2">
<div class="p-2 page-shell">
<section class="page-hero">
<div class="hero-copy">
<span class="hero-eyebrow">THRESHOLD CONTROL</span>
<h2 class="hero-title">统一点位阈值配置中心</h2>
<p class="hero-desc">将点位阈值回差持续触发与启停状态集中管理帮助现场运维更直观地完成阈值巡检和统一调整</p>
</div>
<div class="hero-stats">
<article v-for="item in overviewStats" :key="item.label" class="stat-card">
<span class="stat-label">{{ item.label }}</span>
<strong class="stat-value">{{ item.value }}</strong>
<span class="stat-hint">{{ item.hint }}</span>
</article>
</div>
</section>
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover" class="query-card">
<el-card shadow="hover" class="query-card panel-card">
<div class="panel-head">
<div>
<h3>阈值筛选</h3>
<p>支持按点位与启用状态快速检索适合做阈值巡检和统一治理</p>
</div>
<span class="panel-badge">点位检索</span>
</div>
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="点位名称" prop="monitorCode">
<el-tree-select
@ -16,17 +38,12 @@
style="width: 240px"
/>
</el-form-item>
<el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="queryParams.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 240px">
<el-option v-for="item in notifyGroupOptions" :key="item.id" :label="item.groupName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="启用状态" prop="isEnable">
<el-select v-model="queryParams.isEnable" placeholder="请选择启用状态" clearable style="width: 160px">
<el-option v-for="item in sysNormalDisableOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-form-item class="query-actions">
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
<el-button icon="Refresh" @click="resetQuery"></el-button>
</el-form-item>
@ -35,9 +52,16 @@
</div>
</transition>
<el-card shadow="never" class="table-card">
<el-card shadow="never" class="table-card panel-card">
<template #header>
<el-row :gutter="10" class="mb8">
<div class="panel-head panel-head-compact">
<div>
<h3>阈值列表</h3>
<p>保留原有增删改导出与分页逻辑只增强视觉识别与阅读节奏</p>
</div>
<span class="panel-badge warm">统一阈值</span>
</div>
<el-row :gutter="10" class="mb8 toolbar-row">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['ems/base:monitorMetricThreshold:add']"></el-button>
</el-col>
@ -74,7 +98,7 @@
</el-row>
</template>
<el-table v-loading="loading" border :data="monitorMetricThresholdList" @selection-change="handleSelectionChange">
<el-table v-loading="loading" border :data="monitorMetricThresholdList" @selection-change="handleSelectionChange" class="data-table">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" width="60" align="center" />
<el-table-column label="点位名称" align="center" min-width="180" show-overflow-tooltip>
@ -89,11 +113,6 @@
<el-table-column label="回差值" align="center" prop="hysteresis" min-width="100" />
<el-table-column label="持续触发秒数" align="center" prop="durationSec" min-width="120" />
<el-table-column label="告警级别" align="center" prop="alarmLevel" min-width="110" />
<el-table-column label="通知组" align="center" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ resolveNotifyGroupName(scope.row) }}
</template>
</el-table-column>
<el-table-column label="启用状态" align="center" width="110">
<template #default="scope">
<dict-tag :options="sysNormalDisableOptions" :value="scope.row.isEnable" />
@ -115,8 +134,12 @@
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
</el-card>
<el-dialog v-model="dialog.visible" :title="dialog.title" width="720px" append-to-body>
<el-form ref="monitorMetricThresholdFormRef" :model="form" :rules="rules" label-width="110px">
<el-dialog v-model="dialog.visible" :title="dialog.title" width="720px" append-to-body class="themed-dialog">
<div class="dialog-tip">
<span class="tip-dot"></span>
阈值表单继续自动带出测量项编码只提升录入体验与信息层次避免人工误填技术字段
</div>
<el-form ref="monitorMetricThresholdFormRef" :model="form" :rules="rules" label-width="110px" class="dialog-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="点位名称" prop="monitorCode">
@ -180,13 +203,6 @@
<el-input v-model="form.alarmLevel" placeholder="请输入告警级别" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="form.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 100%">
<el-option v-for="item in notifyGroupOptions" :key="item.id" :label="item.groupName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用状态" prop="isEnable">
@ -220,9 +236,7 @@ import {
listMonitorMetricThreshold,
updateMonitorMetricThreshold
} from '@/api/ems/base/monitorMetricThreshold';
import { listAlarmNotifyGroupAll } from '@/api/ems/base/alarmNotifyGroup';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import type { AlarmNotifyGroupVO } from '@/api/ems/base/alarmNotifyGroup/types';
import type { MonitorMetricThresholdForm, MonitorMetricThresholdQuery, MonitorMetricThresholdVO } from '@/api/ems/base/monitorMetricThreshold/types';
import type { EmsTreeNode } from '@/api/ems/types';
@ -244,10 +258,8 @@ interface ThresholdTreeNode extends EmsTreeNode {
}
const monitorTreeOptions = ref<ThresholdTreeNode[]>([]);
const notifyGroupOptions = ref<AlarmNotifyGroupVO[]>([]);
const monitorNameMap = ref<Record<string, string>>({});
const monitorMetaMap = ref<Record<string, { label: string; metricCode?: string }>>({});
const notifyGroupNameMap = ref<Record<string, string>>({});
const monitorTreeProps = {
label: 'label',
@ -322,7 +334,8 @@ const initFormData: MonitorMetricThresholdForm = {
hysteresis: undefined,
durationSec: undefined,
alarmLevel: undefined,
notifyGroupId: undefined,
//
// notifyGroupId: undefined,
isEnable: '0',
remark: undefined
};
@ -341,7 +354,8 @@ const data = reactive<PageData<MonitorMetricThresholdForm, MonitorMetricThreshol
hysteresis: undefined,
durationSec: undefined,
alarmLevel: undefined,
notifyGroupId: undefined,
//
// notifyGroupId: undefined,
isEnable: undefined,
params: {}
},
@ -354,8 +368,39 @@ const data = reactive<PageData<MonitorMetricThresholdForm, MonitorMetricThreshol
const { queryParams, form, rules } = toRefs(data);
//
const overviewStats = computed(() => {
const thresholds = monitorMetricThresholdList.value;
const relatedPoints = new Set(thresholds.filter((item) => item.monitorCode).map((item) => item.monitorCode)).size;
const upperConfigured = thresholds.filter((item) => item.alarmUpper != null || item.warnUpper != null).length;
const hysteresisConfigured = thresholds.filter((item) => item.hysteresis != null).length;
return [
{
label: '阈值总数',
value: total.value || thresholds.length,
hint: '当前列表分页总量'
},
{
label: '关联点位',
value: relatedPoints,
hint: '已挂接监测点位'
},
{
label: '已设上限',
value: upperConfigured,
hint: '含预警或告警上限'
},
{
label: '已设回差',
value: hysteresisConfigured,
hint: '带回差保护配置'
}
];
});
const loadOptions = async () => {
const [treeRes, groupRes] = await Promise.all([getMonitorInfoTree({}), listAlarmNotifyGroupAll()]);
const treeRes = await getMonitorInfoTree({});
// label/value
const treeData = normalizeMonitorTree(((treeRes as any).data ?? treeRes ?? []) as ThresholdTreeNode[]);
@ -363,11 +408,6 @@ const loadOptions = async () => {
monitorNameMap.value = {};
monitorMetaMap.value = {};
collectMonitorNames(flattenMonitorOptions(treeData));
// groupName使 id
const groups = ((groupRes as any).rows ?? (groupRes as any).data ?? groupRes ?? []) as AlarmNotifyGroupVO[];
notifyGroupOptions.value = groups;
notifyGroupNameMap.value = Object.fromEntries(groups.map((item) => [String(item.id), item.groupName || String(item.id)]));
};
const getList = async () => {
@ -386,11 +426,6 @@ const resolveMonitorName = (row: MonitorMetricThresholdVO) => {
return row.monitorName || monitorNameMap.value[String(row.monitorCode ?? '')] || row.monitorCode || '-';
};
const resolveNotifyGroupName = (row: MonitorMetricThresholdVO) => {
// ID
return row.notifyGroupName || notifyGroupNameMap.value[String(row.notifyGroupId ?? '')] || (row.notifyGroupId ? String(row.notifyGroupId) : '-');
};
const reset = () => {
form.value = { ...initFormData };
monitorMetricThresholdFormRef.value?.resetFields();
@ -487,10 +522,207 @@ onMounted(async () => {
});
</script>
<style scoped>
<style scoped lang="scss">
.page-shell {
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(20, 184, 166, 0.14), transparent 28%),
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 24%), linear-gradient(180deg, #f5faf9 0%, #edf3f4 48%, #f8fafc 100%);
}
.page-hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 1fr);
gap: 18px;
margin-bottom: 16px;
padding: 28px 30px;
border-radius: 28px;
background: linear-gradient(135deg, rgba(8, 71, 87, 0.96) 0%, rgba(13, 148, 136, 0.9) 50%, rgba(245, 158, 11, 0.82) 100%);
box-shadow: 0 26px 54px rgba(20, 95, 88, 0.18);
color: #fff;
position: relative;
overflow: hidden;
}
.page-hero::after {
content: '';
position: absolute;
inset: auto -80px -110px auto;
width: 240px;
height: 240px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.hero-copy,
.hero-stats {
position: relative;
z-index: 1;
}
.hero-eyebrow {
display: inline-flex;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.16em;
}
.hero-title {
margin: 18px 0 12px;
font-size: 30px;
line-height: 1.2;
}
.hero-desc {
margin: 0;
max-width: 700px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.75;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.stat-card {
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.14);
backdrop-filter: blur(10px);
}
.stat-label,
.stat-hint {
display: block;
}
.stat-label {
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
}
.stat-value {
display: block;
margin: 10px 0 8px;
font-size: 30px;
line-height: 1;
font-weight: 700;
}
.stat-hint {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
}
.panel-card {
border: 1px solid rgba(201, 214, 215, 0.72);
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 40px rgba(36, 71, 73, 0.08);
backdrop-filter: blur(12px);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.panel-head-compact {
margin-bottom: 14px;
}
.panel-head h3 {
margin: 0 0 6px;
font-size: 20px;
color: #18454a;
}
.panel-head p {
margin: 0;
color: #64767a;
line-height: 1.6;
}
.panel-badge {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
background: linear-gradient(135deg, #ccfbf1, #dbeafe);
color: #0f6b73;
font-size: 12px;
font-weight: 600;
}
.panel-badge.warm {
background: linear-gradient(135deg, #fef3c7, #fed7aa);
color: #a16207;
}
.query-actions {
:deep(.el-form-item__content) {
gap: 10px;
}
}
.toolbar-row {
padding: 14px 16px;
margin: 0;
border-radius: 18px;
background: linear-gradient(90deg, rgba(13, 148, 136, 0.08), rgba(249, 115, 22, 0.08));
}
.data-table {
:deep(.el-table__header-wrapper th) {
background: #f3fbfa;
color: #17464d;
font-weight: 700;
}
:deep(.el-table__body tr:hover > td) {
background: rgba(13, 148, 136, 0.06) !important;
}
}
.dialog-tip {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 16px;
background: linear-gradient(90deg, rgba(13, 148, 136, 0.08), rgba(249, 115, 22, 0.08));
color: #2d5960;
line-height: 1.65;
}
.tip-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 50%;
background: linear-gradient(135deg, #14b8a6, #f59e0b);
box-shadow: 0 0 0 5px rgba(20, 184, 166, 0.12);
}
.dialog-form {
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, #f8fcfc 0%, #ffffff 100%);
}
.main-cell {
line-height: 20px;
font-weight: 600;
color: #17464d;
}
.sub-cell {
@ -498,4 +730,61 @@ onMounted(async () => {
font-size: 12px;
line-height: 18px;
}
:deep(.panel-card > .el-card__header) {
border-bottom: none;
padding-bottom: 0;
}
:deep(.panel-card > .el-card__body) {
padding-top: 0;
}
:deep(.themed-dialog .el-dialog) {
border-radius: 26px;
overflow: hidden;
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.2);
}
:deep(.themed-dialog .el-dialog__header) {
padding: 22px 24px 14px;
background: linear-gradient(135deg, rgba(8, 71, 87, 0.96), rgba(13, 148, 136, 0.88));
}
:deep(.themed-dialog .el-dialog__title),
:deep(.themed-dialog .el-dialog__headerbtn .el-dialog__close) {
color: #fff;
}
:deep(.themed-dialog .el-dialog__body) {
padding: 22px 24px;
background: linear-gradient(180deg, #f8fcfc 0%, #ffffff 100%);
}
:deep(.themed-dialog .el-dialog__footer) {
padding: 0 24px 24px;
background: linear-gradient(180deg, #ffffff 0%, #f8fcfc 100%);
}
@media (max-width: 992px) {
.page-hero {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-hero {
padding: 18px;
border-radius: 20px;
}
.hero-stats {
grid-template-columns: 1fr;
}
.panel-head {
flex-direction: column;
align-items: flex-start;
}
}
</style>

@ -5,18 +5,17 @@
<div class="hero-main">
<div class="hero-kicker">ALARM CENTER</div>
<div class="hero-title">报警中心工作台</div>
<div class="hero-desc">把告警主记录推送状态确认处理动作收敛到一个入口里减少在记录页日志页通知页之间来回切换</div>
<div class="hero-desc">把告警主记录与确认处理动作收敛到一个入口里值班人员只需要围绕记录列表详情和处置闭环工作</div>
<div class="hero-tags">
<el-tag effect="dark" round class="hero-tag">实时告警闭环</el-tag>
<el-tag effect="plain" round class="hero-tag">{{ currentAlarmStatusLabel }}</el-tag>
<el-tag effect="plain" round class="hero-tag">{{ currentPushStatusLabel }}</el-tag>
</div>
</div>
<div class="hero-side">
<div class="hero-panel">
<div class="hero-panel-label">当前详情对象</div>
<div class="hero-panel-value">{{ selectedAlarm ? resolveMonitorName(selectedAlarm) : '未选中告警' }}</div>
<div class="hero-panel-foot">支持在左侧记录列表点选后联动查看推送日志与确认备注</div>
<div class="hero-panel-foot">支持在左侧记录列表点选后联动查看告警详情与确认备注</div>
</div>
</div>
</div>
@ -45,11 +44,6 @@
<el-form-item label="告警标题" prop="alarmTitle">
<el-input v-model="queryParams.alarmTitle" placeholder="请输入告警标题" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="推送状态" prop="pushStatus">
<el-select v-model="queryParams.pushStatus" placeholder="请选择推送状态" clearable style="width: 160px">
<el-option v-for="item in pushStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
<el-button icon="Refresh" @click="resetQuery"></el-button>
@ -71,14 +65,9 @@
<div class="summary-foot">用于快速判断当前筛选范围内仍待闭环的风险规模</div>
</el-card>
<el-card shadow="never" class="summary-card">
<div class="summary-label">推送失败总数</div>
<div class="summary-value">{{ overviewSummary.pushFailCount ?? 0 }}</div>
<div class="summary-foot">筛出当前条件下所有通知未送达的告警便于统一追补</div>
</el-card>
<el-card shadow="never" class="summary-card">
<div class="summary-label">当前详情推送条数</div>
<div class="summary-value">{{ pushLogList.length }}</div>
<div class="summary-foot">点选左侧告警后右侧联动展示</div>
<div class="summary-label">已处理总数</div>
<div class="summary-value">{{ overviewSummary.handledCount ?? 0 }}</div>
<div class="summary-foot">用于判断当前筛选范围内已经完成闭环的处理量</div>
</el-card>
</div>
@ -142,13 +131,6 @@
</template>
</el-table-column>
<el-table-column label="告警级别" align="center" prop="alarmLevel" width="100" />
<el-table-column label="推送状态" align="center" prop="pushStatus" width="110">
<template #default="scope">
<el-tag :type="getPushStatusMeta(scope.row.pushStatus).type" effect="light">
{{ getPushStatusMeta(scope.row.pushStatus).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="告警标题" align="center" prop="alarmTitle" min-width="180" show-overflow-tooltip />
<el-table-column label="确认备注" align="center" prop="confirmRemark" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
@ -212,12 +194,6 @@
<el-descriptions-item label="阈值/实际值">
{{ formatValue(selectedAlarm.thresholdValue) }} / {{ formatValue(selectedAlarm.actualValue) }}
</el-descriptions-item>
<el-descriptions-item label="推送状态">
<el-tag :type="getPushStatusMeta(selectedAlarm.pushStatus).type" effect="light">
{{ getPushStatusMeta(selectedAlarm.pushStatus).label }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="推送次数">{{ selectedAlarm.pushCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="操作人员">{{ selectedAlarm.operationName || '-' }}</el-descriptions-item>
<el-descriptions-item label="确认备注">{{ selectedAlarm.confirmRemark || '-' }}</el-descriptions-item>
<el-descriptions-item label="恢复时间">{{ formatDateTime(selectedAlarm.recoverTime) }}</el-descriptions-item>
@ -231,33 +207,44 @@
<el-button type="warning" plain @click="handleUpdate(selectedAlarm)"></el-button>
</div>
<div class="section-title">推送日志</div>
<div v-loading="pushLogLoading" class="push-log-wrap">
<el-scrollbar height="320px">
<el-empty v-if="!pushLogList.length && !pushLogLoading" description="暂无推送日志" />
<el-timeline v-else>
<el-timeline-item
v-for="item in pushLogList"
:key="item.id"
:timestamp="formatDateTime(item.pushTime)"
:type="getPushStatusMeta(item.pushStatus).type"
placement="top"
>
<el-card shadow="never" class="push-log-card">
<div class="push-log-title">
<span>{{ item.channelType || '未知渠道' }}</span>
<el-tag :type="getPushStatusMeta(item.pushStatus).type" effect="light">
{{ getPushStatusMeta(item.pushStatus).label }}
</el-tag>
<div class="sop-panel">
<div class="sop-panel-header">
<div>
<div class="sop-panel-title">命中 SOP</div>
<div class="sop-panel-tip">根据当前告警的 `monitorId + cause` 自动命中标准处置步骤和值班弹窗保持同一口径</div>
</div>
<el-button text type="primary" :disabled="!selectedAlarm" :loading="sopLoading" @click="refreshAlarmActionSteps"> SOP</el-button>
</div>
<el-skeleton :loading="sopLoading" animated>
<template #template>
<div v-for="index in 2" :key="index" class="sop-skeleton-row">
<el-skeleton-item variant="p" style="width: 100%; height: 68px" />
</div>
</template>
<template #default>
<el-empty v-if="!actionSteps.length" description="当前告警未命中 SOP请在规则页补充措施步骤" />
<div v-else class="sop-list">
<article v-for="step in actionSteps" :key="step.objId || step.stepSequence" class="sop-card">
<div class="sop-card-sequence">步骤 {{ step.stepSequence || '-' }}</div>
<div class="sop-card-desc">{{ step.description || '-' }}</div>
<div v-if="step.remark" class="sop-card-remark">{{ step.remark }}</div>
<div v-if="step.stepImages?.length" class="sop-card-images">
<button
v-for="image in step.stepImages"
:key="image.objId || image.imageSequence"
type="button"
class="sop-card-image"
@click="previewImage(image.imageUrl)"
>
<img :src="resolveImageUrl(image.imageUrl)" :alt="image.description || 'SOP 参考图'" />
<span>{{ image.description || `图片 ${image.imageSequence || ''}` }}</span>
</button>
</div>
<div class="push-log-line">推送对象{{ item.targetValue || '-' }}</div>
<div class="push-log-line">告警级别{{ item.alarmLevel || '-' }}</div>
<div class="push-log-line">推送内容{{ item.pushContent || '-' }}</div>
<div class="push-log-line">响应信息{{ item.responseMsg || '-' }}</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-scrollbar>
</article>
</div>
</template>
</el-skeleton>
</div>
</template>
</el-card>
@ -327,20 +314,9 @@
<el-form-item label="异常数据" prop="alarmData">
<el-input v-model="form.alarmData" placeholder="请输入异常数据" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="恢复时间" prop="recoverTime">
<el-date-picker clearable v-model="form.recoverTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择恢复时间" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="推送状态" prop="pushStatus">
<el-select v-model="form.pushStatus" placeholder="请选择推送状态" clearable style="width: 100%">
<el-option v-for="item in pushStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="恢复时间" prop="recoverTime">
<el-date-picker clearable v-model="form.recoverTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择恢复时间" />
</el-form-item>
<el-form-item label="确认备注" prop="confirmRemark">
<el-input v-model="form.confirmRemark" type="textarea" placeholder="请输入确认备注" :rows="3" />
</el-form-item>
@ -352,6 +328,12 @@
</div>
</template>
</el-dialog>
<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>
</div>
</template>
@ -359,6 +341,7 @@
import { getCurrentInstance } from 'vue';
import { useDict } from '@/utils/dict';
import { parseTime } from '@/utils/ruoyi';
import { getEmsAlarmActionStepsByAlarmInfo } from '@/api/ems/base/emsAlarmActionStep';
import {
addRecordAlarmData,
delRecordAlarmData,
@ -368,9 +351,8 @@ import {
listRecordAlarmData,
updateRecordAlarmData
} from '@/api/ems/record/recordAlarmData';
import { listAlarmPushLog } from '@/api/ems/base/alarmPushLog';
import type { AlarmPushLogVO } from '@/api/ems/base/alarmPushLog/types';
import type { AlarmOverviewSummaryVO, EmsRecordAlarmDataVO } from '@/api/ems/types';
import { useAlarmRealtimeBus } from '@/utils/alarmRealtime';
import type { AlarmOverviewSummaryVO, EmsAlarmActionStepVO, EmsRecordAlarmDataVO } from '@/api/ems/types';
defineOptions({
name: 'RecordAlarmData',
@ -386,22 +368,8 @@ const tableRef = ref();
const queryFormRef = ref<ElFormInstance>();
const formRef = ref<ElFormInstance>();
const detailRequestToken = ref(0);
const pushStatusOptions = [
{ label: '待推送', value: 'PENDING', type: 'info' },
{ label: '推送中', value: 'PROCESSING', type: 'warning' },
{ label: '推送成功', value: 'SUCCESS', type: 'success' },
{ label: '推送失败', value: 'FAIL', type: 'danger' }
];
const pushStatusMetaMap: Record<string, { label: string; type: 'info' | 'success' | 'warning' | 'danger' }> = {
PENDING: { label: '待推送', type: 'info' },
WAIT: { label: '待推送', type: 'info' },
PROCESSING: { label: '推送中', type: 'warning' },
SUCCESS: { label: '推送成功', type: 'success' },
FAIL: { label: '推送失败', type: 'danger' },
FAILED: { label: '推送失败', type: 'danger' }
};
const previewImageUrl = ref('');
const imagePreviewVisible = ref(false);
const createQueryParams = () => ({
pageNum: 1,
@ -425,7 +393,8 @@ const createQueryParams = () => ({
beginOperationTime: null,
endOperationTime: null,
recoverTime: null,
pushStatus: null,
//
// pushStatus: null,
confirmRemark: null,
cause: null,
notifyUser: null,
@ -452,8 +421,9 @@ const createFormData = () => ({
operationName: null,
operationTime: null,
recoverTime: null,
pushStatus: null,
pushCount: null,
//
// pushStatus: null,
// pushCount: null,
confirmUserId: null,
confirmRemark: null,
cause: null,
@ -478,7 +448,7 @@ const state = reactive({
loading: true,
overviewLoading: false,
detailLoading: false,
pushLogLoading: false,
sopLoading: false,
ids: [] as Array<number | string>,
single: true,
multiple: true,
@ -486,8 +456,8 @@ const state = reactive({
total: 0,
recordAlarmDataList: [] as EmsRecordAlarmDataVO[],
overviewSummary: createOverviewSummary() as AlarmOverviewSummaryVO,
pushLogList: [] as AlarmPushLogVO[],
selectedAlarm: null as EmsRecordAlarmDataVO | null,
actionSteps: [] as EmsAlarmActionStepVO[],
title: '',
open: false,
queryParams: createQueryParams() as any,
@ -501,7 +471,7 @@ const {
loading,
overviewLoading,
detailLoading,
pushLogLoading,
sopLoading,
ids,
single,
multiple,
@ -509,8 +479,8 @@ const {
total,
recordAlarmDataList,
overviewSummary,
pushLogList,
selectedAlarm,
actionSteps,
title,
open,
queryParams,
@ -526,13 +496,6 @@ const currentAlarmStatusLabel = computed(() => {
return matched?.label || '状态未知';
});
const currentPushStatusLabel = computed(() => {
if (!selectedAlarm.value) {
return '未选中推送状态';
}
return getPushStatusMeta(selectedAlarm.value.pushStatus).label;
});
const formatDateTime = (value?: string | Date | number | null) => {
if (!value) {
return '-';
@ -547,12 +510,44 @@ const formatValue = (value?: string | number | null) => {
return value;
};
const getPushStatusMeta = (status?: string | null) => pushStatusMetaMap[String(status ?? '')] ?? { label: status || '未知', type: 'info' };
const resolveMonitorName = (row?: EmsRecordAlarmDataVO | null) =>
row?.monitorName || row?.deviceName || row?.collectDeviceName || row?.monitorId || '-';
const loadDetailAndPushLogs = async (row: EmsRecordAlarmDataVO) => {
const resolveImageUrl = (imageUrl?: string | null) => {
if (!imageUrl) {
return '';
}
if (/^https?:\/\//.test(imageUrl)) {
return imageUrl;
}
return `${window.location.origin}${imageUrl}`;
};
const previewImage = (imageUrl?: string | null) => {
if (!imageUrl) {
return;
}
previewImageUrl.value = resolveImageUrl(imageUrl);
imagePreviewVisible.value = true;
};
const loadAlarmActionSteps = async (row?: EmsRecordAlarmDataVO | null) => {
if (!row?.monitorId || !row?.cause) {
actionSteps.value = [];
return;
}
sopLoading.value = true;
try {
const response = await getEmsAlarmActionStepsByAlarmInfo(row.monitorId, row.cause);
actionSteps.value = ((response as any).data ?? []) as EmsAlarmActionStepVO[];
} catch {
actionSteps.value = [];
} finally {
sopLoading.value = false;
}
};
const loadAlarmDetail = async (row: EmsRecordAlarmDataVO) => {
if (!row?.objId) {
return;
}
@ -560,39 +555,23 @@ const loadDetailAndPushLogs = async (row: EmsRecordAlarmDataVO) => {
const requestToken = ++detailRequestToken.value;
//
selectedAlarm.value = { ...row } as EmsRecordAlarmDataVO;
pushLogList.value = [];
detailLoading.value = true;
pushLogLoading.value = true;
try {
const [detailRes, pushRes] = await Promise.allSettled([
//
getRecordAlarmData(row.objId),
//
listAlarmPushLog({
alarmObjId: row.objId,
pageNum: 1,
pageSize: 20
} as any)
]);
//
const detailRes = await getRecordAlarmData(row.objId);
if (requestToken !== detailRequestToken.value) {
return;
}
if (detailRes.status === 'fulfilled') {
selectedAlarm.value = {
...row,
...((detailRes.value as any).data ?? {})
} as EmsRecordAlarmDataVO;
}
if (pushRes.status === 'fulfilled') {
pushLogList.value = (((pushRes.value as any).rows ?? (pushRes.value as any).data ?? []) as AlarmPushLogVO[]) || [];
}
selectedAlarm.value = {
...row,
...((detailRes as any).data ?? {})
} as EmsRecordAlarmDataVO;
await loadAlarmActionSteps(selectedAlarm.value);
} finally {
if (requestToken === detailRequestToken.value) {
detailLoading.value = false;
pushLogLoading.value = false;
}
}
};
@ -601,7 +580,6 @@ const syncCurrentRow = () => {
if (!recordAlarmDataList.value.length) {
detailRequestToken.value++;
selectedAlarm.value = null;
pushLogList.value = [];
return;
}
@ -610,7 +588,7 @@ const syncCurrentRow = () => {
if (tableRef.value?.setCurrentRow) {
tableRef.value.setCurrentRow(targetRow);
}
void loadDetailAndPushLogs(targetRow as EmsRecordAlarmDataVO);
void loadAlarmDetail(targetRow as EmsRecordAlarmDataVO);
};
const getList = async () => {
@ -649,6 +627,7 @@ const getList = async () => {
const reset = () => {
//
form.value = createFormData();
actionSteps.value = [];
formRef.value?.resetFields();
};
@ -675,7 +654,7 @@ const handleSelectionChange = (selection: EmsRecordAlarmDataVO[]) => {
const handleRowClick = async (row: EmsRecordAlarmDataVO) => {
tableRef.value?.setCurrentRow?.(row);
await loadDetailAndPushLogs(row);
await loadAlarmDetail(row);
};
const refreshCurrentAlarm = async () => {
@ -684,17 +663,28 @@ const refreshCurrentAlarm = async () => {
}
const matched = recordAlarmDataList.value.find((item) => item.objId === selectedAlarm.value?.objId);
if (matched) {
await loadDetailAndPushLogs(matched);
await loadAlarmDetail(matched);
}
};
const refreshAlarmActionSteps = async () => {
await loadAlarmActionSteps(selectedAlarm.value);
};
const handleConfirmCurrentAlarm = async () => {
if (!selectedAlarm.value?.objId) {
return;
}
await proxy?.$modal.confirm(`是否确认将告警“${selectedAlarm.value.alarmTitle || resolveMonitorName(selectedAlarm.value)}”标记为已处理?`);
await handleExceptions(selectedAlarm.value.objId as any);
proxy?.$modal.msgSuccess('确认处理成功');
const response = await handleExceptions(selectedAlarm.value.objId as any);
const result = ((response as any).data ?? {}) as any;
const updatedCount = Number(result.updatedCount ?? 0);
const alreadyHandledCount = Number(result.alreadyHandledCount ?? 0);
if (updatedCount <= 0 && alreadyHandledCount <= 0) {
proxy?.$modal.msgWarning((response as any).msg || '告警状态未更新,请刷新后重试');
return;
}
proxy?.$modal.msgSuccess(updatedCount > 0 ? '确认处理成功' : '告警已是已处理状态');
await getList();
};
@ -748,8 +738,17 @@ const handleExport = () => {
);
};
let stopRealtimeBus: (() => void) | undefined;
onMounted(() => {
getList();
stopRealtimeBus = useAlarmRealtimeBus().on(() => {
void getList();
});
});
onUnmounted(() => {
stopRealtimeBus?.();
});
</script>
@ -885,40 +884,116 @@ onMounted(() => {
font-weight: 600;
}
.section-title {
margin: 8px 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.push-log-card {
margin-bottom: 8px;
}
.push-log-wrap {
min-height: 120px;
}
.detail-actions {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.push-log-title {
display: flex;
.sop-panel {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.sop-panel-header {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
margin-bottom: 12px;
}
.sop-panel-title {
font-size: 16px;
font-weight: 600;
}
.push-log-line {
margin-top: 4px;
.sop-panel-tip {
color: var(--el-text-color-secondary);
line-height: 20px;
font-size: 13px;
line-height: 1.7;
margin-top: 4px;
}
.sop-skeleton-row + .sop-skeleton-row {
margin-top: 10px;
}
.sop-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sop-card {
background: linear-gradient(180deg, rgba(250, 245, 240, 0.9), rgba(255, 251, 248, 0.96));
border: 1px solid rgba(196, 157, 140, 0.18);
border-radius: 16px;
padding: 14px 16px;
}
.sop-card-sequence {
color: #8d5a47;
font-size: 12px;
letter-spacing: 0.12em;
}
.sop-card-desc {
font-size: 15px;
font-weight: 600;
line-height: 1.7;
margin-top: 8px;
}
.sop-card-remark {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.7;
margin-top: 8px;
}
.sop-card-images {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
margin-top: 12px;
}
.sop-card-image {
background: #fff;
border: 1px solid rgba(196, 157, 140, 0.22);
border-radius: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
text-align: left;
}
.sop-card-image img {
border-radius: 8px;
height: 96px;
object-fit: cover;
width: 100%;
}
.sop-card-image span {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.image-preview-container {
align-items: center;
display: flex;
justify-content: center;
min-height: 320px;
}
.image-preview-container img {
max-height: 70vh;
max-width: 100%;
}
@media (max-width: 1200px) {

@ -1,124 +1,172 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryFormRef" size="small" :inline="true" v-show="showSearch" label-width="100px">
<el-form-item label="规则名称" prop="ruleName">
<el-input v-model="queryParams.ruleName" placeholder="请输入规则名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="触发规则" prop="triggerRule">
<el-select v-model="queryParams.triggerRule" placeholder="请选择触发规则" clearable>
<el-option v-for="dict in dict.type.trigger_rule" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="启用状态" prop="isEnable">
<el-select v-model="queryParams.isEnable" placeholder="请选择启用状态" clearable>
<el-option v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<!-- <el-form-item label="通知用户" prop="notifyUser">-->
<!-- <el-input-->
<!-- v-model="queryParams.notifyUser"-->
<!-- placeholder="请输入通知用户"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery"></el-button>
</el-form-item>
</el-form>
<div class="app-container page-shell">
<section class="page-hero">
<div class="hero-copy">
<span class="hero-eyebrow">ALARM RULE STUDIO</span>
<h2 class="hero-title">异常告警规则与处置措施工作台</h2>
<p class="hero-desc">把触发阈值恢复区间和处置步骤聚合在一个界面中帮助运维团队更快定位规则编排措施并完成闭环维护</p>
</div>
<div class="hero-stats">
<article v-for="item in overviewStats" :key="item.label" class="stat-card">
<span class="stat-label">{{ item.label }}</span>
<strong class="stat-value">{{ item.value }}</strong>
<span class="stat-hint">{{ item.hint }}</span>
</article>
</div>
</section>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['ems/record:recordAlarmRule:add']"
>新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['ems/record:recordAlarmRule:edit']"
>修改
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['ems/record:recordAlarmRule:remove']"
>删除
</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['ems/record:recordAlarmRule:export']"
>导出
</el-button>
</el-col>
<right-toolbar v-model:show-search="showSearch" @query-table="getList" :columns="columns"></right-toolbar>
</el-row>
<section v-show="showSearch" class="panel-shell search-shell">
<div class="panel-head">
<div>
<h3>规则筛选</h3>
<p>按规则名称触发逻辑和启用状态快速定位目标告警规则</p>
</div>
<span class="panel-badge">规则检索</span>
</div>
<el-form :model="queryParams" ref="queryFormRef" size="small" :inline="true" label-width="100px" class="query-form">
<el-form-item label="规则名称" prop="ruleName">
<el-input v-model="queryParams.ruleName" placeholder="请输入规则名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="触发规则" prop="triggerRule">
<el-select v-model="queryParams.triggerRule" placeholder="请选择触发规则" clearable>
<el-option v-for="dict in dict.type.trigger_rule" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="启用状态" prop="isEnable">
<el-select v-model="queryParams.isEnable" placeholder="请选择启用状态" clearable>
<el-option v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="queryParams.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 220px">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item class="query-actions">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery"></el-button>
</el-form-item>
</el-form>
</section>
<el-table v-loading="loading" :data="recordAlarmRuleList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" width="60" align="center" v-if="columns[0].visible" />
<el-table-column label="规则名称" align="center" prop="ruleName" v-if="columns[1].visible" />
<el-table-column label="计量设备" align="center" prop="monitorName" v-if="columns[2].visible" />
<el-table-column label="触发规则" align="center" prop="triggerRule" v-if="columns[3].visible">
<template #default="scope">
<dict-tag :options="dict.type.trigger_rule" :value="scope.row.triggerRule" />
</template>
</el-table-column>
<el-table-column label="监测字段" align="center" prop="monitorField" v-if="columns[4].visible">
<template #default="scope">
<dict-tag :options="dict.type.monitor_field" :value="scope.row.monitorField" />
</template>
</el-table-column>
<el-table-column label="告警上限" align="center" prop="alarmUpper" v-if="columns[5].visible" />
<el-table-column label="告警下限" align="center" prop="alarmLower" v-if="columns[6].visible" />
<el-table-column label="恢复上限" align="center" prop="recoverUpper" v-if="columns[7].visible" />
<el-table-column label="恢复下限" align="center" prop="recoverLower" v-if="columns[8].visible" />
<el-table-column label="回差" align="center" prop="hysteresis" v-if="columns[9].visible" />
<el-table-column label="持续秒数" align="center" prop="durationSec" v-if="columns[10].visible" />
<el-table-column label="告警级别" align="center" prop="alarmLevel" v-if="columns[11].visible" />
<el-table-column label="启用状态" align="center" prop="isEnable" v-if="columns[12].visible">
<template #default="scope">
<dict-tag :options="dict.type.is_flag" :value="scope.row.isEnable" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="cause" v-if="columns[13].visible" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
<template #default="scope">
<el-button
size="mini"
type="text"
icon="el-icon-setting"
@click="handleActionSteps(scope.row)"
v-hasPermi="['ems/record:recordAlarmRule:edit']"
>措施
<section class="panel-shell table-shell">
<div class="panel-head">
<div>
<h3>规则列表</h3>
<p>保持原有批量操作与导出能力同时通过更清晰的层次强化巡检效率</p>
</div>
<span class="panel-badge warm">措施联动</span>
</div>
<div class="notify-entry-bar">
<div class="notify-entry-text">通知组与通知成员的配置入口已经接回规则页保存时会同步回填通知对象摘要避免规则挂空通知链</div>
<div class="notify-entry-actions">
<el-button link type="primary" @click="openNotifyGroupPage"></el-button>
<el-button link type="primary" @click="openNotifyUserPage"></el-button>
</div>
</div>
<el-row :gutter="10" class="toolbar-row">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['ems/record:recordAlarmRule:add']"
>新增
</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['ems/record:recordAlarmRule:edit']"
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['ems/record:recordAlarmRule:edit']"
>修改
</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['ems/record:recordAlarmRule:remove']"
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['ems/record:recordAlarmRule:remove']"
>删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['ems/record:recordAlarmRule:export']"
>导出
</el-button>
</el-col>
<right-toolbar v-model:show-search="showSearch" @query-table="getList" :columns="columns"></right-toolbar>
</el-row>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<el-table v-loading="loading" :data="recordAlarmRuleList" @selection-change="handleSelectionChange" class="data-table">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" width="60" align="center" v-if="columns[0].visible" />
<el-table-column label="规则名称" align="center" prop="ruleName" v-if="columns[1].visible" />
<el-table-column label="计量设备" align="center" prop="monitorName" v-if="columns[2].visible" />
<el-table-column label="触发规则" align="center" prop="triggerRule" v-if="columns[3].visible">
<template #default="scope">
<dict-tag :options="dict.type.trigger_rule" :value="scope.row.triggerRule" />
</template>
</el-table-column>
<el-table-column label="监测字段" align="center" prop="monitorField" v-if="columns[4].visible">
<template #default="scope">
<dict-tag :options="dict.type.monitor_field" :value="scope.row.monitorField" />
</template>
</el-table-column>
<el-table-column label="告警上限" align="center" prop="alarmUpper" v-if="columns[5].visible" />
<el-table-column label="告警下限" align="center" prop="alarmLower" v-if="columns[6].visible" />
<el-table-column label="恢复上限" align="center" prop="recoverUpper" v-if="columns[7].visible" />
<el-table-column label="恢复下限" align="center" prop="recoverLower" v-if="columns[8].visible" />
<el-table-column label="回差" align="center" prop="hysteresis" v-if="columns[9].visible" />
<el-table-column label="持续秒数" align="center" prop="durationSec" v-if="columns[10].visible" />
<el-table-column label="告警级别" align="center" prop="alarmLevel" v-if="columns[11].visible" />
<el-table-column label="启用状态" align="center" prop="isEnable" v-if="columns[12].visible">
<template #default="scope">
<dict-tag :options="dict.type.is_flag" :value="scope.row.isEnable" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="cause" v-if="columns[13].visible" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
<template #default="scope">
<el-button
size="mini"
type="text"
icon="el-icon-setting"
@click="handleActionSteps(scope.row)"
v-hasPermi="['ems/record:recordAlarmRule:edit']"
>措施
</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['ems/record:recordAlarmRule:edit']"
>修改
</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ems/record:recordAlarmRule:remove']"
>删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
</section>
<!-- 添加或修改异常告警规则对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="150px">
<el-dialog :title="title" v-model="open" width="500px" append-to-body class="themed-dialog">
<div class="dialog-tip">
<span class="tip-dot"></span>
规则页继续沿用现有字段提交逻辑只在视觉上突出阈值恢复区间和设备绑定关系避免误改业务能力
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="150px" class="dialog-form">
<!-- <el-form-item label="规则编号" prop="ruleId">
<el-input v-model="form.ruleId" placeholder="请输入规则编号" disabled/>
</el-form-item> -->
@ -177,14 +225,20 @@
<el-form-item label="告警级别" prop="alarmLevel">
<el-input v-model="form.alarmLevel" placeholder="请输入告警级别" />
</el-form-item>
<el-form-item label="通知组" prop="notifyGroupId">
<el-select v-model="form.notifyGroupId" placeholder="请选择通知组" clearable filterable style="width: 100%" @change="handleNotifyGroupChange">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="通知对象摘要" prop="notifyUser">
<el-input v-model="form.notifyUser" type="textarea" :rows="2" placeholder="可手工补充通知对象;选择通知组后会自动回填成员摘要" />
<div class="field-tip">这里保留 notifyUser 摘要字段是为了兼容旧台账展示后端会以 notifyGroupId 作为通知配置的主校验入口</div>
</el-form-item>
<el-form-item label="启用状态" prop="isEnable">
<el-radio-group v-model="form.isEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<!-- <el-form-item label="通知用户" prop="notifyUser">-->
<!-- <el-input v-model="form.notifyUser" placeholder="请输入通知用户"/>-->
<!-- </el-form-item>-->
<el-form-item label="备注" prop="cause">
<el-input v-model="form.cause" placeholder="请输入备注" />
</el-form-item>
@ -198,8 +252,12 @@
</el-dialog>
<!-- 措施管理对话框 -->
<el-dialog :title="actionStepsTitle" v-model="actionStepsOpen" width="1200px" append-to-body>
<el-dialog :title="actionStepsTitle" v-model="actionStepsOpen" width="1200px" append-to-body class="themed-dialog action-dialog">
<div class="action-steps-container">
<div class="dialog-tip soft">
<span class="tip-dot"></span>
处置步骤支持顺序编排图片补充和批量保存适合沉淀标准化应急 SOP
</div>
<!-- 步骤列表 -->
<div class="steps-section">
<div class="section-header">
@ -289,7 +347,7 @@
</el-dialog>
<!-- 图片预览对话框 -->
<el-dialog title="图片预览" v-model="imagePreviewVisible" width="80%" append-to-body center>
<el-dialog title="图片预览" v-model="imagePreviewVisible" width="80%" append-to-body center class="themed-dialog preview-dialog">
<div class="image-preview-container">
<img :src="previewImageUrl" style="max-width: 100%; max-height: 70vh" />
</div>
@ -300,6 +358,8 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus';
import { useDict } from '@/utils/dict';
import { listAlarmNotifyGroupAll } from '@/api/ems/base/alarmNotifyGroup';
import { listAlarmNotifyGroupUser } from '@/api/ems/base/alarmNotifyGroupUser';
import {
listRecordAlarmRule,
getRecordAlarmRule,
@ -309,6 +369,8 @@ import {
} from '@/api/ems/record/recordAlarmRule';
import { listBaseMonitorInfo } from '@/api/ems/base/baseMonitorInfo';
import { getEmsAlarmActionStepsByRuleId, batchSaveActionSteps } from '@/api/ems/base/emsAlarmActionStep';
import type { AlarmNotifyGroupVO } from '@/api/ems/base/alarmNotifyGroup/types';
import type { AlarmNotifyGroupUserVO } from '@/api/ems/base/alarmNotifyGroupUser/types';
import { getToken } from '@/utils/auth';
defineOptions({
@ -410,6 +472,8 @@ const state = reactive({
open: false,
//
monitorList: [],
groupOptions: [] as AlarmNotifyGroupVO[],
groupUserMap: {} as Record<string, AlarmNotifyGroupUserVO[]>,
selectedMonitorType: null,
// monitorType
//
@ -419,13 +483,10 @@ const state = reactive({
form: createFormData(),
//
rules: {
objId: [
{
required: true,
message: '自增标识不能为空',
trigger: 'blur'
}
]
ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
monitorId: [{ required: true, message: '计量设备不能为空', trigger: 'change' }],
triggerRule: [{ required: true, message: '触发规则不能为空', trigger: 'change' }],
monitorField: [{ required: true, message: '监测字段不能为空', trigger: 'change' }]
},
columns: [
{
@ -524,6 +585,7 @@ const {
columns,
currentRuleObjId,
form,
groupOptions,
ids,
imagePreviewVisible,
loading,
@ -543,6 +605,48 @@ const {
uploadHeaders
} = toRefs(state);
const resolveNotifyUsers = (groupId?: string | number | null) => {
if (groupId === null || groupId === undefined || groupId === '') {
return '';
}
const users = state.groupUserMap[String(groupId)] || [];
return users
.map((item) => item.nickName || item.userName || item.phone || item.email)
.filter(Boolean)
.join('、');
};
//
const overviewStats = computed(() => {
const rules = recordAlarmRuleList.value;
const relatedDevices = new Set(rules.filter((item) => item.monitorId).map((item) => item.monitorId)).size;
const dualThresholdRules = rules.filter((item) => item.alarmUpper != null && item.alarmLower != null).length;
const recoveryRules = rules.filter((item) => item.recoverUpper != null || item.recoverLower != null).length;
return [
{
label: '规则总数',
value: total.value || rules.length,
hint: '当前列表分页总量'
},
{
label: '关联设备',
value: relatedDevices,
hint: '已绑定计量设备'
},
{
label: '双阈值规则',
value: dualThresholdRules,
hint: '同时配置上下限'
},
{
label: '含恢复区间',
value: recoveryRules,
hint: '带恢复阈值约束'
}
];
});
const getList = () => {
loading.value = true;
listRecordAlarmRule(queryParams.value).then((response) => {
@ -600,6 +704,9 @@ const handleUpdate = (row) => {
if (selectedMonitor) {
selectedMonitorType.value = selectedMonitor.monitorType;
}
if (form.value.notifyGroupId) {
handleNotifyGroupChange(form.value.notifyGroupId);
}
open.value = true;
title.value = '修改异常告警规则';
});
@ -623,6 +730,14 @@ const submitForm = () => {
form.value.triggerValue = form.value.alarmLower;
}
form.value.isEnable = form.value.isEnable ?? '0';
if (form.value.notifyGroupId) {
const notifySummary = resolveNotifyUsers(form.value.notifyGroupId);
if (!notifySummary && !form.value.notifyUser) {
ElMessage.warning('当前通知组没有可用成员,请先补充通知成员后再保存规则');
return;
}
form.value.notifyUser = form.value.notifyUser || notifySummary;
}
if (form.value.objId != null) {
updateRecordAlarmRule(form.value).then((response) => {
proxy?.$modal.msgSuccess('修改成功');
@ -670,6 +785,49 @@ const getMonitorList = () => {
});
};
const loadNotifyGroupOptions = async () => {
const response = await listAlarmNotifyGroupAll();
groupOptions.value = (((response as any).data ?? (response as any).rows ?? []) || []) as AlarmNotifyGroupVO[];
};
const loadNotifyGroupUsers = async () => {
const response = await listAlarmNotifyGroupUser({
pageNum: 1,
pageSize: 1000
} as any);
const users = (((response as any).rows ?? (response as any).data ?? []) || []) as AlarmNotifyGroupUserVO[];
const nextMap: Record<string, AlarmNotifyGroupUserVO[]> = {};
users.forEach((item) => {
const groupId = item.groupId;
if (groupId === null || groupId === undefined || groupId === '') {
return;
}
const key = String(groupId);
if (!nextMap[key]) {
nextMap[key] = [];
}
nextMap[key].push(item);
});
state.groupUserMap = nextMap;
};
const handleNotifyGroupChange = (groupId?: string | number | null) => {
if (!groupId) {
form.value.notifyUser = null;
return;
}
const notifySummary = resolveNotifyUsers(groupId);
form.value.notifyUser = notifySummary || form.value.notifyUser;
};
const openNotifyGroupPage = () => {
proxy?.$tab.openPage('/ems/base/alarmNotifyGroup', '通知组配置');
};
const openNotifyUserPage = () => {
proxy?.$tab.openPage('/ems/base/alarmNotifyGroupUser', '通知成员配置');
};
const handleMonitorChange = (monitorId) => {
// monitorIdmonitorCode
const selectedMonitor = monitorList.value.find((m) => m.monitorCode === monitorId);
@ -902,12 +1060,252 @@ const getFullImageUrl = (relativePath) => {
onMounted(() => {
getList();
getMonitorList();
loadNotifyGroupOptions();
loadNotifyGroupUsers();
});
</script>
<style lang="scss" scoped>
.action-steps-container {
.page-shell {
min-height: 100%;
padding: 20px;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 30%),
radial-gradient(circle at right center, rgba(244, 114, 182, 0.12), transparent 24%),
linear-gradient(180deg, #f6f8ff 0%, #eef3fb 45%, #f8fafc 100%);
}
.page-hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 1fr);
gap: 18px;
margin-bottom: 18px;
padding: 30px;
border-radius: 28px;
background: linear-gradient(135deg, rgba(43, 52, 126, 0.96) 0%, rgba(30, 64, 175, 0.92) 45%, rgba(219, 39, 119, 0.82) 100%);
box-shadow: 0 28px 54px rgba(46, 63, 140, 0.2);
color: #fff;
position: relative;
overflow: hidden;
}
.page-hero::before {
content: '';
position: absolute;
inset: -40px auto auto -70px;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.hero-copy,
.hero-stats {
position: relative;
z-index: 1;
}
.hero-eyebrow {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.16em;
}
.hero-title {
margin: 18px 0 12px;
font-size: 30px;
line-height: 1.2;
}
.hero-desc {
margin: 0;
max-width: 680px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.82);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.stat-card {
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(10px);
}
.stat-label,
.stat-hint {
display: block;
}
.stat-label {
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
}
.stat-value {
display: block;
margin: 10px 0 8px;
font-size: 30px;
font-weight: 700;
line-height: 1;
}
.stat-hint {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
}
.panel-shell {
margin-bottom: 18px;
padding: 20px 22px;
border-radius: 24px;
border: 1px solid rgba(208, 216, 237, 0.75);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 38px rgba(49, 62, 124, 0.08);
backdrop-filter: blur(14px);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.panel-head h3 {
margin: 0 0 6px;
font-size: 20px;
color: #1f2a5f;
}
.panel-head p {
margin: 0;
color: #6c7598;
line-height: 1.6;
}
.panel-badge {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
background: linear-gradient(135deg, #dbeafe, #ede9fe);
color: #3344a1;
font-size: 12px;
font-weight: 600;
}
.panel-badge.warm {
background: linear-gradient(135deg, #fee2e2, #fce7f3);
color: #b42367;
}
.query-form {
padding: 18px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(248, 250, 255, 0.96) 0%, rgba(255, 255, 255, 0.92) 100%);
}
.query-actions {
:deep(.el-form-item__content) {
gap: 10px;
}
}
.toolbar-row {
margin-bottom: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.08), rgba(236, 72, 153, 0.08));
}
.notify-entry-bar {
align-items: center;
background: rgba(249, 239, 225, 0.7);
border: 1px solid rgba(190, 141, 103, 0.18);
border-radius: 18px;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
padding: 12px 16px;
}
.notify-entry-text {
color: #6f5b51;
font-size: 13px;
line-height: 1.7;
}
.notify-entry-actions {
display: flex;
gap: 10px;
}
.data-table {
:deep(.el-table__header-wrapper th) {
background: #f5f7ff;
color: #24336f;
font-weight: 700;
}
:deep(.el-table__body tr:hover > td) {
background: rgba(79, 70, 229, 0.06) !important;
}
}
.dialog-tip {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 16px;
background: linear-gradient(90deg, rgba(79, 70, 229, 0.08), rgba(236, 72, 153, 0.08));
color: #42507f;
line-height: 1.65;
}
.dialog-tip.soft {
margin-bottom: 20px;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.08), rgba(16, 185, 129, 0.08));
}
.tip-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #ec4899);
box-shadow: 0 0 0 5px rgba(99, 102, 241, 0.12);
}
.dialog-form {
padding: 18px;
border-radius: 18px;
background: #fafbff;
}
.field-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
margin-top: 6px;
}
.action-steps-container {
padding: 6px 0 0;
}
.section-header {
@ -915,14 +1313,14 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #409eff;
padding: 0 0 14px;
border-bottom: 1px solid rgba(189, 199, 226, 0.72);
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #409eff;
color: #31439f;
}
.empty-steps {
@ -933,23 +1331,24 @@ onMounted(() => {
.steps-list {
.step-item {
margin-bottom: 30px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fafafa;
border: 1px solid rgba(208, 216, 237, 0.85);
border-radius: 22px;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
box-shadow: 0 16px 30px rgba(49, 62, 124, 0.08);
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f0f9ff;
border-bottom: 1px solid #e4e7ed;
border-radius: 8px 8px 0 0;
background: linear-gradient(90deg, rgba(99, 102, 241, 0.1), rgba(236, 72, 153, 0.08));
border-bottom: 1px solid rgba(208, 216, 237, 0.7);
border-radius: 22px 22px 0 0;
.step-number {
font-size: 16px;
font-weight: bold;
color: #409eff;
color: #31439f;
}
.step-controls {
@ -962,7 +1361,7 @@ onMounted(() => {
font-size: 16px;
&:hover {
color: #409eff;
color: #31439f;
}
}
}
@ -979,6 +1378,8 @@ onMounted(() => {
width: 100%;
height: 120px;
margin-bottom: 20px;
border-radius: 18px;
background: linear-gradient(180deg, #f9fbff 0%, #ffffff 100%);
}
.image-list {
@ -989,10 +1390,11 @@ onMounted(() => {
.image-item {
width: 200px;
border: 1px solid #e4e7ed;
border-radius: 6px;
border: 1px solid rgba(208, 216, 237, 0.85);
border-radius: 18px;
overflow: hidden;
background: white;
box-shadow: 0 12px 24px rgba(49, 62, 124, 0.08);
.image-preview {
height: 150px;
@ -1030,12 +1432,70 @@ onMounted(() => {
text-align: center;
img {
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 18px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
}
}
.dialog-footer {
text-align: right;
}
:deep(.themed-dialog .el-dialog) {
border-radius: 26px;
overflow: hidden;
box-shadow: 0 28px 60px rgba(28, 37, 94, 0.22);
}
:deep(.themed-dialog .el-dialog__header) {
padding: 22px 24px 14px;
background: linear-gradient(135deg, rgba(49, 65, 163, 0.96), rgba(190, 24, 93, 0.88));
}
:deep(.themed-dialog .el-dialog__title),
:deep(.themed-dialog .el-dialog__headerbtn .el-dialog__close) {
color: #fff;
}
:deep(.themed-dialog .el-dialog__body) {
padding: 22px 24px;
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
}
:deep(.themed-dialog .el-dialog__footer) {
padding: 0 24px 24px;
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
}
@media (max-width: 992px) {
.page-hero {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-shell {
padding: 12px;
}
.page-hero,
.panel-shell {
padding: 18px;
border-radius: 20px;
}
.hero-stats {
grid-template-columns: 1fr;
}
.panel-head {
flex-direction: column;
align-items: flex-start;
}
.notify-entry-bar {
align-items: flex-start;
flex-direction: column;
}
}
</style>

Loading…
Cancel
Save