Files
smart_grid_iot/client/src/views/device/line/index.vue
T

607 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="device-line">
<!-- 顶部标题 -->
<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">ELECTRIC LINE LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。</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.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">线路状态</label>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear>
<a-select-option :value="1">在线</a-select-option>
<a-select-option :value="0">离线</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="线路名称 / 编码 / IMEI"
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="primary" :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></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 === 'lineCode'">
<span class="mono">{{ record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'lineName'">
<span class="line-name">{{ record.lineName || '-' }}</span>
</template>
<template v-else-if="column.key === 'imei'">
<span class="mono">{{ record.imei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'gatewayId'">
{{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
</template>
<template v-else-if="column.key === 'typeName'">
{{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'loopChannel'">
{{ [record.partUnitLoopCode, record.channelNo].filter(Boolean).join('/') || '-' }}
</template>
<template v-else-if="column.key === 'fireSystemName'">
{{ record.fireSystemName || '-' }}
</template>
<template v-else-if="column.key === 'produceDate'">
{{ record.produceDate || '-' }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? '在线' : '离线' }}
</a-tag>
</template>
<template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewData(record)">
线路数据
</a-button>
</template>
</template>
<template #emptyText>
<a-empty description="暂无线路数据,可点击右上角「同步线路」从 ICC 拉取" />
</template>
</a-table>
</div>
</div>
</div>
<a-modal
v-model:open="analogModal.open"
:title="analogModal.title"
:width="720"
:footer="null"
>
<div v-if="analogModal.loading" class="modal-tip">加载中...</div>
<div v-else-if="!analogModal.snapshot || !analogModal.snapshot.hasData" class="modal-tip">暂无模拟量数据,可点击「同步线路」或等待定时同步后查看</div>
<template v-else>
<div class="modal-meta">
<span>线路:{{ analogModal.snapshot.lineName || '-' }}</span>
<span>编码:{{ analogModal.snapshot.lineCode || '-' }}</span>
<span>采集时间:{{ formatTime(analogModal.snapshot.collectTime) }}</span>
</div>
<a-table
:columns="analogColumns"
:data-source="analogModal.snapshot.list"
:pagination="false"
row-key="analogType"
size="small"
/>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import {
electricLineApi,
electricityDataApi,
type ElectricLine,
type ElectricLineQuery,
type ElectricityLatestSnapshot,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const syncing = ref(false);
const analogModal = reactive<{
open: boolean;
loading: boolean;
title: string;
snapshot: ElectricityLatestSnapshot | null;
}>({
open: false,
loading: false,
title: '线路模拟数据',
snapshot: null,
});
const analogColumns = [
{ title: '模拟量名称', dataIndex: 'analogName', key: 'analogName', ellipsis: true },
{ title: '类型', dataIndex: 'analogType', key: 'analogType', ellipsis: true, width: 100 },
{ title: '数值', dataIndex: 'analogValue', key: 'analogValue', align: 'center' as const, width: 120 },
{ title: '单位', dataIndex: 'analogUnit', key: 'analogUnit', align: 'center' as const, width: 80 },
];
const dataSource = ref<ElectricLine[]>([]);
const total = ref(0);
const searchForm = reactive<ElectricLineQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
status: undefined as any,
});
const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 160 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: 'IMEI', dataIndex: 'imei', key: 'imei', ellipsis: true, width: 180 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '所属网关', dataIndex: 'gatewayId', key: 'gatewayId', ellipsis: true, width: 180 },
{ title: '探测器类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '回路/通道', dataIndex: 'loopChannel', key: 'loopChannel', ellipsis: true, width: 160 },
{ title: '消防系统', dataIndex: 'fireSystemName', key: 'fireSystemName', ellipsis: true, width: 140 },
{ title: '生产日期', dataIndex: 'produceDate', key: 'produceDate', ellipsis: true, width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 80 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
{ title: '操作', key: 'action', align: 'center' as const, fixed: 'right' as const, width: 100 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await electricLineApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取线路列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
async function handleSync() {
syncing.value = true;
try {
const res: any = await electricLineApi.sync();
if (res.code === 200) {
const d = res.data || {};
message.success(`同步完成:拉取 ${d.pulled ?? 0} 条,新增 ${d.inserted ?? 0},更新 ${d.updated ?? 0}`);
fetchList();
} else {
message.error(res.message || '同步失败');
}
} catch (error) {
message.error('同步失败,请稍后重试');
} finally {
syncing.value = false;
}
}
async function handleViewData(record: ElectricLine) {
analogModal.open = true;
analogModal.loading = true;
analogModal.title = `线路数据 - ${record.lineName || record.lineCode}`;
analogModal.snapshot = null;
try {
const res: any = await electricityDataApi.getLatestByLine(record.id);
if (res.code === 200) {
analogModal.snapshot = res.data;
} else {
message.error(res.message || '获取模拟数据失败');
}
} catch (error) {
message.error('获取模拟数据失败,请稍后重试');
} finally {
analogModal.loading = false;
}
}
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>
.device-line {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
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-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 #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
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;
}
.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 #dbe4ee;
}
.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;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.line-name {
font-weight: 600;
color: #0d1c2c;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
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;
}
.modal-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13px;
color: #5b6b7e;
margin-bottom: 12px;
}
.modal-meta span {
font-weight: 600;
color: #0d1c2c;
}
.modal-tip {
padding: 40px 0;
text-align: center;
color: #5b6b7e;
}
</style>