feat(alarm): ICC 报警事件订阅与设备报警监控/报警派单 - 重建 device_alarm 表(迁移20261024_icc_event_alarm.sql:alarm_code 唯一、uuid、line 关联、客户、等级、事件标记、alarm_time 等 19 字段与 4 索引)并新建 alarm_dispatch 派单表(15 字段);订阅服务 IccEventSubscribeService 复用 OAuth2 鉴权向 ICC 注册报警事件回调(POST subscribe/mqinfo,monitor 带 token、同名覆盖保活),每 30 分钟保活任务(sys_job 种子);回调 POST /api/icc-event/callback 无鉴权仅 URL token 校验,兼容单条/数组事件解析、按 alarmCode 幂等 upsert、匹配 electric_line.line_code 回填线路客户;新增 GET /api/device-alarms(列表+今日/严重/未派单统计+客户/等级/关键词筛选)与 POST /api/device-alarms/:id/dispatch(AD 派单号、同报警重复派单拦截、电工在岗审核校验、NoticeService 站内通知);GET /api/alarm-dispatches 台账(联电工/客户名);前端设备报警监控页(统计卡/筛选/表格/发起报警派单弹窗/30 秒轮询+严重报警红灯闪烁与 Web Audio 蜂鸣/订阅状态弹窗)与报警派单台账页+路由;菜单 63/64、role_menu、sys_job 种子;同步 db_init/db_seed 与设计文档;端到端验证通过

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
wkandClaude Sonnet 4.6 noreply@anthropic.com authored and trae-solo-agent committed 2026-10-24 16:59:02 +08:00
1 parent c8a4330f3b
commit 44fc9a1d54
24 files changed
+2825 -126

No files matched your search

File diff suppressed because it is too large. Load diff
+511
View File
@@ -0,0 +1,511 @@
<template>
<div class="alarm-dispatch-page">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>运营派单</span>
<span class="sep">/</span>
<span class="cur">报警派单台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">报警派单台账</span>
<span class="sub-title">ALARM DISPATCH LEDGER</span>
</div>
<p class="desc">统一管理报警派单全过程台账,支持按派单状态检索,跟踪派发、处理与完成情况。</p>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">派单状态</label>
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
:options="statusOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="派单号 / 线路名称 / 线路编码"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">报警派单数据台账</span>
<span class="count">{{ total }} 条</span>
</div>
<div class="list-actions">
<a-button type="text" size="small" title="刷新列表" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dispatchNo'">
<span class="mono dispatch-no">{{ record.dispatchNo || '-' }}</span>
</template>
<template v-else-if="column.key === 'alarmTime'">
<span>{{ formatTime(record.alarmTime) }}</span>
</template>
<template v-else-if="column.key === 'lineName'">
<span class="line-name">{{ record.lineName || record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'lineCode'">
<span class="mono">{{ record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName || record.customerId || '-' }}
</template>
<template v-else-if="column.key === 'workerName'">
<span>{{ record.workerName || '未指派' }}</span>
</template>
<template v-else-if="column.key === 'alarmGrade'">
<a-tag :color="gradeMeta(record.alarmGrade).color">
{{ gradeMeta(record.alarmGrade).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusMeta(record.status).color">
{{ statusMeta(record.status).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'dispatcher'">
<span>{{ record.dispatcher || '-' }}</span>
</template>
<template v-else-if="column.key === 'createdAt'">
<span>{{ formatTime(record.createdAt) }}</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无报警派单数据" />
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { alarmDispatchApi, type AlarmDispatchRow, type AlarmDispatchQuery } from '@/api/alarm';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<AlarmDispatchRow[]>([]);
const total = ref(0);
const searchForm = reactive<AlarmDispatchQuery>({
page: 1,
pageSize: 10,
keyword: '',
status: undefined as any,
});
const statusOptions = [
{ label: '已派发', value: 0 },
{ label: '已处理', value: 1 },
];
const STATUS_META: Record<number, { label: string; color: string }> = {
0: { label: '已派发', color: 'processing' },
1: { label: '已处理', color: 'success' },
};
function statusMeta(v?: number) {
return STATUS_META[v ?? -1] || { label: '未知', color: 'default' };
}
const GRADE_META: Record<number, { label: string; color: string }> = {
1: { label: '严重', color: 'error' },
2: { label: '一般', color: 'warning' },
3: { label: '提示', color: 'default' },
};
function gradeMeta(v?: number) {
return GRADE_META[v ?? 0] || { label: '未知', color: 'default' };
}
const columns = [
{ title: '派单号', dataIndex: 'dispatchNo', key: 'dispatchNo', ellipsis: true, width: 160 },
{ title: '报警时间', dataIndex: 'alarmTime', key: 'alarmTime', ellipsis: true, width: 170 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 150 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '指派电工', dataIndex: 'workerName', key: 'workerName', ellipsis: true, width: 120 },
{ title: '报警等级', dataIndex: 'alarmGrade', key: 'alarmGrade', align: 'center' as const, width: 90 },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 100 },
{ title: '派发人', dataIndex: 'dispatcher', key: 'dispatcher', ellipsis: true, width: 120 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 170 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res = await alarmDispatchApi.getList(searchForm);
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
// 拦截器已提示
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchList();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleRefresh() {
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 展示辅助 ====================
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
</script>
<style scoped>
.alarm-dispatch-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f4f7fb;
padding: 20px 24px;
box-sizing: border-box;
}
/* 顶部标题 */
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
/* 主体卡片 */
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
padding: 16px 18px;
box-sizing: border-box;
overflow: hidden;
}
/* 筛选区 */
.filter-card {
flex-shrink: 0;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: 1fr minmax(220px, 2fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #eef1f6;
}
.filter-actions {
display: flex;
gap: 8px;
}
/* 列表区 */
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 单元格 */
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.dispatch-no {
color: #0054cd;
font-weight: 700;
}
.line-name {
font-weight: 600;
color: #0d1c2c;
}
/* AntD 覆盖 */
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f8fafc;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>