- 重建 electric_line 表结构(迁移20261024_icc_line_rebuild.sql并同步db_init/db_seed):删building_id/floor_no/location/icc_device_code占位字段,equip_id改可空,新增imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date,gateway_id外键→electric_gateway - 同步服务IccLineSyncService复用OAuth2真实鉴权分页拉取探测器(POST /detector/subsystem/page,pageSize=50短页停止),按line_code(detectorCode)幂等upsert写icc_sync_log;customer_id由orgCode→customer.icc_code解析、gateway_id由parentCode→electric_gateway.device_code匹配、equip_id同步不填充 - 列表接口返回gateway/type/imei等新字段,keyword检索含imei;前端台账页更新为新列(IMEI/所属网关/探测器类型/回路通道/消防系统/生产日期),保留手动同步按钮与筛选 - 更新系统设计文档electric_line表定义与spec(add-icc-line-sync)
521 lines
12 KiB
Vue
521 lines
12 KiB
Vue
<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>
|
||
|
||
<template #emptyText>
|
||
<a-empty description="暂无线路数据,可点击右上角「同步线路」从 ICC 拉取" />
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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,
|
||
type ElectricLine,
|
||
type ElectricLineQuery,
|
||
} 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 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 },
|
||
];
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
</style>
|