feat(map): 添加人员轨迹功能及筛选组件

master
LiuJiaNan 2026-07-27 16:56:12 +08:00
parent 273aa353ed
commit f32d44bad9
7 changed files with 237 additions and 2 deletions

View File

@ -0,0 +1,7 @@
import { declareRequest } from "@cqsjjb/jjb-dva-runtime";
// 人员轨迹按终端或人员编号、时间范围查询,接口参数由轨迹筛选面板传入。
export const getPeopleTrajectory = declareRequest(
"getPeopleTrajectoryLoading",
"Post > @/personnelPosition/bi/personTrace",
);

View File

@ -9,3 +9,4 @@ export const NS_GLOBAL = defineNamespace("global");
export const NS_BI_STATISTICS = defineNamespace("biStatistics");
export const NS_BI_MARK = defineNamespace("biMark");
export const NS_BI_MARK_INFO = defineNamespace("biMarkInfo");
export const NS_BI_PEOPLE = defineNamespace("biPeople");

View File

@ -11,6 +11,7 @@ import { NS_BI_MARK } from "~/enumerate/namespace";
import { branchOfficeUtilsList } from "~/pages/Container/Map/components/BottomUtils/branchOfficeUtilsList";
import { Context } from "~/pages/Container/Map/js/context";
import usePeoplePosition from "~/pages/Container/Map/js/usePeoplePosition";
import PeopleTrajectorySelect from "~/pages/Container/Map/components/PeopleTrajectorySelect";
import { portUtilsList } from "./portUtilsList";
import { usePortUtilsAnimation } from "./usePortUtilsAnimation";
import "./index.less";
@ -67,7 +68,12 @@ function BottomUtils(props) {
portArea,
} = useContext(Context);
const { connect: peoplePositionConnect, disconnect: peoplePositionDisconnect } = usePeoplePosition(mapMethods, currentBranchOffice);
const [peopleList, setPeopleList] = useState([]);
const { connect: peoplePositionConnect, disconnect: peoplePositionDisconnect } = usePeoplePosition(
mapMethods,
currentBranchOffice,
setPeopleList,
);
const bottomUtilsMode = !pureMap && currentPort && !currentBranchOffice
? "port"
@ -92,6 +98,10 @@ function BottomUtils(props) {
);
const resetAllCheck = () => {
// 底部工具栏被外部操作重置时,人员定位的 WebSocket 和 Cesium 实体不会随勾选状态自动销毁,需要统一清理。
peoplePositionDisconnect();
mapMethods.current.removePeoplePointAll();
setPeopleList([]);
setList(produce((draft) => {
draft.forEach((item) => {
item.list.forEach((item1) => {
@ -181,6 +191,7 @@ function BottomUtils(props) {
if (!check) {
if (item.type === "peoplePosition") {
resetPeopleTrajectory(index);
mapMethods.current.removePeopleTrajectory();
peoplePositionDisconnect();
return;
}
@ -209,6 +220,8 @@ function BottomUtils(props) {
await addMarkPoint(item);
};
const isPeopleTrajectoryChecked = list.some(item => item.list?.some(option => option.type === "peopleTrajectory" && option.check));
const renderPortUtils = () => {
if (bottomUtilsMode === "port" && listMode === bottomUtilsMode) {
return list.map((item, index) => {
@ -313,6 +326,18 @@ function BottomUtils(props) {
return (
<div className="map_content_bottom_utils_container">
<AnimatePresence>
{isPeopleTrajectoryChecked && (
<motion.div
className="people_trajectory_select"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
>
<PeopleTrajectorySelect peopleList={peopleList} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence mode="wait">
{bottomUtilsMode && (
<motion.div

View File

@ -0,0 +1,117 @@
import { Connect } from "@cqsjjb/jjb-dva-runtime";
import { Button, DatePicker, Form, message, Select, Spin } from "antd";
import dayjs from "dayjs";
import { useContext, useEffect, useMemo, useState } from "react";
import { NS_BI_PEOPLE } from "~/enumerate/namespace";
import { Context } from "~/pages/Container/Map/js/context";
import "./index.less";
const MAX_TRAJECTORY_RANGE = 24 * 60 * 60 * 1000;
function PeopleTrajectorySelect(props) {
const { mapMethods } = useContext(Context);
const [form] = Form.useForm();
const [queryLoading, setQueryLoading] = useState(false);
const peopleOptions = useMemo(
() => props.peopleList.map(person => ({
value: person.terminalNo || person.staffNo,
label: person.staffName,
})),
[props.peopleList],
);
useEffect(() => {
return () => mapMethods.current.removePeopleTrajectory();
}, [mapMethods]);
const handleQuery = async () => {
const { personId, timeRange } = await form.validateFields();
const [startTime, endTime] = timeRange.map(time => dayjs(time).valueOf());
if (endTime - startTime > MAX_TRAJECTORY_RANGE) {
message.warning("时间范围不能超过24小时");
return;
}
const person = props.peopleList.find(item => (item.terminalNo || item.staffNo) === personId);
const params = { startTime, endTime };
if (person?.terminalNo)
params.terminalNo = person.terminalNo;
else
params.staffNo = person?.staffNo;
setQueryLoading(true);
try {
const { data } = await props.getPeopleTrajectory(params);
const points = data?.points || [];
if (points.length < 2) {
mapMethods.current.removePeopleTrajectory();
message.warning("该时间范围内没有可绘制的轨迹");
return;
}
mapMethods.current.addPeopleTrajectory(points);
}
finally {
setQueryLoading(false);
}
};
const handleReset = () => {
form.resetFields();
mapMethods.current.removePeopleTrajectory();
};
return (
<div className="people_trajectory_select__content">
<Spin spinning={queryLoading}>
<Form
form={form}
styles={{ label: { color: "#fff" } }}
className="people_trajectory_select__form"
>
<Form.Item
className="people_trajectory_select__person"
label="人员"
name="personId"
rules={[{ required: true, message: "请选择人员" }]}
>
<Select
showSearch
optionFilterProp="label"
options={peopleOptions}
placeholder="请选择人员"
styles={{
root: { color: "#fff", backgroundColor: "rgba(2, 30, 81, 0.85)", borderColor: "rgba(2, 30, 81, 1)" },
placeholder: { color: "#fff" },
suffix: { color: "#fff" },
}}
/>
</Form.Item>
<Form.Item
className="people_trajectory_select__time"
label="时间范围"
name="timeRange"
rules={[{ required: true, message: "请选择时间范围" }]}
>
<DatePicker.RangePicker
showTime
styles={{
root: {
color: "#fff",
backgroundColor: "rgba(2, 30, 81, 0.85)",
borderColor: "rgba(2, 30, 81, 1)",
},
}}
/>
</Form.Item>
<div className="people_trajectory_select__actions">
<Button type="primary" loading={queryLoading} onClick={handleQuery}>查询</Button>
<Button onClick={handleReset}>重置</Button>
</div>
</Form>
</Spin>
</div>
);
}
export default Connect([NS_BI_PEOPLE], true)(PeopleTrajectorySelect);

View File

@ -0,0 +1,55 @@
.people_trajectory_select {
position: absolute;
bottom: 180px;
left: calc(50% - 500px);
z-index: 2;
width: 900px;
&__content {
padding: 12px 16px;
color: #fff;
background: rgba(6, 24, 64, 0.94);
border: 1px solid rgba(70, 174, 255, 0.7);
box-shadow: 0 0 16px rgba(36, 136, 255, 0.35);
}
.@{ant-prefix}-form-item {
margin: 0;
}
&__form {
display: flex;
gap: 16px;
.@{ant-prefix}-select-selector,
.@{ant-prefix}-picker {
color: #fff;
background-color: rgba(2, 30, 81, 0.85);
border-color: rgba(2, 30, 81, 1);
}
.@{ant-prefix}-picker-input > input::placeholder,
.@{ant-prefix}-picker-suffix,
.@{ant-prefix}-picker-separator {
color: #fff !important;
}
.@{ant-prefix}-picker {
width: 100%;
}
}
&__person {
width: 220px;
}
&__time {
width: 500px;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}

View File

@ -63,6 +63,7 @@ const peopleImg = {
red: people_red,
yellow: people_yellow,
}; // 人员定位颜色图片
const peopleTrajectoryEntityId = "peopleTrajectoryEntity";
const Cesium = window.Cesium;
@ -410,6 +411,29 @@ export default function useMapMethods(viewerRef, request) {
removeEntityCollection(getViewer(), `markEntityCollectionMerged${markType ? `_${markType}` : ""}`);
};
// 查询新轨迹前先移除旧轨迹,确保地图上始终只展示当前筛选结果。
const removePeopleTrajectory = () => {
getViewer().entities.removeById(peopleTrajectoryEntityId);
};
// 将接口返回的经纬高点位绘制为动态轨迹线,少于两个点时不创建无意义的线段。
const addPeopleTrajectory = (points = []) => {
removePeopleTrajectory();
if (points.length < 2)
return;
const coordinates = points.flatMap(point => [point.lon, point.lat, point.alt || 0]);
getViewer().entities.add({
id: peopleTrajectoryEntityId,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(coordinates),
width: 5,
material: new Cesium.TrajectoryPolylineTrailLinkMaterialProperty(getViewer()),
clampToGround: true,
},
});
};
// 人员定位使用 Viewer 的全局时钟;每次开启定位仅初始化一次,避免首批点位互相重置时钟。
const initPeoplePositionClock = () => {
const start = Cesium.JulianDate.now();
@ -492,6 +516,7 @@ export default function useMapMethods(viewerRef, request) {
});
peopleId.current = [];
peoplePositionStartTime.current = null;
removePeopleTrajectory();
};
return {
@ -509,6 +534,8 @@ export default function useMapMethods(viewerRef, request) {
removeWall,
addMarkPoint,
removeMarkPoint,
addPeopleTrajectory,
removePeopleTrajectory,
initPeoplePositionClock,
addPeoplePoint,
updatePeoplePoint,

View File

@ -3,7 +3,7 @@ import { useRef } from "react";
import edgeQHD from "./edge/qhd.js";
import { filterNull, isPointWithinTheArea } from "./utils";
export default function usePeoplePosition(mapMethods, currentBranchOffice) {
export default function usePeoplePosition(mapMethods, currentBranchOffice, onPeopleChange) {
const markType = "peoplePosition";
const points = useRef([]);
@ -43,6 +43,8 @@ export default function usePeoplePosition(mapMethods, currentBranchOffice) {
mapMethods.current && (await mapMethods.current.addPeoplePoint(perLoc, { markType, isShowModal: false }));
}
}
// 将已去重的实时人员列表同步给轨迹筛选面板,避免面板直接依赖 WebSocket。
onPeopleChange?.([...points.current]);
};
const { connect, disconnect } = useWebSocket(
@ -52,6 +54,7 @@ export default function usePeoplePosition(mapMethods, currentBranchOffice) {
onClose: () => {
mapMethods.current && mapMethods.current.removePeoplePointAll();
points.current = [];
onPeopleChange?.([]);
},
onMessage: (message) => {
onMessage(message);