Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Q
qingdaoMuseumPlatform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
node_server
qingdaoMuseumPlatform
Commits
6350c715
Commit
6350c715
authored
Jul 15, 2026
by
PC-20251223ZVQQ\Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
运行分析展示数据问题修复、日程管理定时开关机任务创建和环境质量弹窗接口
parent
e110cac7
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
628 additions
and
11 deletions
+628
-11
device.ts
src/biz/device.ts
+131
-1
region.ts
src/biz/region.ts
+50
-0
report.ts
src/biz/report.ts
+2
-2
running.ts
src/biz/running.ts
+7
-6
schedule.ts
src/biz/schedule.ts
+1
-0
scheduleExecutor.ts
src/biz/scheduleExecutor.ts
+409
-0
main.ts
src/main.ts
+6
-0
admin.ts
src/routers/admin.ts
+7
-0
device.ts
src/routers/device.ts
+15
-2
No files found.
src/biz/device.ts
View file @
6350c715
...
...
@@ -418,7 +418,8 @@ export async function getEnvironmentDevices() {
}
const
regionResult
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
regionKeys
}
id
:
{
"%in%"
:
regionKeys
},
"%orderAsc%"
:
"sort_order"
,
},
[
"id"
,
"name"
,
"type"
,
"groups"
]);
const
regions
=
regionResult
.
data
||
[];
...
...
@@ -569,6 +570,7 @@ export async function getEnvironmentData(regionGroup: string, regionType: string
if
(
deviceRegionKeys
.
length
>
0
)
{
const
regionResult
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
deviceRegionKeys
},
"%orderAsc%"
:
"sort_order"
,
},
[
"id"
,
"name"
,
"type"
,
"groups"
]);
(
regionResult
.
data
||
[]).
forEach
((
r
:
any
)
=>
{
regionNameMap
[
r
.
id
]
=
r
.
name
||
''
;
...
...
@@ -622,4 +624,131 @@ export async function getEnvironmentData(regionGroup: string, regionType: string
};
return
{
indicators
,
records
,
areaInfo
};
}
/**
* CO2 线性超标描述(仅一般/差级别调用)
* 偏高:1000~1500 超标:1500~2500 严重超标:>2500
*/
function
getCo2ExceedLabel
(
co2
:
number
):
string
|
null
{
if
(
co2
>
2500
)
return
'二氧化碳严重超标'
;
if
(
co2
>
1500
)
return
'二氧化碳超标'
;
if
(
co2
>=
1000
)
return
'二氧化碳偏高'
;
return
null
;
}
/**
* PM2.5 线性超标描述(仅一般/差级别调用)
* 偏高:50~75 超标:75~95 严重超标:>95
*/
function
getPm25ExceedLabel
(
pm25
:
number
):
string
|
null
{
if
(
pm25
>
95
)
return
'PM2.5严重超标'
;
if
(
pm25
>
75
)
return
'PM2.5超标'
;
if
(
pm25
>
50
)
return
'PM2.5偏高'
;
return
null
;
}
/**
* 运行分析 - 环境质量弹窗
* 按区域查询环境监测设备最新数据,返回超标区域(一般/差)。
* 质量等级与 todayEnvironmental 一致,优秀/良好不返回。
*/
export
async
function
getEnvQualityPop
(
regionGroup
?:
string
,
regionType
?:
string
)
{
// 1. 查询区域
const
regionWhere
:
any
=
{};
if
(
regionGroup
)
regionWhere
.
groups
=
regionGroup
;
if
(
regionType
)
regionWhere
.
type
=
regionType
;
const
regionResult
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
...
regionWhere
,
"%orderAsc%"
:
"sort_order"
},
[
"id"
,
"name"
,
"type"
,
"groups"
]);
const
regions
=
regionResult
.
data
||
[];
if
(
regions
.
length
===
0
)
return
{
total
:
0
,
dataList
:
[]
};
const
regionMap
:
{
[
id
:
number
]:
any
}
=
{};
regions
.
forEach
((
r
:
any
)
=>
{
regionMap
[
r
.
id
]
=
r
;
});
const
regionKeys
=
regions
.
map
((
r
:
any
)
=>
r
.
id
);
// 2. 查询环境监测设备
const
deviceResult
=
await
selectDataListByParam
(
TABLENAME
.
设备表
,
{
region_key
:
{
"%in%"
:
regionKeys
},
device_type
:
"环境监测"
},
[
"device_id"
,
"region_key"
]);
const
devices
=
deviceResult
.
data
||
[];
// 3. 按 region_key 分组
const
regionDevices
:
{
[
rk
:
number
]:
string
[]
}
=
{};
devices
.
forEach
((
d
:
any
)
=>
{
if
(
!
regionDevices
[
d
.
region_key
])
regionDevices
[
d
.
region_key
]
=
[];
regionDevices
[
d
.
region_key
].
push
(
d
.
device_id
);
});
const
dataList
:
any
[]
=
[];
const
todayStart
=
getTodayStart
();
// 4. 并行处理有设备的区域
await
Promise
.
all
(
Object
.
keys
(
regionDevices
).
map
(
async
(
rkStr
)
=>
{
const
rk
=
Number
(
rkStr
);
const
deviceIds
=
regionDevices
[
rk
];
const
region
=
regionMap
[
rk
];
if
(
!
region
||
deviceIds
.
length
===
0
)
return
;
// 仅查询今天的数据,按 device_time 降序,每个设备最多取一条
const
dataResult
=
await
selectDataListByParam
(
TABLENAME
.
设备数据表
,
{
device_id
:
{
"%in%"
:
deviceIds
},
device_time
:
{
"%gte%"
:
todayStart
},
"%orderDesc%"
:
"device_time"
,
"%limit%"
:
deviceIds
.
length
},
[
"device_id"
,
"data"
]);
const
records
=
dataResult
.
data
||
[];
if
(
records
.
length
===
0
)
return
;
// 每个设备取最新一条,算均值
const
deviceLatest
:
{
[
devId
:
string
]:
any
}
=
{};
records
.
forEach
((
r
:
any
)
=>
{
if
(
!
deviceLatest
[
r
.
device_id
])
deviceLatest
[
r
.
device_id
]
=
r
.
data
;
});
const
dataArr
=
Object
.
values
(
deviceLatest
);
const
co2Avg
=
Math
.
round
(
dataArr
.
reduce
((
s
,
d
:
any
)
=>
s
+
(
d
.
co2
??
0
),
0
)
/
dataArr
.
length
);
const
pm25Avg
=
Math
.
round
(
dataArr
.
reduce
((
s
,
d
:
any
)
=>
s
+
(
d
.
pm2_5
??
0
),
0
)
/
dataArr
.
length
);
// 质量等级判定(与 todayEnvironmental 一致)
let
quality
:
string
;
if
(
co2Avg
<
800
&&
pm25Avg
<=
35
)
{
quality
=
'优秀'
;
}
else
if
(
co2Avg
<
1000
&&
pm25Avg
<=
45
)
{
quality
=
'优秀'
;
}
else
if
(
co2Avg
<=
1000
&&
pm25Avg
<=
50
)
{
quality
=
'良好'
;
}
else
if
(
co2Avg
>
1000
&&
co2Avg
<
1500
&&
pm25Avg
>
50
&&
pm25Avg
<
75
)
{
quality
=
'一般'
;
}
else
{
quality
=
'差'
;
}
// 仅返回一般/差
if
(
quality
===
'优秀'
||
quality
===
'良好'
)
return
;
// 构建超标内容
const
exceedItems
:
string
[]
=
[];
const
co2Label
=
getCo2ExceedLabel
(
co2Avg
);
const
pm25Label
=
getPm25ExceedLabel
(
pm25Avg
);
if
(
co2Label
)
exceedItems
.
push
(
co2Label
);
if
(
pm25Label
)
exceedItems
.
push
(
pm25Label
);
const
qualityContent
=
exceedItems
.
length
>
0
?
`环境质量
${
quality
}
:
${
exceedItems
.
join
(
'、'
)}
`
:
`环境质量
${
quality
}
`
;
dataList
.
push
({
indexs
:
0
,
regionName
:
[
region
.
groups
,
region
.
type
,
region
.
name
].
filter
(
Boolean
).
join
(
'_'
),
co2
:
co2Avg
,
pm25
:
pm25Avg
,
qualityContent
});
}));
// 5. 排序并分配序号
dataList
.
sort
((
a
,
b
)
=>
a
.
regionName
.
localeCompare
(
b
.
regionName
,
'zh'
));
dataList
.
forEach
((
item
,
i
)
=>
{
item
.
indexs
=
i
+
1
;
});
return
{
total
:
dataList
.
length
,
dataList
};
}
\ No newline at end of file
src/biz/region.ts
View file @
6350c715
import
{
selectDataListByParam
}
from
"../data/findData"
;
import
{
TABLENAME
}
from
"../config/dbEnum"
;
import
{
updateManyData
}
from
"../data/updateData"
;
/** 楼栋排序基础值(间隔100,便于中间插入) */
const
BUILDING_BASE
:
Record
<
string
,
number
>
=
{
'A馆'
:
100
,
'B馆'
:
200
,
'C馆'
:
300
,
};
/** 楼层排序偏移值 */
const
FLOOR_OFFSET
:
Record
<
string
,
number
>
=
{
'一层'
:
1
,
'二层'
:
2
,
'三层'
:
3
,
'四层'
:
4
,
};
/**
* 根据楼栋和楼层计算 sort_order 值
* 排序规则:A馆 > B馆 > C馆 > 其它;一层 > 二层 > 三层 > 四层 > 其它
*/
export
function
computeSortOrder
(
groups
:
string
,
type
:
string
):
number
{
const
buildingBase
=
BUILDING_BASE
[
groups
]
??
900
;
const
floorOffset
=
FLOOR_OFFSET
[
type
]
??
99
;
return
buildingBase
+
floorOffset
;
}
/**
* 刷新所有区域的 sort_order(系统启动时执行)
* 遍历所有区域,根据 groups + type 重新计算并更新 sort_order
*/
export
async
function
refreshRegionSortOrder
():
Promise
<
void
>
{
const
regionList
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{},
[
"id"
,
"groups"
,
"type"
,
"sort_order"
],
);
const
regions
=
regionList
.
data
||
[];
let
updatedCount
=
0
;
for
(
const
r
of
regions
)
{
const
newVal
=
computeSortOrder
(
r
.
groups
,
r
.
type
);
if
(
r
.
sort_order
!==
newVal
)
{
await
updateManyData
(
TABLENAME
.
区域表
,
{
id
:
r
.
id
},
{
sort_order
:
newVal
});
updatedCount
++
;
}
}
console
.
log
(
`sort_order 刷新完成,共更新
${
updatedCount
}
条区域记录`
);
}
/**
* 获取楼栋区域列表(供前端下拉选择)
...
...
src/biz/report.ts
View file @
6350c715
...
...
@@ -24,7 +24,7 @@ function formatLocalTime(date: Date): string {
/** 并行查询三馆区域,按 groups 分组 */
async
function
queryHallRegions
():
Promise
<
Map
<
string
,
number
[]
>>
{
const
results
=
await
Promise
.
all
(
HALL_NAMES
.
map
(
g
=>
selectDataListByParam
(
TABLENAME
.
区域表
,
{
groups
:
g
},
[
"id"
]))
HALL_NAMES
.
map
(
g
=>
selectDataListByParam
(
TABLENAME
.
区域表
,
{
groups
:
g
,
"%orderAsc%"
:
"sort_order"
},
[
"id"
]))
);
const
hallRegionMap
=
new
Map
<
string
,
number
[]
>
();
for
(
let
i
=
0
;
i
<
HALL_NAMES
.
length
;
i
++
)
{
...
...
@@ -112,7 +112,7 @@ async function queryHallDeviceGroup(regionIds: number[]): Promise<HallDeviceGrou
// 并行查询该馆设备和区域名称
const
[
allDevRes
,
regionRes
]
=
await
Promise
.
all
([
selectDataListByParam
(
TABLENAME
.
设备表
,
{
region_key
:
{
"%in%"
:
regionIds
}
},
[
"device_id"
,
"device_name"
,
"device_type"
,
"region_key"
]),
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
regionIds
}
},
[
"id"
,
"name"
]),
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
regionIds
}
,
"%orderAsc%"
:
"sort_order"
},
[
"id"
,
"name"
]),
]);
const
nameMap
=
new
Map
<
number
,
string
>
();
...
...
src/biz/running.ts
View file @
6350c715
...
...
@@ -43,7 +43,7 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let
resultAny
:
any
=
{
};
// 根据区域类型和名称筛选设备ID列表(如果提供了区域信息)
let
regionParam
:
any
=
{
groups
:
regionGroup
};
let
regionParam
:
any
=
{
groups
:
regionGroup
,
"%orderAsc%"
:
"sort_order"
};
if
(
regionType
)
{
// regionParam.type = regionTypeKeyMap[regionType];
regionParam
.
type
=
regionType
;
...
...
@@ -381,7 +381,7 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
}
if
(
regionKeySet
.
size
>
0
)
{
const
regionsRes
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
[...
regionKeySet
]
}
},
{
id
:
{
"%in%"
:
[...
regionKeySet
]
}
,
"%orderAsc%"
:
"sort_order"
},
[
"id"
,
"name"
]);
const
regionNameMap
=
new
Map
<
string
,
string
>
();
for
(
const
r
of
regionsRes
.
data
)
{
...
...
@@ -422,10 +422,10 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let
regionInfos
:
any
=
[];
if
(
regionKeys
.
length
===
0
)
{
// 6.1.1 若没有选择区域,则查询区域表获取所有区域类型
regionInfos
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{},
[
"id"
,
"room_id"
,
"name"
,
"type"
,
"groups"
]);
regionInfos
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
"%orderAsc%"
:
"sort_order"
},
[
"id"
,
"room_id"
,
"name"
,
"type"
,
"groups"
]);
}
else
{
// 6.1.2 若选择了区域,则查询选择的区域
regionInfos
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
regionKeys
}
},
[
"id"
,
"room_id"
,
"name"
,
"type"
,
"groups"
]);
regionInfos
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
regionKeys
}
,
"%orderAsc%"
:
"sort_order"
},
[
"id"
,
"room_id"
,
"name"
,
"type"
,
"groups"
]);
}
// 6.2 循环区域查询客流传感器设备,获取过去24小时每个区域的客流量
...
...
@@ -472,6 +472,7 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
if
(
!
regionCustomerMap
.
has
(
regionName
))
{
regionCustomerMap
.
set
(
regionName
,
netVisitor
);
}
else
{
totalNum
=
regionCustomerMap
.
get
(
regionName
);
regionCustomerMap
.
set
(
regionName
,
totalNum
+
netVisitor
);
}
}
...
...
@@ -487,7 +488,7 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
// 计算每个区域的饱和度(客流量/区域最大承载量),并转换成表格
for
(
let
[
regionName
,
customerNum
]
of
regionCustomerMap
.
entries
())
{
let
regionMaxCustomer
=
regionMaxCustomerMap
.
get
(
regionName
)
||
1000
;
// 默认最大承载量为1000
let
saturation
=
`
${((
customerNum
/
regionMaxCustomer
)
*
100
).
toFixed
(
2
)}
%`
;
let
saturation
=
`
${((
customerNum
/
regionMaxCustomer
)
*
100
)
>
100
?
100
:((
customerNum
/
regionMaxCustomer
)
*
100
)
.
toFixed
(
2
)}
%`
;
regionCustomerTable
.
push
({
regionName
,
customerNum
,
saturation
});
todayCustomer
+=
customerNum
;
}
...
...
@@ -684,7 +685,7 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
// 运行监控总数据(一天)
resultAny
.
mainRunningManagement
=
{
totalCustomerNumber
:
totalRegionCustomerNum
,
totalDeviceOnlineRate
:
(
acOnlineCount
/
acCount
*
100
).
toFixed
(
1
),
totalDeviceOnlineRate
:
(
acOnlineCount
/
acCount
*
100
)
>=
100
?
'100'
:(
acOnlineCount
/
acCount
*
100
)
.
toFixed
(
1
),
// totalElectricity: last24hRunningEnergy.toFixed(0).slice(0, 5),
totalElectricity
:
last24hRunningEnergy
.
toFixed
(
0
),
};
...
...
src/biz/schedule.ts
View file @
6350c715
...
...
@@ -102,6 +102,7 @@ export async function getScheduleList(params: {
if
(
regionKeySet
.
size
>
0
)
{
const
regionRes
=
await
selectDataListByParam
(
TABLENAME
.
区域表
,
{
id
:
{
"%in%"
:
[...
regionKeySet
]
},
"%orderAsc%"
:
"sort_order"
,
},
[
"id"
,
"name"
]);
for
(
const
r
of
(
regionRes
.
data
||
[]))
{
regionNameMap
[
r
.
id
]
=
r
.
name
;
...
...
src/biz/scheduleExecutor.ts
0 → 100644
View file @
6350c715
/**
* 日程执行器
* 项目启动时加载有效期内的日程,通过关联设备表获取执行设备,
* 按 schedule_time 每天定时触发,调用飞奕 controlIndoorUnit API 控制空调设备。
*
* 特性:
* - 启动时加载所有有效日程并设置 setTimeout
* - 执行失败时自动重试(最多 3 次,间隔 30s/60s/120s)
* - 执行后自动安排下一次(第二天同一时间)
* - 每天凌晨 00:05 自动重载,处理新增/过期日程
* - 增删改日程后可通过 reloadScheduleTasks() 立即生效
* - 设备为空则跳过执行(不安排定时器)
*/
import
{
selectDataListByParam
}
from
"../data/findData"
;
import
{
addData
}
from
"../data/addData"
;
import
{
TABLENAME
}
from
"../config/dbEnum"
;
import
{
controlIndoorUnit
}
from
"./feiyiClient"
;
// ======================= 常量 =======================
/** 重试配置 */
const
RETRY_CONFIG
=
{
maxRetries
:
3
,
delays
:
[
30
_000
,
60
_000
,
120
_000
],
// 30s, 60s, 120s
};
/**
* 空调参数中文到 API 数字的映射
* 与 running.ts 中的 acDeviceKeyMap 保持一致
*/
const
AC_KEY_MAP
:
Record
<
string
,
number
>
=
{
// 模式
"制冷"
:
1
,
"制热"
:
2
,
"送风"
:
3
,
"除湿"
:
4
,
// 开关
"on"
:
1
,
"off"
:
0
,
// 风速
"超强"
:
1
,
"高风"
:
1
,
"强"
:
2
,
"中风"
:
2
,
"弱"
:
4
,
"低风"
:
4
,
// 锁定
"解除"
:
1
,
"生效"
:
0
,
};
// ======================= 数据结构 =======================
/** 活跃定时器记录 */
interface
ActiveTimer
{
scheduleId
:
number
;
timer
:
NodeJS
.
Timeout
;
title
:
string
;
}
/** 所有活跃的定时器 Map<scheduleId, ActiveTimer> */
const
activeTimers
:
Map
<
number
,
ActiveTimer
>
=
new
Map
();
/** 每日重载定时器 */
let
dailyReloadTimer
:
NodeJS
.
Timeout
|
null
=
null
;
// ======================= 工具函数 =======================
/** 格式化为本地时间字符串 YYYY-MM-DD HH:mm:ss */
function
formatLocalTime
(
d
:
Date
):
string
{
const
pad
=
(
n
:
number
)
=>
String
(
n
).
padStart
(
2
,
"0"
);
return
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
-
${
pad
(
d
.
getDate
())}
${
pad
(
d
.
getHours
())}
:
${
pad
(
d
.
getMinutes
())}
:
${
pad
(
d
.
getSeconds
())}
`
;
}
/**
* 根据 schedule_time(HH:mm:ss)计算下一次执行时间
* 如果今天的时间已过,安排到明天
*/
function
calcNextExecutionTime
(
timeStr
:
string
):
Date
{
const
now
=
new
Date
();
const
parts
=
timeStr
.
split
(
":"
).
map
(
Number
);
const
next
=
new
Date
(
now
);
next
.
setHours
(
parts
[
0
]
||
0
,
parts
[
1
]
||
0
,
parts
[
2
]
||
0
,
0
);
if
(
next
.
getTime
()
<=
now
.
getTime
())
{
next
.
setDate
(
next
.
getDate
()
+
1
);
}
return
next
;
}
/** 判断日程是否在有效期内 */
function
isInValidity
(
begin
:
string
|
null
|
undefined
,
end
:
string
|
null
|
undefined
):
boolean
{
const
now
=
new
Date
();
if
(
begin
&&
new
Date
(
begin
)
>
now
)
return
false
;
if
(
end
&&
new
Date
(
end
)
<
now
)
return
false
;
return
true
;
}
/** 判断日程是否已过期(end_validity 已过,不应再排下次) */
function
isExpired
(
end
:
string
|
null
|
undefined
):
boolean
{
if
(
!
end
)
return
false
;
return
new
Date
(
end
)
<
new
Date
();
}
// ======================= 核心逻辑 =======================
/**
* 构建 controlIndoorUnit 的请求参数
*/
function
buildControlParams
(
deviceIds
:
string
[],
acControl
:
string
,
todoItem
:
Record
<
string
,
string
>
|
null
|
undefined
):
Record
<
string
,
any
>
{
const
params
:
Record
<
string
,
any
>
=
{
indoorUnitAddressFull
:
deviceIds
,
onOff
:
AC_KEY_MAP
[
acControl
]
??
1
,
openApiAction
:
"control"
,
};
// 关机只发送 onOff=0,不传模式/温度/风速
if
(
acControl
===
"off"
)
{
return
params
;
}
if
(
todoItem
)
{
if
(
todoItem
[
"温度"
]
!=
null
)
{
params
.
tempSet
=
Number
(
todoItem
[
"温度"
]);
}
if
(
todoItem
[
"模式"
]
&&
AC_KEY_MAP
[
todoItem
[
"模式"
]]
!=
null
)
{
params
.
workMode
=
AC_KEY_MAP
[
todoItem
[
"模式"
]];
}
if
(
todoItem
[
"风速"
]
&&
AC_KEY_MAP
[
todoItem
[
"风速"
]]
!=
null
)
{
params
.
fanSpeed
=
AC_KEY_MAP
[
todoItem
[
"风速"
]];
}
}
return
params
;
}
/**
* 执行单次设备控制 + 写入设备日志
* @returns 执行结果 { success, message }
*/
async
function
executeControl
(
scheduleId
:
number
,
title
:
string
,
deviceIds
:
string
[],
acControl
:
string
,
todoItem
:
Record
<
string
,
string
>
|
null
|
undefined
):
Promise
<
{
success
:
boolean
;
message
:
string
}
>
{
console
.
log
(
`[日程执行器] 执行日程 #
${
scheduleId
}
"
${
title
}
", 设备:
${
deviceIds
.
join
(
","
)}
, 操作:
${
acControl
}
`
);
// 设备为空直接跳过
if
(
deviceIds
.
length
===
0
)
{
console
.
log
(
`[日程执行器] 日程 #
${
scheduleId
}
没有关联设备,跳过执行`
);
return
{
success
:
true
,
message
:
"无关联设备,跳过"
};
}
const
params
=
buildControlParams
(
deviceIds
,
acControl
,
todoItem
);
const
now
=
new
Date
();
// 调用第三方 API
let
result
:
{
success
:
boolean
;
message
:
string
};
try
{
const
res
=
await
controlIndoorUnit
(
params
);
result
=
{
success
:
res
.
success
,
message
:
res
.
data
??
""
};
}
catch
(
err
:
any
)
{
result
=
{
success
:
false
,
message
:
err
.
message
||
"控制异常"
};
}
// 批量写入设备日志
const
logs
=
deviceIds
.
map
((
deviceId
)
=>
({
device_id
:
deviceId
,
device_type
:
"空调"
,
operation_content
:
{
option
:
`日程定时执行:
${
title
}
`
,
scheduleId
,
time
:
formatLocalTime
(
now
),
acControl
,
params
:
{
...
params
,
indoorUnitAddressFull
:
undefined
},
success
:
result
.
success
,
message
:
result
.
message
,
},
status
:
result
.
success
?
1
:
0
,
created_at
:
now
,
updated_at
:
now
,
}));
try
{
await
addData
(
TABLENAME
.
设备日志表
,
logs
);
}
catch
(
err
)
{
console
.
error
(
`[日程执行器] 写入设备日志失败:`
,
err
);
}
if
(
result
.
success
)
{
console
.
log
(
`[日程执行器] 日程 #
${
scheduleId
}
"
${
title
}
" 执行成功`
);
}
else
{
console
.
error
(
`[日程执行器] 日程 #
${
scheduleId
}
"
${
title
}
" 执行失败:
${
result
.
message
}
`
);
}
return
result
;
}
/**
* 带重试的执行逻辑
* 最多重试 RETRY_CONFIG.maxRetries 次,间隔递增
*/
async
function
executeWithRetry
(
scheduleId
:
number
,
title
:
string
,
deviceIds
:
string
[],
acControl
:
string
,
todoItem
:
Record
<
string
,
string
>
|
null
|
undefined
):
Promise
<
void
>
{
let
lastResult
:
{
success
:
boolean
;
message
:
string
}
|
null
=
null
;
for
(
let
attempt
=
0
;
attempt
<=
RETRY_CONFIG
.
maxRetries
;
attempt
++
)
{
if
(
attempt
>
0
)
{
const
delay
=
RETRY_CONFIG
.
delays
[
attempt
-
1
]
??
120
_000
;
console
.
log
(
`[日程执行器] 日程 #
${
scheduleId
}
第
${
attempt
}
次重试,等待
${
delay
/
1000
}
s...`
);
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
delay
));
}
const
result
=
await
executeControl
(
scheduleId
,
title
,
deviceIds
,
acControl
,
todoItem
);
lastResult
=
result
;
if
(
result
.
success
)
{
return
;
// 成功,结束
}
console
.
error
(
`[日程执行器] 日程 #
${
scheduleId
}
第
${
attempt
}
次执行失败:
${
result
.
message
}
`
);
}
console
.
error
(
`[日程执行器] 日程 #
${
scheduleId
}
"
${
title
}
" 重试
${
RETRY_CONFIG
.
maxRetries
}
次后仍失败:
${
lastResult
?.
message
}
`
);
}
/**
* 为单个日程安排下一次执行
* 执行完成后自动排下一次(第二天同一时间)
*/
function scheduleNextRun(schedule: any, deviceIds: string[]): void {
const { id, title, schedule_time, ac_control, todo_item, end_validity } = schedule;
// 已过期不再调度
if (isExpired(end_validity)) {
console.log(`
[
日程执行器
]
日程
#
$
{
id
}
"${title}"
已过有效期,不再调度
`);
return;
}
const nextTime = calcNextExecutionTime(schedule_time);
const delayMs = nextTime.getTime() - Date.now();
console.log(
`
[
日程执行器
]
日程
#
$
{
id
}
"${title}"
下次执行
:
$
{
formatLocalTime
(
nextTime
)}
(
${
Math
.
round
(
delayMs
/
1000
)}
s 后)`
);
const
timer
=
setTimeout
(
async
()
=>
{
// 执行前再次检查有效期(可能在等待期间越过 end_validity)
if
(
isExpired
(
end_validity
))
{
console
.
log
(
`[日程执行器] 日程 #
${
id
}
"
${
title
}
" 在等待期间过期,取消执行`
);
activeTimers
.
delete
(
id
);
return
;
}
// 带重试执行
await
executeWithRetry
(
id
,
title
,
deviceIds
,
ac_control
,
todo_item
);
// 安排下一次
activeTimers
.
delete
(
id
);
scheduleNextRun
(
schedule
,
deviceIds
);
},
delayMs
);
activeTimers
.
set
(
id
,
{
scheduleId
:
id
,
timer
,
title
});
}
// ======================= 公开接口 =======================
/** 清除所有活跃定时器 */
function
clearAllTimers
():
void
{
console
.
log
(
`[日程执行器] 清除
${
activeTimers
.
size
}
个活跃定时器`
);
for
(
const
[,
entry
]
of
activeTimers
)
{
clearTimeout
(
entry
.
timer
);
}
activeTimers
.
clear
();
}
/**
* 从数据库加载所有有效期内的日程,安排定时执行
*/
async
function
loadAndScheduleAll
():
Promise
<
void
>
{
console
.
log
(
"[日程执行器] 开始加载日程..."
);
try
{
const
scheduleRes
=
await
selectDataListByParam
(
TABLENAME
.
日程表
,
{},
[
"id"
,
"title"
,
"ac_control"
,
"todo_item"
,
"schedule_time"
,
"begin_validity"
,
"end_validity"
]
);
const
schedules
:
any
[]
=
scheduleRes
.
data
||
[];
let
scheduledCount
=
0
;
for
(
const
schedule
of
schedules
)
{
const
{
id
,
title
,
schedule_time
,
begin_validity
,
end_validity
}
=
schedule
;
// 没有 schedule_time 的跳过
if
(
!
schedule_time
)
{
console
.
log
(
`[日程执行器] 日程 #
${
id
}
"
${
title
}
" 未设置 schedule_time,跳过`
);
continue
;
}
// 不在有效期内跳过
if
(
!
isInValidity
(
begin_validity
,
end_validity
))
{
console
.
log
(
`[日程执行器] 日程 #
${
id
}
"
${
title
}
" 不在有效期内,跳过`
);
continue
;
}
// 查询关联设备
const
sdRes
=
await
selectDataListByParam
(
TABLENAME
.
日程设备关联表
,
{
schedule_id
:
id
},
[
"device_id"
]
);
const
sdRows
:
any
[]
=
sdRes
.
data
||
[];
const
deviceIds
=
sdRows
.
map
((
r
:
any
)
=>
r
.
device_id
)
.
filter
((
did
:
string
)
=>
did
&&
did
.
trim
()
!==
""
);
// 没有有效设备则跳过
if
(
deviceIds
.
length
===
0
)
{
console
.
log
(
`[日程执行器] 日程 #
${
id
}
"
${
title
}
" 没有有效设备,跳过`
);
continue
;
}
// 安排执行
scheduleNextRun
(
schedule
,
deviceIds
);
scheduledCount
++
;
}
console
.
log
(
`[日程执行器] 加载完成,共安排
${
scheduledCount
}
个日程`
);
}
catch
(
err
)
{
console
.
error
(
"[日程执行器] 加载日程失败:"
,
err
);
}
}
/**
* 安排每日凌晨 00:05 重载
* 用于自动处理新增、过期日程
*/
function
scheduleDailyReload
():
void
{
if
(
dailyReloadTimer
)
{
clearTimeout
(
dailyReloadTimer
);
}
const
now
=
new
Date
();
const
reloadTime
=
new
Date
(
now
);
reloadTime
.
setDate
(
reloadTime
.
getDate
()
+
1
);
reloadTime
.
setHours
(
0
,
5
,
0
,
0
);
const
delayMs
=
reloadTime
.
getTime
()
-
now
.
getTime
();
console
.
log
(
`[日程执行器] 每日重载将在
${
formatLocalTime
(
reloadTime
)}
执行 (
${
Math
.
round
(
delayMs
/
1000
)}
s 后)`
);
dailyReloadTimer
=
setTimeout
(
async
()
=>
{
console
.
log
(
"[日程执行器] 开始每日重载..."
);
clearAllTimers
();
await
loadAndScheduleAll
();
scheduleDailyReload
();
},
delayMs
);
}
/**
* 初始化日程任务(项目启动时调用)
*/
export
async
function
initScheduleTasks
():
Promise
<
void
>
{
console
.
log
(
"[日程执行器] ========== 初始化日程任务 =========="
);
clearAllTimers
();
await
loadAndScheduleAll
();
scheduleDailyReload
();
console
.
log
(
"[日程执行器] ========== 初始化完成 =========="
);
}
/**
* 重载日程任务(增/删/改日程后调用,立即生效)
*/
export
async
function
reloadScheduleTasks
():
Promise
<
void
>
{
console
.
log
(
"[日程执行器] ========== 重载日程任务 =========="
);
clearAllTimers
();
if
(
dailyReloadTimer
)
{
clearTimeout
(
dailyReloadTimer
);
dailyReloadTimer
=
null
;
}
await
loadAndScheduleAll
();
scheduleDailyReload
();
console
.
log
(
"[日程执行器] ========== 重载完成 =========="
);
}
/**
* 获取当前活跃的定时任务信息(调试用)
*/
export
function
getActiveScheduleTasks
():
Array
<
{
scheduleId
:
number
;
title
:
string
}
>
{
const
result
:
Array
<
{
scheduleId
:
number
;
title
:
string
}
>
=
[];
for
(
const
[,
entry
]
of
activeTimers
)
{
result
.
push
({
scheduleId
:
entry
.
scheduleId
,
title
:
entry
.
title
});
}
return
result
;
}
src/main.ts
View file @
6350c715
...
...
@@ -4,6 +4,8 @@ import * as mqttSrv from "./service/mqttInit";
import
{
initMysqlModel
}
from
"./model/sqlModelBind"
;
import
{
httpServer
}
from
"./net/http_server"
;
import
{
startSchedule
,
startAlertSchedule
,
startFaultStatusSchedule
}
from
"./config/schedule"
;
import
{
initScheduleTasks
}
from
"./biz/scheduleExecutor"
;
import
{
refreshRegionSortOrder
}
from
"./biz/region"
;
async
function
lanuch
()
{
...
...
@@ -12,6 +14,8 @@ async function lanuch() {
/**初始化sql */
await
mysqlDB
.
initMysqlDB
();
await
initMysqlModel
();
/**刷新区域排序 */
await
refreshRegionSortOrder
();
/**创建http服务 */
httpServer
.
createServer
(
systemConfig
.
port
);
/**启动MQTT订阅服务 */
...
...
@@ -22,6 +26,8 @@ async function lanuch() {
startAlertSchedule
();
/**启动故障状态更新定时任务(5分钟) */
startFaultStatusSchedule
();
/**启动日程执行器 */
await
initScheduleTasks
();
console
.
log
(
'This indicates that the server is started successfully.'
);
...
...
src/routers/admin.ts
View file @
6350c715
...
...
@@ -6,6 +6,7 @@ import asyncHandler from "express-async-handler";
import
*
as
reportBiz
from
"../biz/report"
;
import
*
as
regionBiz
from
"../biz/region"
;
import
*
as
scheduleBiz
from
"../biz/schedule"
;
import
{
reloadScheduleTasks
}
from
"../biz/scheduleExecutor"
;
import
{
eccReqParamater
}
from
"../util/verificationParam"
;
export
function
setRouter
(
httpServer
)
{
...
...
@@ -134,6 +135,8 @@ async function addSchedule(req, res) {
regionKeys
:
regionKeys
??
[]
});
res
.
success
(
data
);
// 新增日程后重载执行器
reloadScheduleTasks
().
catch
((
err
)
=>
console
.
error
(
"[日程重载] 新增后重载失败:"
,
err
));
}
/**
...
...
@@ -164,6 +167,8 @@ async function editSchedule(req, res) {
regionKeys
:
regionKeys
??
[]
});
res
.
success
(
data
);
// 编辑日程后重载执行器
reloadScheduleTasks
().
catch
((
err
)
=>
console
.
error
(
"[日程重载] 编辑后重载失败:"
,
err
));
}
/**
...
...
@@ -179,4 +184,6 @@ async function deleteSchedule(req, res) {
}
const
data
=
await
scheduleBiz
.
deleteSchedule
(
Number
(
scheduleId
));
res
.
success
(
data
);
// 删除日程后重载执行器
reloadScheduleTasks
().
catch
((
err
)
=>
console
.
error
(
"[日程重载] 删除后重载失败:"
,
err
));
}
src/routers/device.ts
View file @
6350c715
...
...
@@ -32,15 +32,17 @@ export function setRouter(httpServer) {
httpServer
.
post
(
'/api/qdm/run/monitor/acPop'
,
asyncHandler
(
getAcRunningPop
));
/** 空调开关 */
httpServer
.
post
(
'/api/qdm/run/monitor/acRun'
,
asyncHandler
(
controlAcRunning
));
/** 运行分析 - 环境质量弹窗 */
httpServer
.
post
(
'/api/qdm/run/analysis/envPop'
,
asyncHandler
(
getRunAnalysisEnvPop
));
/** 启动数据集成 */
httpServer
.
post
(
'/api/qdm/run/start/schedule'
,
asyncHandler
(
startSchedule
));
/** 停止数据集成 */
httpServer
.
post
(
'/api/qdm/run/stop/schedule'
,
asyncHandler
(
stopSchedule
));
/** 获取环境监测设备 */
/**
IAQ-
获取环境监测设备 */
httpServer
.
post
(
'/api/qdm/device/envDevice'
,
asyncHandler
(
getEnvironmentDevices
));
/** 获取环境监测数据 */
/**
IAQ-
获取环境监测数据 */
httpServer
.
post
(
'/api/qdm/device/environment'
,
asyncHandler
(
getEnvironmentData
));
}
...
...
@@ -184,6 +186,17 @@ async function getEnvironmentData(req, res) {
res
.
success
(
result
);
}
/**
* 运行分析 - 环境质量弹窗
*/
async
function
getRunAnalysisEnvPop
(
req
,
res
)
{
let
reqConf
=
{
regionGroup
:
'String'
,
regionType
:
'String'
};
const
NotMustHaveKeys
=
[
"regionGroup"
,
"regionType"
];
let
{
regionGroup
,
regionType
}
=
eccReqParamater
(
reqConf
,
req
.
body
,
NotMustHaveKeys
);
const
result
=
await
deviceBiz
.
getEnvQualityPop
(
regionGroup
,
regionType
);
res
.
success
(
result
);
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment