@7365admin1/layer-common 4.58.1 → 4.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/assets/css/robot-hero.css +26 -0
- package/components/Robot/Detail.vue +143 -71
- package/components/Robot/PatrolHistory.vue +15 -5
- package/package.json +1 -1
- package/utils/robot-control.ts +26 -4
- package/utils/robot-display.ts +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.59.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d7cf74d: Robot detail laid out for the operator: header (name, status, site, model · serial, Edit/Remove), one-row status strip, camera and live map side by side above the fold with the commands under the map (map full width for robots without a camera; stacks camera, map, commands, patrol when narrow), then Patrol schedule next to Recent patrols (5 rows, See all), this robot's Alerts, and "What this robot can do" folded at the bottom. New `detectPatrolSchedule` / `patrolSlotText`: for brands without a schedule API (PadBot) the patrol schedule is detected from the last 30 days of runs ("Patrolling Lvl3 · daily 20:00 · last ran 18 h ago · next expected 20:00 today"), clearly labelled.
|
|
8
|
+
|
|
9
|
+
## 4.58.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 40a1da2: Robot live view (PadBot S2): the camera's stray H.264 delimiters are now rewritten into real H.265 access-unit delimiters (after a 4-byte start code). The S2 sends VPS/SPS/PPS each in its own PES; without an H.265 delimiter hls.js 1.7.3 dropped the PPS from the init segment and Chrome decoded 0 frames (black picture, clock running).
|
|
14
|
+
|
|
3
15
|
## 4.58.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1624,11 +1636,11 @@
|
|
|
1624
1636
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1625
1637
|
own rule from two endpoints the console already calls, unprojected:
|
|
1626
1638
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1639
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1640
|
+
GET /api/roles/id/:role that membership's role document
|
|
1629
1641
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1642
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1643
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1632
1644
|
|
|
1633
1645
|
`role.default` is the marker because it is the only property of a platform
|
|
1634
1646
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -801,6 +801,32 @@
|
|
|
801
801
|
margin-bottom: 8px;
|
|
802
802
|
}
|
|
803
803
|
.hero-detail__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 24px; }
|
|
804
|
+
|
|
805
|
+
/* Robot detail layout (2026-09-26): strip in one row, camera | map above the fold, stacked when narrow. */
|
|
806
|
+
.hero-strip { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); }
|
|
807
|
+
.hero-strip .hero-fact { padding: 8px 12px; }
|
|
808
|
+
.hero-strip .hero-fact__value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
809
|
+
.hero-detail__main { display: grid; grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); gap: 20px; align-items: start; }
|
|
810
|
+
.hero-detail__main--single { grid-template-columns: minmax(0, 1fr); }
|
|
811
|
+
.hero-detail__cam, .hero-detail__map { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
812
|
+
.hero-detail__main .hero-plan--detail { max-height: 280px; }
|
|
813
|
+
.hero-detail__main--single .hero-plan--detail { max-height: 360px; }
|
|
814
|
+
.hero-detail__main .hero-livepanel .hero-dialog__actions:empty { display: none; }
|
|
815
|
+
.hero-detail__commands { border-top: 2px solid var(--hero-divider); padding-top: 10px; }
|
|
816
|
+
.hero-detail__alertcode { color: var(--hero-accent); }
|
|
817
|
+
@media (max-width: 900px) { .hero-detail__main { grid-template-columns: minmax(0, 1fr); } }
|
|
818
|
+
.hero-sched__note { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
|
|
819
|
+
.hero-sched__row.hero-sched__row--slot { grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); }
|
|
820
|
+
.hero-sched__row.hero-sched__row--alert { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
|
821
|
+
.hero-detail__sub { display: flex; flex-wrap: wrap; gap: 4px 14px; opacity: 0.65; font-size: 12px; margin-top: 2px; }
|
|
822
|
+
.hero-detail__title--main { flex-wrap: nowrap; min-width: 0; }
|
|
823
|
+
.hero-detail__title--main h2 { overflow: hidden; text-overflow: ellipsis; min-width: 8ch; }
|
|
824
|
+
@media (max-width: 700px) { .hero-detail__title--main { flex-wrap: wrap; } .hero-detail__title--main h2 { flex-basis: 100%; } }
|
|
825
|
+
@media (max-width: 560px) { .hero-faultbar { grid-auto-flow: row; } .hero-faultbar > .v-btn, .hero-faultbar__badge { justify-self: start; } }
|
|
826
|
+
.hero-detail__main .hero-mapsel { margin: 0; }
|
|
827
|
+
.hero-can--fold { padding: 8px 14px; }
|
|
828
|
+
.hero-can--fold > summary { cursor: pointer; list-style: revert; }
|
|
829
|
+
.hero-can--fold[open] > summary { margin-bottom: 6px; }
|
|
804
830
|
.hero-detail__grid .hero-aside__hint { opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
805
831
|
|
|
806
832
|
.hero-sched__empty { padding: 20px 0; opacity: 0.65; }
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<!--
|
|
2
|
+
<!--
|
|
3
|
+
Robot detail (README §3), laid out for the operator's job (reasoning:
|
|
4
|
+
Project-Notes 2026-09-26-robot-detail-layout.md):
|
|
5
|
+
header (who / where / model) -> one-row status strip -> ABOVE THE FOLD the
|
|
6
|
+
camera (left, large) and the live map (right) with the commands that act on
|
|
7
|
+
them directly under the map -> patrol / cleaning: schedule next to recent
|
|
8
|
+
runs -> this robot's alerts -> "What this robot can do" folded at the bottom.
|
|
9
|
+
No camera: the map takes the full width. Narrow: camera, map, commands, patrol.
|
|
10
|
+
-->
|
|
3
11
|
<div>
|
|
4
12
|
<button type="button" class="hero-back hero-back--sm" @click="emit('back')">‹ {{ backLabel || `${record?.site?.name || "Site"} fleet` }}</button>
|
|
5
|
-
<div class="hero-detail__title">
|
|
13
|
+
<div class="hero-detail__title hero-detail__title--main">
|
|
6
14
|
<h2>{{ record?.name ?? "Robot" }}</h2>
|
|
7
15
|
<template v-if="statusMode">
|
|
8
16
|
<RobotStatusChip :mode="statusMode" :type="robotType" />
|
|
@@ -10,13 +18,16 @@
|
|
|
10
18
|
<span v-else-if="live && !live.ok" class="hero-tag hero-tag--neutral">Reconnecting</span>
|
|
11
19
|
<span v-if="caps" class="hero-tag hero-tag--neutral">{{ capabilityLabel(caps) }}</span>
|
|
12
20
|
<span class="hero-tag hero-tag--neutral">{{ typeLabel(robotType) }}</span>
|
|
13
|
-
<span v-if="record" class="hero-detail__where">{{ record.client?.name }} · {{ record.site?.name }}</span>
|
|
14
21
|
<span class="hero-head__spacer" />
|
|
15
22
|
<template v-if="record">
|
|
16
23
|
<v-btn v-if="canEdit" variant="outlined" @click="editOpen = true">Edit</v-btn>
|
|
17
24
|
<v-btn v-if="canDelete" variant="text" class="hero-ghost" @click="removeOpen = true">Remove</v-btn>
|
|
18
25
|
</template>
|
|
19
26
|
</div>
|
|
27
|
+
<div v-if="record" class="hero-detail__sub">
|
|
28
|
+
<span>{{ record.client?.name }} · {{ record.site?.name }}</span>
|
|
29
|
+
<span v-if="identity">{{ identity }}</span>
|
|
30
|
+
</div>
|
|
20
31
|
</div>
|
|
21
32
|
|
|
22
33
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
@@ -31,49 +42,30 @@
|
|
|
31
42
|
<template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
|
|
32
43
|
</div>
|
|
33
44
|
</div>
|
|
34
|
-
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="livePanel?.show()">Live view</v-btn>
|
|
35
45
|
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
36
46
|
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
37
47
|
</div>
|
|
38
48
|
|
|
39
49
|
<RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
|
|
40
50
|
|
|
41
|
-
<!--
|
|
42
|
-
<
|
|
43
|
-
<div class="hero-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
<span>{{ r.value }}</span>
|
|
51
|
+
<!-- Status strip: connection · battery · what it is doing · floor · last seen, one row. -->
|
|
52
|
+
<div v-if="stripFacts.length" class="hero-facts hero-strip" role="list" aria-label="Robot status">
|
|
53
|
+
<div v-for="f in stripFacts" :key="f.label" class="hero-fact" role="listitem">
|
|
54
|
+
<div class="hero-fact__label">{{ f.label }}</div>
|
|
55
|
+
<div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }" :title="String(f.value ?? '')">{{ f.value }}</div>
|
|
47
56
|
</div>
|
|
48
|
-
</section>
|
|
49
|
-
|
|
50
|
-
<div v-if="commands.length">
|
|
51
|
-
<div class="hero-section__title">Commands</div>
|
|
52
|
-
<RobotCommandBar
|
|
53
|
-
:capabilities="caps"
|
|
54
|
-
:can-command="canCommand"
|
|
55
|
-
:online="live?.ok === true && live.online === true"
|
|
56
|
-
:mode="statusMode"
|
|
57
|
-
:type="robotType"
|
|
58
|
-
:busy="busy"
|
|
59
|
-
@command="send"
|
|
60
|
-
/>
|
|
61
57
|
</div>
|
|
62
|
-
<RobotViewOnlyNotice v-if="!canCommand && !noInterface" />
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
<
|
|
59
|
+
<!-- Above the fold: camera | map, with the commands under the map they act on. -->
|
|
60
|
+
<div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
|
|
61
|
+
<section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
|
|
62
|
+
<RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
|
|
63
|
+
<div v-if="canDrive" class="hero-detail__video">
|
|
64
|
+
<v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
70
65
|
</div>
|
|
71
|
-
</
|
|
72
|
-
</div>
|
|
66
|
+
</section>
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
<div>
|
|
76
|
-
<div class="hero-section__title">Location</div>
|
|
68
|
+
<section class="hero-detail__map" aria-label="Map">
|
|
77
69
|
<div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
|
|
78
70
|
<div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
|
|
79
71
|
<div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
|
|
@@ -92,7 +84,6 @@
|
|
|
92
84
|
</select>
|
|
93
85
|
<span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
|
|
94
86
|
<span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
|
|
95
|
-
<span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
|
|
96
87
|
</div>
|
|
97
88
|
<RobotLivePlan
|
|
98
89
|
:name="record?.name"
|
|
@@ -122,43 +113,71 @@
|
|
|
122
113
|
</div>
|
|
123
114
|
</template>
|
|
124
115
|
<div v-else-if="caps" class="hero-sched__empty">{{ brand }} doesn't share where this robot is with us yet.</div>
|
|
125
|
-
</div>
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
117
|
+
<div v-if="commands.length" class="hero-detail__commands">
|
|
118
|
+
<RobotCommandBar
|
|
119
|
+
:capabilities="caps"
|
|
120
|
+
:can-command="canCommand"
|
|
121
|
+
:online="live?.ok === true && live.online === true"
|
|
122
|
+
:mode="statusMode"
|
|
123
|
+
:type="robotType"
|
|
124
|
+
:busy="busy"
|
|
125
|
+
@command="send"
|
|
126
|
+
/>
|
|
134
127
|
</div>
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
<RobotViewOnlyNotice v-if="!canCommand" />
|
|
129
|
+
</section>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Patrol / cleaning: when it runs, next to what it did. -->
|
|
133
|
+
<div v-if="!noInterface" class="hero-detail__grid">
|
|
134
|
+
<div>
|
|
135
|
+
<div class="hero-section__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</div>
|
|
136
|
+
<template v-if="robotType === 'security' && !schedule.length && patrol?.scheduleShared === false">
|
|
137
|
+
<div class="hero-sched__note">Detected from the robot's recent patrols (last 30 days). Schedules are changed in {{ makerApp }}.</div>
|
|
138
|
+
<div v-if="!runsLoaded" class="hero-sched__empty">Checking recent patrols…</div>
|
|
139
|
+
<div v-else-if="!detected.length" class="hero-sched__empty">No regular patrols detected in the last 30 days.</div>
|
|
140
|
+
<div v-for="s in detected" :key="s.route + s.minutes" class="hero-sched__row hero-sched__row--slot">
|
|
141
|
+
<b>{{ s.route }}</b><span>{{ patrolSlotText(s, now) }}</span>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
144
|
+
<template v-else>
|
|
145
|
+
<div class="hero-sched__note">{{ robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes" }}</div>
|
|
146
|
+
<div v-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
147
|
+
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
148
|
+
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
149
|
+
</div>
|
|
139
150
|
</template>
|
|
140
|
-
<div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
141
|
-
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
142
|
-
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
143
|
-
</div>
|
|
144
151
|
<template v-if="unplacedRoutes.length">
|
|
145
|
-
<div class="hero-
|
|
152
|
+
<div class="hero-sched__note">Patrol routes (stops not placed on the map)</div>
|
|
146
153
|
<div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
|
|
147
154
|
<b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
|
|
148
155
|
</div>
|
|
149
156
|
</template>
|
|
150
157
|
</div>
|
|
158
|
+
<div v-if="robotType === 'security' && caps?.patrolHistory === true">
|
|
159
|
+
<div class="hero-section__title">Recent patrols</div>
|
|
160
|
+
<RobotPatrolHistory :robot-id="robotId" compact />
|
|
161
|
+
</div>
|
|
151
162
|
</div>
|
|
152
163
|
|
|
153
|
-
|
|
164
|
+
<!-- This robot's open alerts (the newest also sits in the red bar on top while it is open). -->
|
|
165
|
+
<section v-if="!noInterface && record" aria-label="Alerts for this robot">
|
|
166
|
+
<div class="hero-section__title">Alerts</div>
|
|
167
|
+
<div v-if="!robotAlerts.length" class="hero-sched__empty">No open alerts for this robot.</div>
|
|
168
|
+
<div v-for="a in robotAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert">
|
|
169
|
+
<b class="hero-detail__alertcode">{{ a.code }}</b><span>{{ a.message }}</span><span>{{ a.since }}</span>
|
|
170
|
+
</div>
|
|
171
|
+
</section>
|
|
154
172
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<
|
|
158
|
-
<div v-
|
|
159
|
-
<
|
|
173
|
+
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
174
|
+
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
175
|
+
<summary class="hero-can__title">What this robot can do</summary>
|
|
176
|
+
<div v-for="r in abilities" :key="r.label" class="hero-can__row">
|
|
177
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
178
|
+
<span>{{ r.value }}</span>
|
|
160
179
|
</div>
|
|
161
|
-
</
|
|
180
|
+
</details>
|
|
162
181
|
|
|
163
182
|
<v-dialog v-model="confirmOpen" width="440">
|
|
164
183
|
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
|
|
@@ -234,6 +253,7 @@ import { faultPhotos } from "../../utils/robot-control";
|
|
|
234
253
|
import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
235
254
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
236
255
|
import {
|
|
256
|
+
attentionText,
|
|
237
257
|
brandName,
|
|
238
258
|
capabilityLabel,
|
|
239
259
|
cleaningScheduleRows,
|
|
@@ -246,7 +266,8 @@ import {
|
|
|
246
266
|
lastKnownLabel,
|
|
247
267
|
mergePose,
|
|
248
268
|
optimisticMode,
|
|
249
|
-
|
|
269
|
+
detectPatrolSchedule,
|
|
270
|
+
patrolSlotText,
|
|
250
271
|
patrolScheduleRows,
|
|
251
272
|
pointCommandBody,
|
|
252
273
|
savedPointGroups,
|
|
@@ -259,7 +280,6 @@ import {
|
|
|
259
280
|
robotErrorMessage,
|
|
260
281
|
robotFacts,
|
|
261
282
|
robotStatus,
|
|
262
|
-
scheduleElsewhereText,
|
|
263
283
|
showSpotClean,
|
|
264
284
|
sinceText,
|
|
265
285
|
speedText,
|
|
@@ -308,6 +328,7 @@ const {
|
|
|
308
328
|
getRobotMapImages,
|
|
309
329
|
resolveRobotAlerts,
|
|
310
330
|
getPatrolHistory,
|
|
331
|
+
getSiteFaults,
|
|
311
332
|
} = useRobots();
|
|
312
333
|
|
|
313
334
|
const record = ref<Record<string, any> | null>(null);
|
|
@@ -363,14 +384,25 @@ const faultSince = computed(() => sinceText(fault.value?.since, now.value));
|
|
|
363
384
|
|
|
364
385
|
// Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
|
|
365
386
|
const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
|
|
366
|
-
|
|
367
|
-
const
|
|
387
|
+
// The strip: connection · battery · what it is doing · floor · last seen (model and serial sit in the header).
|
|
388
|
+
const STRIP_ORDER = ["Connection", "Battery", "Robot reports", "Floor", "Last seen"];
|
|
389
|
+
const stripFacts = computed(() =>
|
|
390
|
+
STRIP_ORDER.map((l) => facts.value.find((f) => f.label === l))
|
|
391
|
+
.filter((f): f is NonNullable<typeof f> => !!f)
|
|
392
|
+
.map((f) => (f.label === "Robot reports" ? { ...f, label: "Doing now" } : f)),
|
|
393
|
+
);
|
|
394
|
+
const identity = computed(() => {
|
|
395
|
+
const model = facts.value.find((f) => f.label === "Model")?.value;
|
|
396
|
+
const serial = facts.value.find((f) => f.label === "Serial")?.value;
|
|
397
|
+
return [model, serial ? `Serial ${serial}` : ""].filter(Boolean).join(" · ");
|
|
398
|
+
});
|
|
368
399
|
// The plan's readout already shows the live position; the cell only when there is no plan.
|
|
369
|
-
|
|
400
|
+
// Floor is in the strip; the plan's readout shows the live position, so the cell only when there is no plan.
|
|
401
|
+
const locationFacts = computed(() => facts.value.filter((f) => f.label === "Live position" && !showPlan.value));
|
|
370
402
|
const abilities = computed(() =>
|
|
371
403
|
robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
|
|
372
404
|
);
|
|
373
|
-
const
|
|
405
|
+
const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp : "") || `${brand.value}'s own app`);
|
|
374
406
|
|
|
375
407
|
const online = computed(() => live.value?.ok === true && live.value.online === true);
|
|
376
408
|
const commands = computed(() =>
|
|
@@ -408,7 +440,7 @@ const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capa
|
|
|
408
440
|
const route = computed<TPatrolRouteView | null>(() =>
|
|
409
441
|
selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
|
|
410
442
|
);
|
|
411
|
-
const floorText = computed(() =>
|
|
443
|
+
const floorText = computed(() => facts.value.find((f) => f.label === "Floor")?.value ?? "");
|
|
412
444
|
// The plan only for a robot that reports where it is, has maps to show, takes spot clicks, or walks a route we can draw.
|
|
413
445
|
const showPlan = computed(
|
|
414
446
|
() => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
|
|
@@ -433,17 +465,34 @@ const schedule = computed(() => (robotType.value === "security" ? patrolSchedule
|
|
|
433
465
|
const unplacedRoutes = computed(() =>
|
|
434
466
|
(patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
|
|
435
467
|
);
|
|
436
|
-
// The brand keeps its schedules to itself:
|
|
468
|
+
// The brand keeps its schedules to itself (PadBot): detect them from the last 30 days of runs.
|
|
437
469
|
const runs = ref<any[]>([]);
|
|
438
|
-
const
|
|
470
|
+
const runsLoaded = ref(false);
|
|
471
|
+
const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
|
|
472
|
+
/** Newest first, 100 a page, until a page reaches 30 days back. ponytail: capped at 5 pages (500 runs); raise if a robot patrols more than ~16x a day. */
|
|
473
|
+
async function loadRuns() {
|
|
474
|
+
const since = Date.now() - 30 * 86_400_000;
|
|
475
|
+
const all: any[] = [];
|
|
476
|
+
for (let page = 1; page <= 5; page++) {
|
|
477
|
+
const res = await getPatrolHistory(props.robotId, { page, limit: 100 }).catch(() => null);
|
|
478
|
+
const items = res?.items ?? [];
|
|
479
|
+
all.push(...items);
|
|
480
|
+
const oldest = Date.parse(items[items.length - 1]?.startedAt ?? "");
|
|
481
|
+
if (!items.length || page >= (res?.pages ?? 0) || !(oldest >= since)) break;
|
|
482
|
+
}
|
|
483
|
+
runs.value = all;
|
|
484
|
+
runsLoaded.value = true;
|
|
485
|
+
}
|
|
439
486
|
|
|
487
|
+
let runsLoading = false;
|
|
440
488
|
async function loadSchedule() {
|
|
441
489
|
if (!record.value || noInterface.value) return;
|
|
442
490
|
try {
|
|
443
491
|
if (robotType.value === "security") {
|
|
444
492
|
patrol.value = await getPatrolRoutes(props.robotId);
|
|
445
|
-
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !
|
|
446
|
-
|
|
493
|
+
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runsLoaded.value && !runsLoading) {
|
|
494
|
+
runsLoading = true;
|
|
495
|
+
loadRuns().finally(() => (runsLoading = false));
|
|
447
496
|
}
|
|
448
497
|
} else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
|
|
449
498
|
} catch {
|
|
@@ -613,6 +662,7 @@ function onPick(p: TSavedPoint) {
|
|
|
613
662
|
|
|
614
663
|
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
615
664
|
const livePanel = ref<{ show(): void } | null>(null);
|
|
665
|
+
const hasCamera = computed(() => caps.value?.liveVideo === true || canDrive.value);
|
|
616
666
|
const driveOpen = ref(false);
|
|
617
667
|
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
618
668
|
const canResolve = computed(
|
|
@@ -631,6 +681,23 @@ async function resolveFault() {
|
|
|
631
681
|
}
|
|
632
682
|
}
|
|
633
683
|
|
|
684
|
+
/* ---- this robot's open alerts: the site's alert list, narrowed to this robot ---- */
|
|
685
|
+
const siteAlerts = ref<any[]>([]);
|
|
686
|
+
const robotAlerts = computed(() =>
|
|
687
|
+
siteAlerts.value
|
|
688
|
+
.filter((a) => String(a?.robot?._id ?? "") === props.robotId)
|
|
689
|
+
.map((a, i) => ({ key: `${a?.code ?? ""}-${i}`, ...attentionText(a), since: a?.since ? `Since ${sinceText(a.since, now.value)}` : "" })),
|
|
690
|
+
);
|
|
691
|
+
async function loadAlerts() {
|
|
692
|
+
const site = String(record.value?.site?._id ?? "");
|
|
693
|
+
if (!site) return;
|
|
694
|
+
try {
|
|
695
|
+
siteAlerts.value = (await getSiteFaults(site, { page: 1, limit: 100 }))?.items ?? [];
|
|
696
|
+
} catch {
|
|
697
|
+
siteAlerts.value = [];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
634
701
|
/* ---- edit / remove ---- */
|
|
635
702
|
function onSaved() {
|
|
636
703
|
editOpen.value = false;
|
|
@@ -674,9 +741,13 @@ watch(
|
|
|
674
741
|
lastPose.value = null;
|
|
675
742
|
prevPose.value = nextPose.value = null;
|
|
676
743
|
streamLost.value = false;
|
|
744
|
+
runs.value = [];
|
|
745
|
+
runsLoaded.value = false;
|
|
746
|
+
siteAlerts.value = [];
|
|
677
747
|
await loadRecord();
|
|
678
748
|
loadSchedule();
|
|
679
749
|
loadMaps();
|
|
750
|
+
loadAlerts();
|
|
680
751
|
},
|
|
681
752
|
{ immediate: true },
|
|
682
753
|
);
|
|
@@ -696,6 +767,7 @@ const timer = setInterval(() => {
|
|
|
696
767
|
now.value = Date.now();
|
|
697
768
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
698
769
|
if (++ticks % 8 === 0 && robotType.value === "security") loadSchedule();
|
|
770
|
+
if (ticks % 30 === 0) loadAlerts();
|
|
699
771
|
}, 1000);
|
|
700
772
|
onBeforeUnmount(() => {
|
|
701
773
|
clearInterval(timer);
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
patrols from its own cloud, newest first; a row opens what was captured at
|
|
5
5
|
each checkpoint. Read only; photo links open the brand's image.
|
|
6
6
|
-->
|
|
7
|
-
<div class="hero-patrolhist">
|
|
8
|
-
<div class="hero-aside__head">
|
|
7
|
+
<div class="hero-patrolhist" :class="{ 'hero-patrolhist--compact': compact }">
|
|
8
|
+
<div v-if="!compact" class="hero-aside__head">
|
|
9
9
|
<span class="hero-aside__title">Patrol history</span>
|
|
10
10
|
<span class="hero-aside__hint">route · result · time · distance · alerts</span>
|
|
11
11
|
</div>
|
|
12
12
|
<div v-if="error" class="hero-sched__empty">{{ error }}</div>
|
|
13
13
|
<div v-else-if="loaded && !rows.length" class="hero-sched__empty">No patrols recorded yet.</div>
|
|
14
|
-
<template v-for="r in
|
|
14
|
+
<template v-for="r in shownRows" :key="r.key">
|
|
15
15
|
<button type="button" class="hero-sched__row hero-patrolhist__row" :aria-expanded="open === r.key" @click="toggle(r.key)">
|
|
16
16
|
<b>{{ r.when }}</b><span>{{ r.route }} · <span :class="`hero-patrolhist__${r.tone}`">{{ r.result }}</span></span><span>{{ r.detail }}</span>
|
|
17
17
|
</button>
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
29
29
|
</template>
|
|
30
|
-
<div v-if="pages > 1" class="hero-patrolhist__foot">
|
|
30
|
+
<div v-if="short && (rows.length > 5 || pages > 1)" class="hero-patrolhist__foot">
|
|
31
|
+
<v-btn variant="text" size="small" @click="all = true">See all</v-btn>
|
|
32
|
+
</div>
|
|
33
|
+
<div v-else-if="pages > 1" class="hero-patrolhist__foot">
|
|
34
|
+
<v-btn v-if="compact" variant="text" size="small" @click="(all = false), load(1)">Show fewer</v-btn>
|
|
31
35
|
<v-btn variant="text" size="small" :disabled="page <= 1" @click="load(page - 1)">Newer</v-btn>
|
|
32
36
|
<span>Page {{ page }} of {{ pages }}</span>
|
|
33
37
|
<v-btn variant="text" size="small" :disabled="page >= pages" @click="load(page + 1)">Older</v-btn>
|
|
@@ -39,7 +43,10 @@
|
|
|
39
43
|
import { computed, ref, watch } from "vue";
|
|
40
44
|
import { activityWhen, patrolRecordRow, robotErrorMessage } from "../../utils/robot-display";
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
/** `compact`: the newest 5 with "See all" (robot detail); the full pager after that. */
|
|
47
|
+
const props = defineProps<{ robotId: string; compact?: boolean }>();
|
|
48
|
+
const all = ref(false);
|
|
49
|
+
const short = computed(() => props.compact && !all.value);
|
|
43
50
|
const { getPatrolHistory, getPatrolVisits } = useRobots();
|
|
44
51
|
|
|
45
52
|
const items = ref<any[]>([]);
|
|
@@ -52,6 +59,7 @@ const visits = ref<any[]>([]);
|
|
|
52
59
|
const visitsLoading = ref(false);
|
|
53
60
|
const now = Date.now();
|
|
54
61
|
const rows = computed(() => items.value.map((r) => patrolRecordRow(r, now)));
|
|
62
|
+
const shownRows = computed(() => (short.value ? rows.value.slice(0, 5) : rows.value));
|
|
55
63
|
|
|
56
64
|
/** Only http(s) links from the brand are rendered. */
|
|
57
65
|
const safeUrl = (u: unknown) => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : "");
|
|
@@ -89,6 +97,8 @@ watch(() => props.robotId, () => load(1), { immediate: true });
|
|
|
89
97
|
|
|
90
98
|
<style scoped>
|
|
91
99
|
.hero-patrolhist { margin-top: 20px; }
|
|
100
|
+
.hero-patrolhist--compact { margin-top: 0; }
|
|
101
|
+
.hero-patrolhist--compact .hero-sched__row { grid-template-columns: 76px minmax(0, 1fr) auto; }
|
|
92
102
|
.hero-patrolhist__row { width: 100%; text-align: left; background: none; border: 0; cursor: pointer; color: inherit; font: inherit; }
|
|
93
103
|
.hero-patrolhist__visits { padding: 4px 0 8px 16px; }
|
|
94
104
|
.hero-patrolhist__visit { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 0; font-size: 12px; }
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.59.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/utils/robot-control.ts
CHANGED
|
@@ -78,9 +78,20 @@ export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, un
|
|
|
78
78
|
* with these units removed Chrome's own decoder decodes 40/40 frames.
|
|
79
79
|
*
|
|
80
80
|
* Rewrites each such header in place, inside the TS packets of video PIDs
|
|
81
|
-
* only,
|
|
82
|
-
* ignore. Same length, so the TS stays intact. A valid single-layer H.265
|
|
81
|
+
* only. Same length, so the TS stays intact. A valid single-layer H.265
|
|
83
82
|
* header never matches (its temporal id is never 0). Returns the count.
|
|
83
|
+
*
|
|
84
|
+
* - After a 4-byte start code (the real S2 stream) it becomes a real H.265
|
|
85
|
+
* access-unit delimiter (type 35). This matters: the S2 sends every
|
|
86
|
+
* parameter set (VPS, SPS, PPS) in its own PES packet. Without an H.265
|
|
87
|
+
* delimiter hls.js 1.7.3 treats each PES as a new picture, and its HEVC
|
|
88
|
+
* parser forgets the VPS on every picture push (initVPS = null), so the PPS
|
|
89
|
+
* is never stored and the hvcC it writes has no PPS: Chrome then decodes
|
|
90
|
+
* 0 frames with no error - time runs, picture black (prod, 2026-09-26 late;
|
|
91
|
+
* the page's own hls.js decoded the real segments once the delimiter was a
|
|
92
|
+
* real one: 136 frames, PPS present).
|
|
93
|
+
* - After a 3-byte start code there is no spare byte: an "unspecified" unit
|
|
94
|
+
* (type 48) that hls.js and decoders ignore.
|
|
84
95
|
*/
|
|
85
96
|
export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
|
|
86
97
|
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
@@ -107,8 +118,19 @@ export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
|
|
|
107
118
|
const len = t + end - p;
|
|
108
119
|
for (let k = 0; k + 4 < len; k++) {
|
|
109
120
|
if (b[at(k)] === 0 && b[at(k + 1)] === 0 && b[at(k + 2)] === 1 && b[at(k + 3)] === 0x09 && (b[at(k + 4)]! & 7) === 0) {
|
|
110
|
-
b[at(k
|
|
111
|
-
|
|
121
|
+
if (k > 0 && b[at(k - 1)] === 0) {
|
|
122
|
+
// 4-byte start code (what the S2 sends): borrow its spare zero and
|
|
123
|
+
// write a REAL H.265 access-unit delimiter, 00 00 01 46 01 50.
|
|
124
|
+
b[at(k - 1)] = 0;
|
|
125
|
+
b[at(k)] = 0;
|
|
126
|
+
b[at(k + 1)] = 1;
|
|
127
|
+
b[at(k + 2)] = 0x46;
|
|
128
|
+
b[at(k + 3)] = 0x01;
|
|
129
|
+
b[at(k + 4)] = 0x50;
|
|
130
|
+
} else {
|
|
131
|
+
b[at(k + 3)] = 0x60;
|
|
132
|
+
b[at(k + 4)] = 0x01;
|
|
133
|
+
}
|
|
112
134
|
n++;
|
|
113
135
|
k += 4;
|
|
114
136
|
}
|
package/utils/robot-display.ts
CHANGED
|
@@ -1156,6 +1156,107 @@ export function patrolRunHint(records: Array<{ routeName?: unknown; startedAt?:
|
|
|
1156
1156
|
return `Looks like "${best.name}" runs ${how} around ${hhmm} (based on the last 7 days)`;
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
1159
|
+
/* ------------------------------------------ patrol schedule detected from past runs */
|
|
1160
|
+
|
|
1161
|
+
export type TPatrolSlot = {
|
|
1162
|
+
route: string;
|
|
1163
|
+
/** Minutes after local midnight. */
|
|
1164
|
+
minutes: number;
|
|
1165
|
+
hhmm: string;
|
|
1166
|
+
/** "daily" · "every Mon, Thu" · "on 4 of the last 30 days". */
|
|
1167
|
+
days: string;
|
|
1168
|
+
dayCount: number;
|
|
1169
|
+
lastAt: number;
|
|
1170
|
+
/** Next expected start, only while the pattern is still current. */
|
|
1171
|
+
nextAt: number | null;
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const DAY_MS = 86_400_000;
|
|
1175
|
+
const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
1176
|
+
const hhmmOf = (min: number) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
|
1177
|
+
const localDay = (t: number) => {
|
|
1178
|
+
const d = new Date(t);
|
|
1179
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1180
|
+
};
|
|
1181
|
+
const atMinutes = (day: number, min: number) => {
|
|
1182
|
+
const d = new Date(day);
|
|
1183
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), Math.floor(min / 60), min % 60).getTime();
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The robot's brand keeps its patrol schedule to itself (PadBot has no
|
|
1188
|
+
* schedule API), so we detect it from the robot's own recent runs: runs of
|
|
1189
|
+
* the same route that start within 30 minutes of each other (local clock) on
|
|
1190
|
+
* at least 2 different days in the last `windowDays` form one slot.
|
|
1191
|
+
* Every day between the first and last run -> "daily"; the same weekdays
|
|
1192
|
+
* repeated -> "every Mon, Thu"; else "on N of the last 30 days". The next
|
|
1193
|
+
* start is only predicted while the pattern is current (daily: ran in the
|
|
1194
|
+
* last 2 days; weekly: in the last 8). A one-off run is never a slot.
|
|
1195
|
+
*/
|
|
1196
|
+
export function detectPatrolSchedule(
|
|
1197
|
+
records: Array<{ routeName?: unknown; startedAt?: unknown }> | null | undefined,
|
|
1198
|
+
now: number = Date.now(),
|
|
1199
|
+
windowDays = 30,
|
|
1200
|
+
): TPatrolSlot[] {
|
|
1201
|
+
const since = now - windowDays * DAY_MS;
|
|
1202
|
+
const byRoute = new Map<string, Array<{ t: number; min: number }>>();
|
|
1203
|
+
for (const r of records ?? []) {
|
|
1204
|
+
const t = typeof r?.startedAt === "string" ? Date.parse(r.startedAt) : NaN;
|
|
1205
|
+
const name = typeof r?.routeName === "string" ? r.routeName.trim() : "";
|
|
1206
|
+
if (!name || !finite(t) || t < since || t > now) continue;
|
|
1207
|
+
const d = new Date(t);
|
|
1208
|
+
byRoute.set(name, [...(byRoute.get(name) ?? []), { t, min: d.getHours() * 60 + d.getMinutes() }]);
|
|
1209
|
+
}
|
|
1210
|
+
const slots: TPatrolSlot[] = [];
|
|
1211
|
+
for (const [route, runs] of byRoute) {
|
|
1212
|
+
// Group by time of day; a gap over 30 min starts a new group (a group may wrap past midnight).
|
|
1213
|
+
const sorted = [...runs].sort((a, b) => a.min - b.min);
|
|
1214
|
+
const groups: Array<typeof sorted> = [];
|
|
1215
|
+
for (const r of sorted) {
|
|
1216
|
+
const g = groups[groups.length - 1];
|
|
1217
|
+
if (g && r.min - g[g.length - 1]!.min <= 30) g.push(r);
|
|
1218
|
+
else groups.push([r]);
|
|
1219
|
+
}
|
|
1220
|
+
if (groups.length > 1) {
|
|
1221
|
+
const first = groups[0]!, last = groups[groups.length - 1]!;
|
|
1222
|
+
if (first[0]!.min + 1440 - last[last.length - 1]!.min <= 30) groups[0] = [...groups.pop()!.map((r) => ({ ...r, min: r.min - 1440 })), ...first];
|
|
1223
|
+
}
|
|
1224
|
+
for (const g of groups) {
|
|
1225
|
+
const days = [...new Set(g.map((r) => localDay(r.t)))].sort((a, b) => a - b);
|
|
1226
|
+
if (days.length < 2) continue;
|
|
1227
|
+
const mins = g.map((r) => r.min).sort((a, b) => a - b);
|
|
1228
|
+
const minutes = (mins[Math.floor((mins.length - 1) / 2)]! + 1440) % 1440;
|
|
1229
|
+
const lastAt = Math.max(...g.map((r) => r.t));
|
|
1230
|
+
const span = Math.round((days[days.length - 1]! - days[0]!) / DAY_MS) + 1;
|
|
1231
|
+
const wdCount = new Map<number, number>();
|
|
1232
|
+
for (const d of days) wdCount.set(new Date(d).getDay(), (wdCount.get(new Date(d).getDay()) ?? 0) + 1);
|
|
1233
|
+
const weekly = span > 7 && wdCount.size <= 5 && [...wdCount.values()].every((n) => n >= 2);
|
|
1234
|
+
const kind = days.length === span ? "daily" : weekly ? "weekly" : "some";
|
|
1235
|
+
const label =
|
|
1236
|
+
kind === "daily" ? "daily" : kind === "weekly" ? `every ${[...wdCount.keys()].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => WEEKDAY[d]).join(", ")}` : `on ${days.length} of the last ${windowDays} days`;
|
|
1237
|
+
let nextAt: number | null = null;
|
|
1238
|
+
if ((kind === "daily" && now - lastAt <= 2 * DAY_MS) || (kind === "weekly" && now - lastAt <= 8 * DAY_MS)) {
|
|
1239
|
+
for (let i = 0; i <= 7 && nextAt === null; i++) {
|
|
1240
|
+
const at = atMinutes(localDay(now) + i * DAY_MS + DAY_MS / 2, minutes); // noon offset survives DST days
|
|
1241
|
+
if (at > now && (kind === "daily" || wdCount.has(new Date(at).getDay()))) nextAt = at;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
slots.push({ route, minutes, hhmm: hhmmOf(minutes), days: label, dayCount: days.length, lastAt, nextAt });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return slots.sort((a, b) => a.minutes - b.minutes || a.route.localeCompare(b.route));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** One slot as a line: "daily 20:00 · last ran 19 h ago · next expected 20:00 today". */
|
|
1251
|
+
export function patrolSlotText(slot: TPatrolSlot, now: number = Date.now()): string {
|
|
1252
|
+
const parts = [`${slot.days} ${slot.hhmm}`, `last ran ${agoText(slot.lastAt, now)}`];
|
|
1253
|
+
if (slot.nextAt !== null) {
|
|
1254
|
+
const d = Math.round((localDay(slot.nextAt) - localDay(now)) / DAY_MS);
|
|
1255
|
+
parts.push(`next expected ${slot.hhmm} ${d === 0 ? "today" : d === 1 ? "tomorrow" : WEEKDAY[new Date(slot.nextAt).getDay()]}`);
|
|
1256
|
+
}
|
|
1257
|
+
return parts.join(" · ");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1159
1260
|
/**
|
|
1160
1261
|
* May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
|
|
1161
1262
|
* that declares `spotClean`, and a robot confirmed online. (A busy robot keeps
|