Merge branch 'main' of http://100.64.131.105:3000/GY/smart_grid_iot
This commit is contained in:
commit
184b8d03e2
21 files changed
+1363
-6
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# Checklist
|
||||
|
||||
- [x] electric_line / line_room / icc_sync_log 表存在(生产库 + db_seed.sql 一致,幂等)
|
||||
- [x] ElectricLine / IccSyncLog 实体与 DTO 实现,ElectricLine 仅 created_at(timestamps:false)
|
||||
- [x] 列表查询使用参数绑定(Op.like/Op.in),无字符串拼接 SQL
|
||||
- [x] GET /api/electric-lines:无 token 401、有 device:list 权限 200 返回 code/message/data 结构
|
||||
- [x] POST /api/electric-lines/sync:JWT+RBAC 鉴权;定时任务服务内直调
|
||||
- [x] IccLineSyncService 读 ICC_BASE_URL/ICC_TOKEN 占位配置,按 line_code upsert + 写 icc_sync_log;ICC 拉取失败记录 error 不抛业务错
|
||||
- [x] icc_line_sync 定时任务已注册(@midwayjs/cron)
|
||||
- [x] RBAC 映射含 /api/electric-lines → device:list
|
||||
- [x] 前端 /device/line 页面渲染(搜索/同步按钮/表格/分页/状态 tag),无 v-html
|
||||
- [x] 菜单 id=61 挂设备监控(4) 且 role_menu 绑定,db_seed.sql 与生产库一致
|
||||
- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过
|
||||
- [x] 浏览器端登录后菜单可见、页面可用、同步按钮可用
|
||||
@@ -0,0 +1,45 @@
|
||||
# 智电改造线路台帐(设备监控)Spec
|
||||
|
||||
## Why
|
||||
设备监控模块缺少"智电改造线路"台账。线路数据由 ICC 平台同步到本系统,运营侧需按客户/设备/建筑查看线路及状态。需在既有设备模块最小套路基础上新增后端接口(含鉴权与防注入)与前端页面,并兼容 APP。
|
||||
|
||||
## What Changes
|
||||
- **新增** `electric_line` 表后端实体/DTO/Service/Controller(查询接口 `GET /api/electric-lines` 列表 + 详情,手动同步 `POST /api/electric-lines/sync`)
|
||||
- **新增** ICC 拉取同步:`IccLineSyncService` 从 `.env`(`ICC_BASE_URL`/`ICC_TOKEN`)读取占位配置,请求 ICC 线路列表接口(路径与字段映射集中定义),幂等 upsert 写入 `electric_line`,记录 `icc_sync_log`
|
||||
- **新增** 定时任务:注册 `icc_line_sync.task`(@midwayjs/cron,周期默认 10 分钟,可配置),服务内直调拉取
|
||||
- **新增** RBAC 映射:`/api/electric-lines → device:list`
|
||||
- **新增** 前端"智电改造线路台账"页面(`views/device/line/index.vue`),路由 `/device/line`,含"同步线路"手动按钮;菜单挂设备监控目录(parent=4,新 id=61)
|
||||
- **同步** db_seed.sql 与迁移文件(建表 + 菜单 + role_menu),保持幂等一致
|
||||
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`(兼容 APP)
|
||||
|
||||
## Impact
|
||||
- Affected specs: 设备监控(设备台账、智电设备型号)、ICC 数据同步、RBAC 权限
|
||||
- Affected code: server/src(entity/dto/service/controller/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
|
||||
|
||||
## ADDED Requirements
|
||||
### Requirement: 线路台账查询(JWT+RBAC)
|
||||
系统 SHALL 提供线路台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/building_id/status/关键词(line_name/line_code)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
|
||||
#### Scenario: 成功查询
|
||||
- **WHEN** 已登录用户 GET `/api/electric-lines?page=1&pageSize=10&customerId=1`
|
||||
- **THEN** 返回 `code=0,data{list,total}`,含线路编码/名称/所属设备/客户/建筑/楼层/位置/状态/同步时间;无 token 返回 401,无权限返回 403
|
||||
|
||||
### Requirement: ICC 拉取同步(占位配置 + 定时 + 手动)
|
||||
系统 SHALL 提供 ICC 线路拉取同步能力:`IccLineSyncService` 读取 `.env` 的 `ICC_BASE_URL`/`ICC_TOKEN`(占位,字段映射集中在 `icc_line_mapper`),向 ICC 拉取线路列表,按 `line_code` upsert(幂等)写入 `electric_line`,更新 `synced_at`,写 `icc_sync_log`(status/record_count/error_message)。同步入口:① 定时任务(@midwayjs/cron,默认每 10 分钟,服务内直调);② 手动 `POST /api/electric-lines/sync`(JWT+RBAC `device:list`)。
|
||||
#### Scenario: 定时/手动拉取成功
|
||||
- **WHEN** 定时任务触发或运营点击"同步线路"
|
||||
- **THEN** 拉取 ICC 数据并 upsert 对应线路行,`icc_sync_log` 记录成功;返回 `code=0,data{syncedCount,updatedCount}`
|
||||
#### Scenario: 手动同步鉴权失败
|
||||
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-lines/sync`
|
||||
- **THEN** 返回 401/403,不触发拉取
|
||||
#### Scenario: ICC 拉取失败
|
||||
- **WHEN** ICC 地址不可达或返回异常
|
||||
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错
|
||||
|
||||
### Requirement: 前端线路台账页
|
||||
系统 SHALL 提供 `/device/line` 页面:搜索栏(线路名称/编码、客户、状态)、"同步线路"按钮(调 `/api/electric-lines/sync`,成功后刷新)、表格(编码/名称/客户/设备/建筑/楼层/位置/状态 tag/同步时间)、分页;样式与设备台账一致,全部文本经 Vue 插值转义。
|
||||
#### Scenario: 打开页面
|
||||
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造线路台账
|
||||
- **THEN** 渲染列表并展示分页与筛选
|
||||
|
||||
## MODIFIED Requirements
|
||||
(无既有功能被修改)
|
||||
@@ -0,0 +1,26 @@
|
||||
# Tasks
|
||||
|
||||
- [ ] Task 1: 数据库层:新建 migration 建 `electric_line`(按 db_design.html:575-589 字段)、`line_room`、`icc_sync_log`(若不存在),并把建表语句同步进 db_seed.sql;幂等(IF NOT EXISTS / 增量)
|
||||
- [ ] SubTask 1.1: 核对生产库是否已有 electric_line/line_room/icc_sync_log,避免重复
|
||||
- [ ] SubTask 1.2: 写 migration(migrations/20261011_icc_line_ledger.sql)并执行到远程生产库
|
||||
- [ ] Task 2: 后端实体+DTO:新增 `ElectricLine` entity(timestamps:false,仅 created_at)、`IccSyncLog` entity;`electric_line.dto.ts` 定义查询/同步入参(line_code 必填、字段校验、长度限制)
|
||||
- [ ] Task 3: 后端 Service:
|
||||
- [ ] SubTask 3.1: `ElectricLineService.list(page, filter)`(Op.like/Op.in 参数绑定,非 ADMIN 限定 customer_id)
|
||||
- [ ] SubTask 3.2: `IccLineSyncService.syncFromIcc()`:读 `.env` 的 ICC_BASE_URL/ICC_TOKEN 占位配置,请求 ICC 线路列表(路径与字段映射集中在 `icc_line_mapper`),按 line_code 批量 upsert + 写 icc_sync_log + 失败捕获不抛业务错误
|
||||
- [ ] Task 4: 后端 Controller + RBAC:
|
||||
- [ ] SubTask 4.1: `electric_line.controller.ts`:GET /api/electric-lines(列表/详情)、POST /api/electric-lines/sync(调 IccLineSyncService)
|
||||
- [ ] SubTask 4.2: rbac.middleware.ts 追加 `/api/electric-lines → device:list`
|
||||
- [ ] SubTask 4.3: `.env` 增加 ICC_BASE_URL、ICC_TOKEN(占位);定时任务注册 `icc_line_sync.task`(@midwayjs/cron,默认每 10 分钟)
|
||||
- [ ] Task 5: 前端 API:`client/src/api/device.ts`(或新 `line.ts`)新增 `electricLineApi.list/get/sync`
|
||||
- [ ] Task 6: 前端页面:`views/device/line/index.vue`(搜索+同步按钮+表格+分页+状态 tag),路由 `/device/line`(permission device:list),菜单 id=61 挂设备监控(4) + role_menu(1,61),同步 db_seed.sql
|
||||
- [ ] Task 7: 验证:
|
||||
- [ ] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查
|
||||
- [ ] SubTask 7.2: 启动后端/前端,接口实测(无 token 401、有权限 200、无权限 403、sync 接口鉴权、ICC 拉取失败不报错)
|
||||
- [ ] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用
|
||||
|
||||
# Task Dependencies
|
||||
- Task 2/5 依赖 Task 1(表存在)
|
||||
- Task 3 依赖 Task 2
|
||||
- Task 4 依赖 Task 3
|
||||
- Task 6 依赖 Task 5
|
||||
- Task 7 依赖全部
|
||||
@@ -143,4 +143,63 @@ export const smartDeviceApi = {
|
||||
|
||||
delete: (id: number) =>
|
||||
request.delete(`/smart-devices/${id}`),
|
||||
};
|
||||
|
||||
// ==================== 智电改造线路台账 ====================
|
||||
|
||||
export interface ElectricLine {
|
||||
id: number;
|
||||
lineCode: string;
|
||||
equipId: number;
|
||||
iccDeviceCode?: string;
|
||||
lineName?: string;
|
||||
customerId: number;
|
||||
buildingId?: number;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
status: number;
|
||||
syncedAt?: string;
|
||||
createdAt: string;
|
||||
// 联表补全字段
|
||||
customerNo?: string;
|
||||
customerName?: string;
|
||||
equipCode?: string;
|
||||
equipName?: string;
|
||||
buildingName?: string;
|
||||
}
|
||||
|
||||
export interface ElectricLineQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
customerId?: number;
|
||||
equipId?: number;
|
||||
buildingId?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ElectricLineListResult {
|
||||
list: ElectricLine[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface IccLineSyncResult {
|
||||
pulled: number;
|
||||
synced: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export const electricLineApi = {
|
||||
getList: (params: ElectricLineQuery) =>
|
||||
request.get<ElectricLineListResult>('/electric-lines', { params }),
|
||||
|
||||
getDetail: (id: number) =>
|
||||
request.get<ElectricLine>(`/electric-lines/${id}`),
|
||||
|
||||
sync: () =>
|
||||
request.post<IccLineSyncResult>('/electric-lines/sync'),
|
||||
};
|
||||
@@ -186,6 +186,12 @@ const routes: RouteRecordRaw[] = [
|
||||
name: "DeviceEquipment",
|
||||
component: () => import("@/views/device/equipment/index.vue"),
|
||||
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
|
||||
},
|
||||
{
|
||||
path: "device/line",
|
||||
name: "DeviceLine",
|
||||
component: () => import("@/views/device/line/index.vue"),
|
||||
meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" }
|
||||
},
|
||||
{
|
||||
path: "base",
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
<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="线路名称 / 线路编码"
|
||||
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 === 'customerId'">
|
||||
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'equipId'">
|
||||
{{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'buildingId'">
|
||||
{{ record.buildingName || '-' }}
|
||||
</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: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||
{ title: '所属设备', dataIndex: 'equipId', key: 'equipId', ellipsis: true, width: 180 },
|
||||
{ title: '所属建筑', dataIndex: 'buildingId', key: 'buildingId', ellipsis: true, width: 140 },
|
||||
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
|
||||
{ title: '所在位置', dataIndex: 'location', key: 'location', ellipsis: true },
|
||||
{ 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>
|
||||
@@ -1122,7 +1122,7 @@ async function handleSubmit(draft: boolean) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx
|
||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
||||
const preCustomerId = Number(route.query.customerId || 0);
|
||||
if (preCustomerId && !isEdit.value) {
|
||||
formState.customerId = preCustomerId;
|
||||
|
||||
+33
-4
@@ -322,13 +322,34 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60
|
||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-61
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 58), (1, 59), (1, 60)
|
||||
(1, 58), (1, 59), (1, 60), (1, 61)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列(覆盖到 60)
|
||||
SELECT setval('sys_menu_id_seq', 60, true);
|
||||
-- 重置 sys_menu 自增序列(覆盖到 61)
|
||||
SELECT setval('sys_menu_id_seq', 61, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 2, 'device:list', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
menu_path = EXCLUDED.menu_path,
|
||||
component = EXCLUDED.component,
|
||||
icon = EXCLUDED.icon,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
menu_type = EXCLUDED.menu_type,
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 61
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 61)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
||||
@@ -375,6 +396,14 @@ INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config,
|
||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 10a. 智电线路同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
-- 智电改造线路台账(设备监控):
|
||||
-- 1) 幂等建表 electric_line / line_room / icc_sync_log(若 db_init.sql 已建则跳过)
|
||||
-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」
|
||||
-- 3) ADMIN 角色绑定新菜单
|
||||
-- 4) 注册 icc_line_sync 定时任务(默认每 10 分钟)
|
||||
BEGIN;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1) 幂等建表(与 db_init.sql 定义保持一致)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS electric_line (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
line_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
|
||||
icc_device_code VARCHAR(128),
|
||||
line_name VARCHAR(128),
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
building_id BIGINT REFERENCES customer_building(id),
|
||||
floor_no VARCHAR(16),
|
||||
location VARCHAR(256),
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 同步)';
|
||||
COMMENT ON COLUMN electric_line.id IS '自增主键';
|
||||
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
|
||||
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
|
||||
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
|
||||
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
|
||||
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
|
||||
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
|
||||
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
|
||||
COMMENT ON COLUMN electric_line.location IS '所在位置';
|
||||
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
|
||||
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
|
||||
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
|
||||
CREATE INDEX IF NOT EXISTS idx_line_customer ON electric_line (customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_line_equip ON electric_line (equip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_line_icc ON electric_line (icc_device_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS line_room (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
line_id BIGINT NOT NULL REFERENCES electric_line(id) ON DELETE CASCADE,
|
||||
room_id BIGINT NOT NULL REFERENCES customer_room(id),
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE line_room IS '线路所属房间关联表';
|
||||
COMMENT ON COLUMN line_room.id IS '自增主键';
|
||||
COMMENT ON COLUMN line_room.line_id IS '线路 ID(→ electric_line)';
|
||||
COMMENT ON COLUMN line_room.room_id IS '房间 ID(→ customer_room)';
|
||||
COMMENT ON COLUMN line_room.customer_id IS '客户 ID(→ customer)';
|
||||
COMMENT ON COLUMN line_room.created_at IS '创建时间';
|
||||
CREATE INDEX IF NOT EXISTS idx_lr_line ON line_room (line_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lr_room ON line_room (room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lr_customer ON line_room (customer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS icc_sync_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sync_type VARCHAR(32) NOT NULL,
|
||||
start_time TIMESTAMPTZ,
|
||||
end_time TIMESTAMPTZ,
|
||||
record_count INT,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE icc_sync_log IS 'ICC 数据同步日志表';
|
||||
COMMENT ON COLUMN icc_sync_log.id IS '自增主键';
|
||||
COMMENT ON COLUMN icc_sync_log.sync_type IS '同步类型:客户/设备/用电/报警';
|
||||
COMMENT ON COLUMN icc_sync_log.start_time IS '开始时间';
|
||||
COMMENT ON COLUMN icc_sync_log.end_time IS '结束时间';
|
||||
COMMENT ON COLUMN icc_sync_log.record_count IS '同步记录数';
|
||||
COMMENT ON COLUMN icc_sync_log.status IS '状态:1成功 0失败';
|
||||
COMMENT ON COLUMN icc_sync_log.error_message IS '错误信息';
|
||||
COMMENT ON COLUMN icc_sync_log.created_at IS '创建时间';
|
||||
CREATE INDEX IF NOT EXISTS idx_icc_sync_type ON icc_sync_log (sync_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_icc_sync_time ON icc_sync_log (created_at);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」(sort 2,排在设备管理台账之后)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 2, 'device:list', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
menu_path = EXCLUDED.menu_path,
|
||||
component = EXCLUDED.component,
|
||||
icon = EXCLUDED.icon,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
menu_type = EXCLUDED.menu_type,
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3) 角色-菜单绑定:ADMIN(role_id=1) 拥有 61
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 61)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4) 定时任务种子:icc_line_sync(默认每 10 分钟,可按 job_key 幂等)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- 重置序列(覆盖到 61)
|
||||
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 61), true);
|
||||
|
||||
COMMIT;
|
||||
@@ -22,3 +22,7 @@ RUSTFS_BUCKET=xintong
|
||||
REDIS_HOST=222.243.55.42
|
||||
REDIS_PORT=26678
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写)
|
||||
ICC_BASE_URL=
|
||||
ICC_TOKEN=
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { ElectricLineService } from "../service/electric_line.service";
|
||||
import { IccLineSyncService } from "../service/icc_line_sync.service";
|
||||
|
||||
@Controller("/api/electric-lines")
|
||||
export class ElectricLineController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() electricLineService!: ElectricLineService;
|
||||
@Inject() iccLineSyncService!: IccLineSyncService;
|
||||
|
||||
@Get("/") async list(@Query() query: any) { return this.electricLineService.list(query, this.ctx); }
|
||||
@Get("/:id") async detail(@Param("id") id: string) { return this.electricLineService.detail(Number(id), this.ctx); }
|
||||
@Post("/sync") async sync() {
|
||||
const result = await this.iccLineSyncService.syncFromIcc(this.ctx);
|
||||
return { code: 200, message: "线路同步完成", data: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
|
||||
|
||||
export class ElectricLineQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsInt() equipId?: number;
|
||||
@IsOptional() @IsInt() buildingId?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) status?: number;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import CustomerEquipment from "./customer_equipment.entity";
|
||||
import Customer from "./customer.entity";
|
||||
import CustomerBuilding from "./customer_building.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "electric_line",
|
||||
comment: "智电改造线路信息表(ICC 同步)",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_line_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_line_equip", fields: ["equip_id"] },
|
||||
{ name: "idx_line_icc", fields: ["icc_device_code"] }
|
||||
]
|
||||
})
|
||||
export default class ElectricLine extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "线路编码(唯一)" })
|
||||
lineCode!: string;
|
||||
|
||||
@ForeignKey(() => CustomerEquipment)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "equip_id", comment: "客户设备 ID(→ customer_equipment)" })
|
||||
equipId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "icc_device_code", comment: "ICC 智电设备编号" })
|
||||
iccDeviceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(ICC 同步)" })
|
||||
lineName!: string;
|
||||
|
||||
@ForeignKey(() => Customer)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
|
||||
customerId!: number;
|
||||
|
||||
@ForeignKey(() => CustomerBuilding)
|
||||
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" })
|
||||
buildingId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
|
||||
floorNo!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(256), comment: "所在位置" })
|
||||
location!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
|
||||
syncedAt!: Date;
|
||||
|
||||
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
|
||||
equipment!: CustomerEquipment;
|
||||
|
||||
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||
customer!: Customer;
|
||||
|
||||
@BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
|
||||
building!: CustomerBuilding;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "icc_sync_log",
|
||||
comment: "ICC 数据同步日志表",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_icc_sync_type", fields: ["sync_type"] },
|
||||
{ name: "idx_icc_sync_time", fields: ["created_at"] }
|
||||
]
|
||||
})
|
||||
export default class IccSyncLog extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(32), allowNull: false, field: "sync_type", comment: "同步类型:客户/设备/用电/报警" })
|
||||
syncType!: string;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "start_time", comment: "开始时间" })
|
||||
startTime!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "end_time", comment: "结束时间" })
|
||||
endTime!: Date;
|
||||
|
||||
@Column({ type: DataType.INTEGER, field: "record_count", comment: "同步记录数" })
|
||||
recordCount!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1成功 0失败" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "error_message", comment: "错误信息" })
|
||||
errorMessage!: string;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
"/api/customer-circuits": "customer:topology",
|
||||
"/api/customer-equipments": "device:list",
|
||||
"/api/smart-devices": "device:model",
|
||||
"/api/electric-lines": "device:list",
|
||||
"/api/survey-orders": "project:survey",
|
||||
"/api/project-plans": "project:plan",
|
||||
"/api/contracts": "project:contract",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||
import CustomerBuilding from "../entity/customer_building.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
|
||||
@Provide()
|
||||
export class ElectricLineService {
|
||||
@InjectRepository(ElectricLine)
|
||||
lineModel!: Repository<ElectricLine>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerEquipment)
|
||||
equipmentModel!: Repository<CustomerEquipment>;
|
||||
|
||||
@InjectRepository(CustomerBuilding)
|
||||
buildingModel!: Repository<CustomerBuilding>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
const userId = ctx.state.user.userId;
|
||||
const where: any = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ lineName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ lineCode: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
|
||||
if (query.buildingId !== undefined && query.buildingId !== "") where.buildingId = Number(query.buildingId);
|
||||
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
|
||||
|
||||
// 非管理员按客户归属自动限定数据范围
|
||||
if (!(await this.isAdmin(userId))) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) {
|
||||
return { code: 200, message: "获取线路台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(where.customerId)) {
|
||||
return { code: 200, message: "获取线路台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await this.lineModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["created_at", "DESC"], ["id", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
const list = await this.decorate(rows);
|
||||
return { code: 200, message: "获取线路台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
|
||||
async detail(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.lineModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "线路不存在", data: null };
|
||||
|
||||
if (!(await this.isAdmin(ctx.state.user.userId))) {
|
||||
const customer = await this.customerModel.findByPk(record.customerId);
|
||||
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
|
||||
return { code: 403, message: "无权限访问该线路", data: null };
|
||||
}
|
||||
}
|
||||
|
||||
const decorated = await this.decorate([record]);
|
||||
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
|
||||
}
|
||||
|
||||
/** 补全客户/设备/建筑展示信息,避免 N+1。 */
|
||||
private async decorate(rows: ElectricLine[]): Promise<any[]> {
|
||||
if (rows.length === 0) return rows as any;
|
||||
|
||||
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
|
||||
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
|
||||
const buildingIds = [...new Set(rows.map((r) => r.buildingId).filter(Boolean))];
|
||||
|
||||
const [customers, equips, buildings] = await Promise.all([
|
||||
customerIds.length
|
||||
? this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } },
|
||||
attributes: ["id", "customerNo", "customerName"]
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
equipIds.length
|
||||
? this.equipmentModel.findAll({
|
||||
where: { id: { [Op.in]: equipIds } },
|
||||
attributes: ["id", "equipCode", "equipName"]
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
buildingIds.length
|
||||
? this.buildingModel.findAll({
|
||||
where: { id: { [Op.in]: buildingIds } },
|
||||
attributes: ["id", "buildingName"]
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
||||
const equipMap = new Map(equips.map((e) => [e.id, e]));
|
||||
const buildingMap = new Map(buildings.map((b) => [b.id, b]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const json: any = row.toJSON();
|
||||
const customer: any = customerMap.get(row.customerId);
|
||||
const equip: any = equipMap.get(row.equipId);
|
||||
const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined;
|
||||
json.customerNo = customer?.customerNo || "";
|
||||
json.customerName = customer?.customerName || "";
|
||||
json.equipCode = equip?.equipCode || "";
|
||||
json.equipName = equip?.equipName || "";
|
||||
json.buildingName = building?.buildingName || "";
|
||||
return json;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import IccSyncLog from "../entity/icc_sync_log.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||
import CustomerBuilding from "../entity/customer_building.entity";
|
||||
import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper";
|
||||
|
||||
export interface IccLineSyncResult {
|
||||
/** ICC 拉取到的原始条数 */
|
||||
pulled: number;
|
||||
/** 解析落库的线路条数(成功 upsert,含新增/更新) */
|
||||
synced: number;
|
||||
/** 新增的线路条数 */
|
||||
inserted: number;
|
||||
/** 更新的线路条数 */
|
||||
updated: number;
|
||||
/** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const SYNC_TYPE = "electric_line";
|
||||
|
||||
/**
|
||||
* ICC 智电改造线路拉取同步:
|
||||
* 1) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置
|
||||
* 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper)
|
||||
* 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at
|
||||
* 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误
|
||||
*/
|
||||
@Provide()
|
||||
export class IccLineSyncService {
|
||||
@InjectRepository(ElectricLine)
|
||||
lineModel!: Repository<ElectricLine>;
|
||||
|
||||
@InjectRepository(IccSyncLog)
|
||||
syncLogModel!: Repository<IccSyncLog>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerEquipment)
|
||||
equipmentModel!: Repository<CustomerEquipment>;
|
||||
|
||||
@InjectRepository(CustomerBuilding)
|
||||
buildingModel!: Repository<CustomerBuilding>;
|
||||
|
||||
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
|
||||
const startTime = new Date();
|
||||
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||
const token = process.env.ICC_TOKEN?.trim();
|
||||
|
||||
const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
|
||||
|
||||
// 占位配置缺失视为拉取失败:记录日志,不抛业务错误
|
||||
if (!baseUrl) {
|
||||
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const payload: any = await res.json();
|
||||
// 兼容缓存工具包裹(data/list 字段)返回格式
|
||||
const rawList: IccLineRawRecord[] = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray(payload.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload.list)
|
||||
? payload.list
|
||||
: [];
|
||||
result.pulled = rawList.length;
|
||||
|
||||
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
|
||||
result.inserted = inserted;
|
||||
result.updated = updated;
|
||||
result.synced = inserted + updated;
|
||||
result.skipped = skipped;
|
||||
|
||||
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
const message = err?.code === "ERR_MODULE_NOT_FOUND" || err?.message
|
||||
? String(err?.message || err)
|
||||
: String(err);
|
||||
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */
|
||||
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (records.length === 0) return { inserted, updated, skipped };
|
||||
|
||||
// 预取外键映射,避免 N+1
|
||||
const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))];
|
||||
const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))];
|
||||
const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))];
|
||||
|
||||
const [equips, customers, buildings] = await Promise.all([
|
||||
equipCodes.length
|
||||
? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] })
|
||||
: Promise.resolve([]),
|
||||
customerNos.length
|
||||
? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] })
|
||||
: Promise.resolve([]),
|
||||
buildingNames.length
|
||||
? this.buildingModel.findAll({ where: { buildingName: { [Op.in]: buildingNames } }, attributes: ["id", "buildingName", "customerId"] })
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const equipByCode = new Map(equips.map((e) => [e.equipCode, e]));
|
||||
const customerByNo = new Map(customers.map((c) => [c.customerNo, c]));
|
||||
const buildingByName = new Map(buildings.map((b) => [b.buildingName, b]));
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const raw of records) {
|
||||
const mapped = mapIccLineRecord(raw);
|
||||
if (!mapped.lineCode) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined;
|
||||
// equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行
|
||||
if (!equip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let customerId: number = equip.customerId;
|
||||
if (mapped.customerNo) {
|
||||
const byNo = customerByNo.get(mapped.customerNo);
|
||||
if (byNo) customerId = byNo.id;
|
||||
}
|
||||
|
||||
let buildingId: number | null = equip.buildingId || null;
|
||||
if (mapped.buildingName) {
|
||||
const building = buildingByName.get(mapped.buildingName);
|
||||
if (building) buildingId = building.id;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
lineCode: mapped.lineCode,
|
||||
equipId: equip.id,
|
||||
customerId,
|
||||
buildingId,
|
||||
iccDeviceCode: mapped.iccDeviceCode || null,
|
||||
lineName: mapped.lineName || null,
|
||||
floorNo: mapped.floorNo || null,
|
||||
location: mapped.location || null,
|
||||
status: mapped.status,
|
||||
syncedAt: now
|
||||
} as any;
|
||||
|
||||
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
|
||||
if (existing) {
|
||||
await existing.update(payload);
|
||||
updated++;
|
||||
} else {
|
||||
await this.lineModel.create(payload);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, updated, skipped };
|
||||
}
|
||||
|
||||
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
|
||||
try {
|
||||
await this.syncLogModel.create({
|
||||
syncType,
|
||||
startTime,
|
||||
endTime: new Date(),
|
||||
recordCount,
|
||||
status,
|
||||
errorMessage
|
||||
} as any);
|
||||
} catch (err) {
|
||||
console.error("[ICC同步] 同步日志写入失败:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import SysJob from "../entity/sys_job.entity";
|
||||
import SysJobLog from "../entity/sys_job_log.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
import { IccLineSyncService } from "./icc_line_sync.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -445,6 +446,12 @@ export class JobSchedulerService {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
|
||||
return noticeRuleService.executeScan();
|
||||
},
|
||||
syncIccLines: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
|
||||
const result = await iccService.syncFromIcc();
|
||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)
|
||||
*
|
||||
* ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射,
|
||||
* 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。
|
||||
*/
|
||||
export interface IccLineRawRecord {
|
||||
[field: string]: any;
|
||||
}
|
||||
|
||||
/** 原始记录关键字段名(ICC 平台约定,占位) */
|
||||
export const ICC_LINE_FIELDS = {
|
||||
lineCode: "lineCode", // ICC 线路编码(唯一键)
|
||||
iccDeviceCode: "deviceCode", // ICC 智电设备编号
|
||||
equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id)
|
||||
lineName: "lineName", // 线路名称
|
||||
customerNo: "customerNo", // 关联客户编号(用于反查 customer_id)
|
||||
buildingName: "buildingName", // 建筑名称(占位,反查 building_id)
|
||||
floorNo: "floorNo", // 所在楼层
|
||||
location: "location", // 所在位置
|
||||
status: "status" // 1在线 0离线
|
||||
} as const;
|
||||
|
||||
/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */
|
||||
export const ICC_LINE_ENDPOINT = "/api/grid/lines";
|
||||
|
||||
/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
|
||||
export function mapIccLineRecord(raw: IccLineRawRecord): {
|
||||
lineCode: string;
|
||||
iccDeviceCode: string;
|
||||
equipCode?: string;
|
||||
lineName?: string;
|
||||
customerNo?: string;
|
||||
buildingName?: string;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
status: number;
|
||||
} {
|
||||
const f = ICC_LINE_FIELDS;
|
||||
return {
|
||||
lineCode: String(raw[f.lineCode] ?? "").trim(),
|
||||
iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").trim(),
|
||||
equipCode: raw[f.equipCode] ? String(raw[f.equipCode]).trim() : undefined,
|
||||
lineName: raw[f.lineName] ? String(raw[f.lineName]).trim() : undefined,
|
||||
customerNo: raw[f.customerNo] ? String(raw[f.customerNo]).trim() : undefined,
|
||||
buildingName: raw[f.buildingName] ? String(raw[f.buildingName]).trim() : undefined,
|
||||
floorNo: raw[f.floorNo] ? String(raw[f.floorNo]).trim() : undefined,
|
||||
location: raw[f.location] ? String(raw[f.location]).trim() : undefined,
|
||||
status: Number(raw[f.status]) === 0 ? 0 : 1
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* 智电线路同步任务:从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,
|
||||
* 写 icc_sync_log。默认每 10 分钟执行一次。
|
||||
*/
|
||||
export const iccLineSyncJob: BuiltinJob = {
|
||||
jobKey: "icc_line_sync",
|
||||
jobName: "智电线路同步",
|
||||
description: "从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(写 icc_sync_log)",
|
||||
defaultCron: "*/10 * * * *",
|
||||
defaultParams: {},
|
||||
paramFields: [],
|
||||
handler: async (_params, ctx) => {
|
||||
return ctx.runtime.syncIccLines();
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { demoHeartbeatJob } from "./demo_heartbeat.task";
|
||||
import { logCleanJob } from "./log_clean.task";
|
||||
import { noticeScanJob } from "./notice_scan.task";
|
||||
import { iccLineSyncJob } from "./icc_line_sync.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
@@ -29,6 +30,8 @@ export interface JobRuntime {
|
||||
cleanJobLogs(keepDays: number): Promise<number>;
|
||||
/** 执行全部启用的扫描式通知规则,返回本次执行摘要 */
|
||||
runNoticeRules(): Promise<string>;
|
||||
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
|
||||
syncIccLines(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
@@ -55,7 +58,7 @@ export interface BuiltinJob {
|
||||
handler: JobHandler;
|
||||
}
|
||||
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob];
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob];
|
||||
|
||||
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||
|
||||
Reference in new issue
Block a user