@7365admin1/layer-common 4.58.2 → 4.59.2
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 +22 -4
- package/assets/css/robot-hero.css +28 -0
- package/components/Robot/CommandBar.vue +3 -0
- package/components/Robot/Detail.vue +242 -80
- package/components/Robot/FloorPlan.vue +2 -2
- package/components/Robot/PatrolHistory.vue +15 -5
- package/package.json +1 -1
- package/utils/robot-display.ts +191 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.59.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e3bedf4: Robots: Start patrol (security) picks the route from the routes on the robot's current map and the confirm names the route, its stops and the map; after "accepted" the detail watches the robot for 30 s and says "<robot> started <route>." or "<robot> didn't start - <reason>" (kept on screen); Resume patrol shows only when a patrol is paused or unfinished; Cancel only while a job is running; Start patrol hidden while patrolling.
|
|
8
|
+
|
|
9
|
+
## 4.59.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 95e047b: Robot detail: a robot's own map image follows the layout's height cap (280 px beside the camera, 360 px full width) instead of a fixed 440 px, and opening a robot scrolls its header to the top so the camera, map and commands are on screen.
|
|
14
|
+
|
|
15
|
+
## 4.59.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
3
21
|
## 4.58.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -1630,11 +1648,11 @@
|
|
|
1630
1648
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1631
1649
|
own rule from two endpoints the console already calls, unprojected:
|
|
1632
1650
|
|
|
1633
|
-
|
|
1634
|
-
|
|
1651
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1652
|
+
GET /api/roles/id/:role that membership's role document
|
|
1635
1653
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1654
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1655
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1638
1656
|
|
|
1639
1657
|
`role.default` is the marker because it is the only property of a platform
|
|
1640
1658
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -801,6 +801,34 @@
|
|
|
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__top { scroll-margin-top: 72px; }
|
|
813
|
+
.hero-detail__main { --hero-plan-max: 280px; }
|
|
814
|
+
.hero-detail__main--single { --hero-plan-max: 360px; }
|
|
815
|
+
.hero-detail__main .hero-plan--detail { max-height: var(--hero-plan-max); }
|
|
816
|
+
.hero-detail__main .hero-livepanel .hero-dialog__actions:empty { display: none; }
|
|
817
|
+
.hero-detail__commands { border-top: 2px solid var(--hero-divider); padding-top: 10px; }
|
|
818
|
+
.hero-detail__alertcode { color: var(--hero-accent); }
|
|
819
|
+
@media (max-width: 900px) { .hero-detail__main { grid-template-columns: minmax(0, 1fr); } }
|
|
820
|
+
.hero-sched__note { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
|
|
821
|
+
.hero-sched__row.hero-sched__row--slot { grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); }
|
|
822
|
+
.hero-sched__row.hero-sched__row--alert { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
|
823
|
+
.hero-detail__sub { display: flex; flex-wrap: wrap; gap: 4px 14px; opacity: 0.65; font-size: 12px; margin-top: 2px; }
|
|
824
|
+
.hero-detail__title--main { flex-wrap: nowrap; min-width: 0; }
|
|
825
|
+
.hero-detail__title--main h2 { overflow: hidden; text-overflow: ellipsis; min-width: 8ch; }
|
|
826
|
+
@media (max-width: 700px) { .hero-detail__title--main { flex-wrap: wrap; } .hero-detail__title--main h2 { flex-basis: 100%; } }
|
|
827
|
+
@media (max-width: 560px) { .hero-faultbar { grid-auto-flow: row; } .hero-faultbar > .v-btn, .hero-faultbar__badge { justify-self: start; } }
|
|
828
|
+
.hero-detail__main .hero-mapsel { margin: 0; }
|
|
829
|
+
.hero-can--fold { padding: 8px 14px; }
|
|
830
|
+
.hero-can--fold > summary { cursor: pointer; list-style: revert; }
|
|
831
|
+
.hero-can--fold[open] > summary { margin-bottom: 6px; }
|
|
804
832
|
.hero-detail__grid .hero-aside__hint { opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
805
833
|
|
|
806
834
|
.hero-sched__empty { padding: 20px 0; opacity: 0.65; }
|
|
@@ -30,6 +30,8 @@ const props = defineProps<{
|
|
|
30
30
|
online?: boolean;
|
|
31
31
|
mode?: unknown;
|
|
32
32
|
type?: string;
|
|
33
|
+
/** A patrol that can be carried on (Resume patrol shows only then). */
|
|
34
|
+
resumable?: boolean;
|
|
33
35
|
/** The command in flight, if any. */
|
|
34
36
|
busy?: TRobotCommandKind | null;
|
|
35
37
|
}>();
|
|
@@ -42,6 +44,7 @@ const commands = computed(() =>
|
|
|
42
44
|
online: props.online,
|
|
43
45
|
mode: props.mode,
|
|
44
46
|
type: props.type,
|
|
47
|
+
resumable: props.resumable,
|
|
45
48
|
}),
|
|
46
49
|
);
|
|
47
50
|
</script>
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<!--
|
|
3
|
-
|
|
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
|
+
-->
|
|
11
|
+
<div ref="topEl" class="hero-detail__top">
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
<div>
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
<div v-
|
|
68
|
-
<
|
|
69
|
-
<div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
|
|
58
|
+
|
|
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,47 +113,91 @@
|
|
|
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
|
+
:resumable="resumable"
|
|
125
|
+
:busy="busy"
|
|
126
|
+
@command="send"
|
|
127
|
+
/>
|
|
128
|
+
<div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
|
|
129
|
+
{{ commandResult.text }}
|
|
130
|
+
</div>
|
|
134
131
|
</div>
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
<RobotViewOnlyNotice v-if="!canCommand" />
|
|
133
|
+
</section>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Patrol / cleaning: when it runs, next to what it did. -->
|
|
137
|
+
<div v-if="!noInterface" class="hero-detail__grid">
|
|
138
|
+
<div>
|
|
139
|
+
<div class="hero-section__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</div>
|
|
140
|
+
<template v-if="robotType === 'security' && !schedule.length && patrol?.scheduleShared === false">
|
|
141
|
+
<div class="hero-sched__note">Detected from the robot's recent patrols (last 30 days). Schedules are changed in {{ makerApp }}.</div>
|
|
142
|
+
<div v-if="!runsLoaded" class="hero-sched__empty">Checking recent patrols…</div>
|
|
143
|
+
<div v-else-if="!detected.length" class="hero-sched__empty">No regular patrols detected in the last 30 days.</div>
|
|
144
|
+
<div v-for="s in detected" :key="s.route + s.minutes" class="hero-sched__row hero-sched__row--slot">
|
|
145
|
+
<b>{{ s.route }}</b><span>{{ patrolSlotText(s, now) }}</span>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
148
|
+
<template v-else>
|
|
149
|
+
<div class="hero-sched__note">{{ robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes" }}</div>
|
|
150
|
+
<div v-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
151
|
+
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
152
|
+
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
153
|
+
</div>
|
|
139
154
|
</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
155
|
<template v-if="unplacedRoutes.length">
|
|
145
|
-
<div class="hero-
|
|
156
|
+
<div class="hero-sched__note">Patrol routes (stops not placed on the map)</div>
|
|
146
157
|
<div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
|
|
147
158
|
<b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
|
|
148
159
|
</div>
|
|
149
160
|
</template>
|
|
150
161
|
</div>
|
|
162
|
+
<div v-if="robotType === 'security' && caps?.patrolHistory === true">
|
|
163
|
+
<div class="hero-section__title">Recent patrols</div>
|
|
164
|
+
<RobotPatrolHistory :robot-id="robotId" compact />
|
|
165
|
+
</div>
|
|
151
166
|
</div>
|
|
152
167
|
|
|
153
|
-
|
|
168
|
+
<!-- This robot's open alerts (the newest also sits in the red bar on top while it is open). -->
|
|
169
|
+
<section v-if="!noInterface && record" aria-label="Alerts for this robot">
|
|
170
|
+
<div class="hero-section__title">Alerts</div>
|
|
171
|
+
<div v-if="!robotAlerts.length" class="hero-sched__empty">No open alerts for this robot.</div>
|
|
172
|
+
<div v-for="a in robotAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert">
|
|
173
|
+
<b class="hero-detail__alertcode">{{ a.code }}</b><span>{{ a.message }}</span><span>{{ a.since }}</span>
|
|
174
|
+
</div>
|
|
175
|
+
</section>
|
|
154
176
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<
|
|
158
|
-
<div v-
|
|
159
|
-
<
|
|
177
|
+
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
178
|
+
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
179
|
+
<summary class="hero-can__title">What this robot can do</summary>
|
|
180
|
+
<div v-for="r in abilities" :key="r.label" class="hero-can__row">
|
|
181
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
182
|
+
<span>{{ r.value }}</span>
|
|
160
183
|
</div>
|
|
161
|
-
</
|
|
184
|
+
</details>
|
|
162
185
|
|
|
163
186
|
<v-dialog v-model="confirmOpen" width="440">
|
|
164
187
|
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
|
|
165
188
|
<div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
|
|
189
|
+
<v-select
|
|
190
|
+
v-if="confirming.routes?.length"
|
|
191
|
+
v-model="confirming.routeId"
|
|
192
|
+
:items="confirming.routes"
|
|
193
|
+
item-title="name"
|
|
194
|
+
item-value="id"
|
|
195
|
+
label="Patrol route"
|
|
196
|
+
density="compact"
|
|
197
|
+
hide-details
|
|
198
|
+
class="mb-2"
|
|
199
|
+
@update:model-value="pickRoute"
|
|
200
|
+
/>
|
|
166
201
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
167
202
|
<div class="hero-dialog__actions">
|
|
168
203
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
@@ -234,11 +269,16 @@ import { faultPhotos } from "../../utils/robot-control";
|
|
|
234
269
|
import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
235
270
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
236
271
|
import {
|
|
272
|
+
attentionText,
|
|
237
273
|
brandName,
|
|
238
274
|
capabilityLabel,
|
|
239
275
|
cleaningScheduleRows,
|
|
240
276
|
commandBody,
|
|
277
|
+
COMMAND_WATCH_MS,
|
|
241
278
|
commandConfirm,
|
|
279
|
+
commandOutcome,
|
|
280
|
+
patrolResumable,
|
|
281
|
+
patrolStartRoutes,
|
|
242
282
|
commandLabel,
|
|
243
283
|
faultBadge,
|
|
244
284
|
headingDeg,
|
|
@@ -246,7 +286,8 @@ import {
|
|
|
246
286
|
lastKnownLabel,
|
|
247
287
|
mergePose,
|
|
248
288
|
optimisticMode,
|
|
249
|
-
|
|
289
|
+
detectPatrolSchedule,
|
|
290
|
+
patrolSlotText,
|
|
250
291
|
patrolScheduleRows,
|
|
251
292
|
pointCommandBody,
|
|
252
293
|
savedPointGroups,
|
|
@@ -259,7 +300,6 @@ import {
|
|
|
259
300
|
robotErrorMessage,
|
|
260
301
|
robotFacts,
|
|
261
302
|
robotStatus,
|
|
262
|
-
scheduleElsewhereText,
|
|
263
303
|
showSpotClean,
|
|
264
304
|
sinceText,
|
|
265
305
|
speedText,
|
|
@@ -308,9 +348,11 @@ const {
|
|
|
308
348
|
getRobotMapImages,
|
|
309
349
|
resolveRobotAlerts,
|
|
310
350
|
getPatrolHistory,
|
|
351
|
+
getSiteFaults,
|
|
311
352
|
} = useRobots();
|
|
312
353
|
|
|
313
354
|
const record = ref<Record<string, any> | null>(null);
|
|
355
|
+
const topEl = ref<HTMLElement | null>(null);
|
|
314
356
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
315
357
|
const error = ref("");
|
|
316
358
|
const toast = ref("");
|
|
@@ -363,18 +405,29 @@ const faultSince = computed(() => sinceText(fault.value?.since, now.value));
|
|
|
363
405
|
|
|
364
406
|
// Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
|
|
365
407
|
const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
|
|
366
|
-
|
|
367
|
-
const
|
|
408
|
+
// The strip: connection · battery · what it is doing · floor · last seen (model and serial sit in the header).
|
|
409
|
+
const STRIP_ORDER = ["Connection", "Battery", "Robot reports", "Floor", "Last seen"];
|
|
410
|
+
const stripFacts = computed(() =>
|
|
411
|
+
STRIP_ORDER.map((l) => facts.value.find((f) => f.label === l))
|
|
412
|
+
.filter((f): f is NonNullable<typeof f> => !!f)
|
|
413
|
+
.map((f) => (f.label === "Robot reports" ? { ...f, label: "Doing now" } : f)),
|
|
414
|
+
);
|
|
415
|
+
const identity = computed(() => {
|
|
416
|
+
const model = facts.value.find((f) => f.label === "Model")?.value;
|
|
417
|
+
const serial = facts.value.find((f) => f.label === "Serial")?.value;
|
|
418
|
+
return [model, serial ? `Serial ${serial}` : ""].filter(Boolean).join(" · ");
|
|
419
|
+
});
|
|
368
420
|
// The plan's readout already shows the live position; the cell only when there is no plan.
|
|
369
|
-
|
|
421
|
+
// Floor is in the strip; the plan's readout shows the live position, so the cell only when there is no plan.
|
|
422
|
+
const locationFacts = computed(() => facts.value.filter((f) => f.label === "Live position" && !showPlan.value));
|
|
370
423
|
const abilities = computed(() =>
|
|
371
424
|
robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
|
|
372
425
|
);
|
|
373
|
-
const
|
|
426
|
+
const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp : "") || `${brand.value}'s own app`);
|
|
374
427
|
|
|
375
428
|
const online = computed(() => live.value?.ok === true && live.value.online === true);
|
|
376
429
|
const commands = computed(() =>
|
|
377
|
-
visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value }),
|
|
430
|
+
visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value, resumable: resumable.value }),
|
|
378
431
|
);
|
|
379
432
|
|
|
380
433
|
/* ---- the plan ---- */
|
|
@@ -408,7 +461,7 @@ const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capa
|
|
|
408
461
|
const route = computed<TPatrolRouteView | null>(() =>
|
|
409
462
|
selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
|
|
410
463
|
);
|
|
411
|
-
const floorText = computed(() =>
|
|
464
|
+
const floorText = computed(() => facts.value.find((f) => f.label === "Floor")?.value ?? "");
|
|
412
465
|
// 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
466
|
const showPlan = computed(
|
|
414
467
|
() => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
|
|
@@ -433,17 +486,35 @@ const schedule = computed(() => (robotType.value === "security" ? patrolSchedule
|
|
|
433
486
|
const unplacedRoutes = computed(() =>
|
|
434
487
|
(patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
|
|
435
488
|
);
|
|
436
|
-
// The brand keeps its schedules to itself:
|
|
489
|
+
// The brand keeps its schedules to itself (PadBot): detect them from the last 30 days of runs.
|
|
437
490
|
const runs = ref<any[]>([]);
|
|
438
|
-
const
|
|
491
|
+
const runsLoaded = ref(false);
|
|
492
|
+
const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
|
|
493
|
+
const resumable = computed(() => patrolResumable(runs.value));
|
|
494
|
+
/** 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. */
|
|
495
|
+
async function loadRuns() {
|
|
496
|
+
const since = Date.now() - 30 * 86_400_000;
|
|
497
|
+
const all: any[] = [];
|
|
498
|
+
for (let page = 1; page <= 5; page++) {
|
|
499
|
+
const res = await getPatrolHistory(props.robotId, { page, limit: 100 }).catch(() => null);
|
|
500
|
+
const items = res?.items ?? [];
|
|
501
|
+
all.push(...items);
|
|
502
|
+
const oldest = Date.parse(items[items.length - 1]?.startedAt ?? "");
|
|
503
|
+
if (!items.length || page >= (res?.pages ?? 0) || !(oldest >= since)) break;
|
|
504
|
+
}
|
|
505
|
+
runs.value = all;
|
|
506
|
+
runsLoaded.value = true;
|
|
507
|
+
}
|
|
439
508
|
|
|
509
|
+
let runsLoading = false;
|
|
440
510
|
async function loadSchedule() {
|
|
441
511
|
if (!record.value || noInterface.value) return;
|
|
442
512
|
try {
|
|
443
513
|
if (robotType.value === "security") {
|
|
444
514
|
patrol.value = await getPatrolRoutes(props.robotId);
|
|
445
|
-
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !
|
|
446
|
-
|
|
515
|
+
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runsLoaded.value && !runsLoading) {
|
|
516
|
+
runsLoading = true;
|
|
517
|
+
loadRuns().finally(() => (runsLoading = false));
|
|
447
518
|
}
|
|
448
519
|
} else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
|
|
449
520
|
} catch {
|
|
@@ -509,22 +580,54 @@ watch([reportsPosition, online], ([r, o], old) => {
|
|
|
509
580
|
|
|
510
581
|
/* ---- commands ---- */
|
|
511
582
|
const busy = ref<TRobotCommandKind | "spot" | null>(null);
|
|
512
|
-
|
|
583
|
+
/** The last command's real outcome, kept on screen (a toast vanishes in 2.6 s). */
|
|
584
|
+
const commandResult = ref<{ text: string; warn: boolean } | null>(null);
|
|
585
|
+
const watching = ref<{ kind: string; at: number; what?: string } | null>(null);
|
|
586
|
+
watch([lastAt, now], () => {
|
|
587
|
+
const w = watching.value;
|
|
588
|
+
const at = lastAt.value?.getTime() ?? 0;
|
|
589
|
+
// Only a reading taken after the command was accepted counts (never the optimistic status).
|
|
590
|
+
if (!w || at <= w.at + 300) return;
|
|
591
|
+
const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
|
|
592
|
+
if (!out) return;
|
|
593
|
+
watching.value = null;
|
|
594
|
+
commandResult.value = { text: out.text, warn: !out.ok };
|
|
595
|
+
if (!out.ok) optimistic.value = null;
|
|
596
|
+
});
|
|
597
|
+
watch(
|
|
598
|
+
() => props.robotId,
|
|
599
|
+
() => {
|
|
600
|
+
commandResult.value = null;
|
|
601
|
+
watching.value = null;
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
|
|
513
605
|
const name = record.value?.name ?? "the robot";
|
|
514
606
|
const before = statusMode.value;
|
|
515
607
|
busy.value = kind;
|
|
516
608
|
optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
|
|
517
|
-
const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
|
|
609
|
+
const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
|
|
518
610
|
toast.value = `${label} → ${name} · sending…`;
|
|
611
|
+
commandResult.value = { text: `${label} → ${name} · sending…`, warn: false };
|
|
612
|
+
watching.value = null;
|
|
519
613
|
try {
|
|
520
614
|
await sendRobotCommand(props.robotId, body as any);
|
|
521
615
|
// A poll already in flight may still carry the old status; wait past it.
|
|
522
616
|
optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
|
|
523
|
-
toast.value = `${label} → ${name} · accepted
|
|
617
|
+
toast.value = `${label} → ${name} · accepted.`;
|
|
618
|
+
// Resume patrol follows the last run; re-read it once the robot has had time to act.
|
|
619
|
+
if (robotType.value === "security" && caps.value?.patrolHistory === true) setTimeout(loadRuns, 5000);
|
|
620
|
+
// "Accepted" is not "done": watch real readings for up to 30 s (commandOutcome).
|
|
621
|
+
const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
|
|
622
|
+
if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
|
|
623
|
+
watching.value = { kind: String(outcomeKind), at: Date.now(), what };
|
|
624
|
+
commandResult.value = { text: `${label} → ${name} · accepted. Waiting for the robot to move…`, warn: false };
|
|
625
|
+
} else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
|
|
524
626
|
return true;
|
|
525
627
|
} catch (e) {
|
|
526
628
|
optimistic.value = null;
|
|
527
629
|
toast.value = robotErrorMessage(e);
|
|
630
|
+
commandResult.value = { text: `${label} → ${name} · not sent: ${robotErrorMessage(e)}`, warn: true };
|
|
528
631
|
return false;
|
|
529
632
|
} finally {
|
|
530
633
|
busy.value = null;
|
|
@@ -548,6 +651,20 @@ async function send(kind: TRobotCommandKind) {
|
|
|
548
651
|
return;
|
|
549
652
|
}
|
|
550
653
|
}
|
|
654
|
+
// Start patrol (security): pick the route - the robot's own map's routes, first one preselected.
|
|
655
|
+
if (kind === "full" && robotType.value === "security") {
|
|
656
|
+
const routes = patrolStartRoutes(patrol.value?.routes, live.value?.mapId ?? null);
|
|
657
|
+
if (!routes.length && (patrol.value?.routes?.length ?? 0) > 0) {
|
|
658
|
+
commandResult.value = { text: `None of the robot's patrol routes are on ${currentMapLabel.value || "the map it is on"}. Move it to a route's map first.`, warn: true };
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (routes.length) {
|
|
662
|
+
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, routes, routeId: routes[0]!.id };
|
|
663
|
+
pickRoute(routes[0]!.id);
|
|
664
|
+
confirmOpen.value = true;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
551
668
|
const body = commandBody(kind, live.value);
|
|
552
669
|
if (!body) {
|
|
553
670
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -558,11 +675,31 @@ async function send(kind: TRobotCommandKind) {
|
|
|
558
675
|
confirmOpen.value = true;
|
|
559
676
|
}
|
|
560
677
|
const confirmOpen = ref(false);
|
|
561
|
-
const confirming = ref<{
|
|
678
|
+
const confirming = ref<{
|
|
679
|
+
kind: TRobotCommandKind;
|
|
680
|
+
body: Record<string, unknown>;
|
|
681
|
+
text: { title: string; body: string; action: string };
|
|
682
|
+
routes?: TPatrolRouteView[];
|
|
683
|
+
routeId?: string;
|
|
684
|
+
what?: string;
|
|
685
|
+
} | null>(null);
|
|
686
|
+
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
687
|
+
function pickRoute(id: unknown) {
|
|
688
|
+
const c = confirming.value;
|
|
689
|
+
const route = c?.routes?.find((r) => r.id === id);
|
|
690
|
+
if (!c || !route) return;
|
|
691
|
+
const stops = route.stops?.length ? route.stops : route.checkpoints.map((p) => p.name);
|
|
692
|
+
const map = currentMapLabel.value || "";
|
|
693
|
+
const name = route.name.replace(/ \(default\)$/, "");
|
|
694
|
+
c.routeId = route.id;
|
|
695
|
+
c.body = { kind: "area", areas: [route.id] };
|
|
696
|
+
c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
|
|
697
|
+
c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
|
|
698
|
+
}
|
|
562
699
|
function confirmSend() {
|
|
563
700
|
const c = confirming.value;
|
|
564
701
|
confirmOpen.value = false;
|
|
565
|
-
if (c) run(c.kind, c.body);
|
|
702
|
+
if (c) run(c.kind, c.body, c.what);
|
|
566
703
|
}
|
|
567
704
|
|
|
568
705
|
/** The robot's plan image size for its floor, for turning plan clicks into pixels. */
|
|
@@ -608,11 +745,12 @@ function onPick(p: TSavedPoint) {
|
|
|
608
745
|
toast.value = "Set position: the robot must be on this map first.";
|
|
609
746
|
return;
|
|
610
747
|
}
|
|
611
|
-
run(kind, body);
|
|
748
|
+
run(kind, body, kind === "navigate" ? `going to ${p.name}` : undefined);
|
|
612
749
|
}
|
|
613
750
|
|
|
614
751
|
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
615
752
|
const livePanel = ref<{ show(): void } | null>(null);
|
|
753
|
+
const hasCamera = computed(() => caps.value?.liveVideo === true || canDrive.value);
|
|
616
754
|
const driveOpen = ref(false);
|
|
617
755
|
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
618
756
|
const canResolve = computed(
|
|
@@ -631,6 +769,23 @@ async function resolveFault() {
|
|
|
631
769
|
}
|
|
632
770
|
}
|
|
633
771
|
|
|
772
|
+
/* ---- this robot's open alerts: the site's alert list, narrowed to this robot ---- */
|
|
773
|
+
const siteAlerts = ref<any[]>([]);
|
|
774
|
+
const robotAlerts = computed(() =>
|
|
775
|
+
siteAlerts.value
|
|
776
|
+
.filter((a) => String(a?.robot?._id ?? "") === props.robotId)
|
|
777
|
+
.map((a, i) => ({ key: `${a?.code ?? ""}-${i}`, ...attentionText(a), since: a?.since ? `Since ${sinceText(a.since, now.value)}` : "" })),
|
|
778
|
+
);
|
|
779
|
+
async function loadAlerts() {
|
|
780
|
+
const site = String(record.value?.site?._id ?? "");
|
|
781
|
+
if (!site) return;
|
|
782
|
+
try {
|
|
783
|
+
siteAlerts.value = (await getSiteFaults(site, { page: 1, limit: 100 }))?.items ?? [];
|
|
784
|
+
} catch {
|
|
785
|
+
siteAlerts.value = [];
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
634
789
|
/* ---- edit / remove ---- */
|
|
635
790
|
function onSaved() {
|
|
636
791
|
editOpen.value = false;
|
|
@@ -674,9 +829,15 @@ watch(
|
|
|
674
829
|
lastPose.value = null;
|
|
675
830
|
prevPose.value = nextPose.value = null;
|
|
676
831
|
streamLost.value = false;
|
|
832
|
+
runs.value = [];
|
|
833
|
+
runsLoaded.value = false;
|
|
834
|
+
siteAlerts.value = [];
|
|
677
835
|
await loadRecord();
|
|
836
|
+
// Open with the robot's header at the top, so camera + map are on screen (the Super Admin page has pickers above).
|
|
837
|
+
topEl.value?.scrollIntoView?.({ block: "start" });
|
|
678
838
|
loadSchedule();
|
|
679
839
|
loadMaps();
|
|
840
|
+
loadAlerts();
|
|
680
841
|
},
|
|
681
842
|
{ immediate: true },
|
|
682
843
|
);
|
|
@@ -696,6 +857,7 @@ const timer = setInterval(() => {
|
|
|
696
857
|
now.value = Date.now();
|
|
697
858
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
698
859
|
if (++ticks % 8 === 0 && robotType.value === "security") loadSchedule();
|
|
860
|
+
if (ticks % 30 === 0) loadAlerts();
|
|
699
861
|
}, 1000);
|
|
700
862
|
onBeforeUnmount(() => {
|
|
701
863
|
clearInterval(timer);
|
|
@@ -35,11 +35,11 @@ const props = defineProps<{
|
|
|
35
35
|
/** That image's width / height, so the plan keeps its shape. */
|
|
36
36
|
aspect?: number | null;
|
|
37
37
|
}>();
|
|
38
|
-
/** The image's own shape; the detail plan's 440 px
|
|
38
|
+
/** The image's own shape; the detail plan's height cap (--hero-plan-max, 440 px) narrows the width instead of squashing it. */
|
|
39
39
|
const frameStyle = computed(() => {
|
|
40
40
|
const a = props.image && props.aspect && Number.isFinite(props.aspect) && props.aspect > 0 ? props.aspect : null;
|
|
41
41
|
if (!a) return undefined;
|
|
42
|
-
return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%,
|
|
42
|
+
return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%, calc(var(--hero-plan-max, 440px) * ${a.toFixed(4)}))`, maxHeight: "none", marginInline: "auto" } : {}) };
|
|
43
43
|
});
|
|
44
44
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
45
45
|
const root = ref<HTMLElement | null>(null);
|
|
@@ -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.2",
|
|
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-display.ts
CHANGED
|
@@ -404,6 +404,9 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
|
|
|
404
404
|
{ kind: "patrol-resume", label: "Resume patrol" },
|
|
405
405
|
];
|
|
406
406
|
|
|
407
|
+
/** A job is in hand (something for Cancel to stop). */
|
|
408
|
+
const ACTIVE: ReadonlySet<string> = new Set(["WORKING", "IN_TRANSIT", "PAUSED", "MANUAL"]);
|
|
409
|
+
|
|
407
410
|
export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
|
|
408
411
|
|
|
409
412
|
/**
|
|
@@ -412,10 +415,12 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
|
|
|
412
415
|
* - brand: only commands its declared capabilities support;
|
|
413
416
|
* - role: only a Manager (`canCommand`, i.e. the app's command permission);
|
|
414
417
|
* - online: only a robot confirmed online (`online === true`);
|
|
415
|
-
* - context: Pause only while working, Resume only while paused
|
|
418
|
+
* - context: Pause only while working, Resume only while paused, Cancel only
|
|
419
|
+
* while there is a job to stop (working, moving or paused);
|
|
416
420
|
* - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
|
|
417
|
-
* but a brand with `patrolControl` gets Start patrol (
|
|
418
|
-
* Resume patrol
|
|
421
|
+
* but a brand with `patrolControl` gets Start patrol (a route picker) and
|
|
422
|
+
* Resume patrol - only while a patrol is paused or its last run did not
|
|
423
|
+
* finish (`resumable`), never "just in case".
|
|
419
424
|
*
|
|
420
425
|
* Returns [] whenever the bar should not render at all.
|
|
421
426
|
*/
|
|
@@ -425,16 +430,19 @@ export function visibleCommands(input: {
|
|
|
425
430
|
online?: unknown;
|
|
426
431
|
mode?: unknown;
|
|
427
432
|
type?: unknown;
|
|
433
|
+
/** A patrol that can be carried on: the last run did not finish (in progress / partial / failed). */
|
|
434
|
+
resumable?: unknown;
|
|
428
435
|
} | null | undefined): TRobotCommand[] {
|
|
429
436
|
if (!input || input.canCommand !== true || input.online !== true) return [];
|
|
430
437
|
const caps = input.capabilities ?? {};
|
|
431
438
|
const patrol = input.type === "security" && caps.patrolControl === true;
|
|
432
439
|
return COMMANDS.filter((c) => {
|
|
433
|
-
if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
|
|
440
|
+
if (c.kind === "patrol-resume") return patrol && (input.mode === "PAUSED" || (input.resumable === true && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT"));
|
|
434
441
|
if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
|
|
435
442
|
if (c.kind === "pause" && input.mode !== "WORKING") return false;
|
|
436
443
|
if (c.kind === "resume" && input.mode !== "PAUSED") return false;
|
|
437
|
-
if (c.kind === "
|
|
444
|
+
if (c.kind === "cancel" && !ACTIVE.has(String(input.mode))) return false;
|
|
445
|
+
if (c.kind === "full" && input.type === "security") return patrol && !ACTIVE.has(String(input.mode)); // one patrol at a time
|
|
438
446
|
if (c.kind === "area" && input.type === "security") return false;
|
|
439
447
|
return true;
|
|
440
448
|
}).map((c) => ({
|
|
@@ -1156,6 +1164,107 @@ export function patrolRunHint(records: Array<{ routeName?: unknown; startedAt?:
|
|
|
1156
1164
|
return `Looks like "${best.name}" runs ${how} around ${hhmm} (based on the last 7 days)`;
|
|
1157
1165
|
}
|
|
1158
1166
|
|
|
1167
|
+
/* ------------------------------------------ patrol schedule detected from past runs */
|
|
1168
|
+
|
|
1169
|
+
export type TPatrolSlot = {
|
|
1170
|
+
route: string;
|
|
1171
|
+
/** Minutes after local midnight. */
|
|
1172
|
+
minutes: number;
|
|
1173
|
+
hhmm: string;
|
|
1174
|
+
/** "daily" · "every Mon, Thu" · "on 4 of the last 30 days". */
|
|
1175
|
+
days: string;
|
|
1176
|
+
dayCount: number;
|
|
1177
|
+
lastAt: number;
|
|
1178
|
+
/** Next expected start, only while the pattern is still current. */
|
|
1179
|
+
nextAt: number | null;
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
const DAY_MS = 86_400_000;
|
|
1183
|
+
const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
1184
|
+
const hhmmOf = (min: number) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
|
1185
|
+
const localDay = (t: number) => {
|
|
1186
|
+
const d = new Date(t);
|
|
1187
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1188
|
+
};
|
|
1189
|
+
const atMinutes = (day: number, min: number) => {
|
|
1190
|
+
const d = new Date(day);
|
|
1191
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), Math.floor(min / 60), min % 60).getTime();
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* The robot's brand keeps its patrol schedule to itself (PadBot has no
|
|
1196
|
+
* schedule API), so we detect it from the robot's own recent runs: runs of
|
|
1197
|
+
* the same route that start within 30 minutes of each other (local clock) on
|
|
1198
|
+
* at least 2 different days in the last `windowDays` form one slot.
|
|
1199
|
+
* Every day between the first and last run -> "daily"; the same weekdays
|
|
1200
|
+
* repeated -> "every Mon, Thu"; else "on N of the last 30 days". The next
|
|
1201
|
+
* start is only predicted while the pattern is current (daily: ran in the
|
|
1202
|
+
* last 2 days; weekly: in the last 8). A one-off run is never a slot.
|
|
1203
|
+
*/
|
|
1204
|
+
export function detectPatrolSchedule(
|
|
1205
|
+
records: Array<{ routeName?: unknown; startedAt?: unknown }> | null | undefined,
|
|
1206
|
+
now: number = Date.now(),
|
|
1207
|
+
windowDays = 30,
|
|
1208
|
+
): TPatrolSlot[] {
|
|
1209
|
+
const since = now - windowDays * DAY_MS;
|
|
1210
|
+
const byRoute = new Map<string, Array<{ t: number; min: number }>>();
|
|
1211
|
+
for (const r of records ?? []) {
|
|
1212
|
+
const t = typeof r?.startedAt === "string" ? Date.parse(r.startedAt) : NaN;
|
|
1213
|
+
const name = typeof r?.routeName === "string" ? r.routeName.trim() : "";
|
|
1214
|
+
if (!name || !finite(t) || t < since || t > now) continue;
|
|
1215
|
+
const d = new Date(t);
|
|
1216
|
+
byRoute.set(name, [...(byRoute.get(name) ?? []), { t, min: d.getHours() * 60 + d.getMinutes() }]);
|
|
1217
|
+
}
|
|
1218
|
+
const slots: TPatrolSlot[] = [];
|
|
1219
|
+
for (const [route, runs] of byRoute) {
|
|
1220
|
+
// Group by time of day; a gap over 30 min starts a new group (a group may wrap past midnight).
|
|
1221
|
+
const sorted = [...runs].sort((a, b) => a.min - b.min);
|
|
1222
|
+
const groups: Array<typeof sorted> = [];
|
|
1223
|
+
for (const r of sorted) {
|
|
1224
|
+
const g = groups[groups.length - 1];
|
|
1225
|
+
if (g && r.min - g[g.length - 1]!.min <= 30) g.push(r);
|
|
1226
|
+
else groups.push([r]);
|
|
1227
|
+
}
|
|
1228
|
+
if (groups.length > 1) {
|
|
1229
|
+
const first = groups[0]!, last = groups[groups.length - 1]!;
|
|
1230
|
+
if (first[0]!.min + 1440 - last[last.length - 1]!.min <= 30) groups[0] = [...groups.pop()!.map((r) => ({ ...r, min: r.min - 1440 })), ...first];
|
|
1231
|
+
}
|
|
1232
|
+
for (const g of groups) {
|
|
1233
|
+
const days = [...new Set(g.map((r) => localDay(r.t)))].sort((a, b) => a - b);
|
|
1234
|
+
if (days.length < 2) continue;
|
|
1235
|
+
const mins = g.map((r) => r.min).sort((a, b) => a - b);
|
|
1236
|
+
const minutes = (mins[Math.floor((mins.length - 1) / 2)]! + 1440) % 1440;
|
|
1237
|
+
const lastAt = Math.max(...g.map((r) => r.t));
|
|
1238
|
+
const span = Math.round((days[days.length - 1]! - days[0]!) / DAY_MS) + 1;
|
|
1239
|
+
const wdCount = new Map<number, number>();
|
|
1240
|
+
for (const d of days) wdCount.set(new Date(d).getDay(), (wdCount.get(new Date(d).getDay()) ?? 0) + 1);
|
|
1241
|
+
const weekly = span > 7 && wdCount.size <= 5 && [...wdCount.values()].every((n) => n >= 2);
|
|
1242
|
+
const kind = days.length === span ? "daily" : weekly ? "weekly" : "some";
|
|
1243
|
+
const label =
|
|
1244
|
+
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`;
|
|
1245
|
+
let nextAt: number | null = null;
|
|
1246
|
+
if ((kind === "daily" && now - lastAt <= 2 * DAY_MS) || (kind === "weekly" && now - lastAt <= 8 * DAY_MS)) {
|
|
1247
|
+
for (let i = 0; i <= 7 && nextAt === null; i++) {
|
|
1248
|
+
const at = atMinutes(localDay(now) + i * DAY_MS + DAY_MS / 2, minutes); // noon offset survives DST days
|
|
1249
|
+
if (at > now && (kind === "daily" || wdCount.has(new Date(at).getDay()))) nextAt = at;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
slots.push({ route, minutes, hhmm: hhmmOf(minutes), days: label, dayCount: days.length, lastAt, nextAt });
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return slots.sort((a, b) => a.minutes - b.minutes || a.route.localeCompare(b.route));
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** One slot as a line: "daily 20:00 · last ran 19 h ago · next expected 20:00 today". */
|
|
1259
|
+
export function patrolSlotText(slot: TPatrolSlot, now: number = Date.now()): string {
|
|
1260
|
+
const parts = [`${slot.days} ${slot.hhmm}`, `last ran ${agoText(slot.lastAt, now)}`];
|
|
1261
|
+
if (slot.nextAt !== null) {
|
|
1262
|
+
const d = Math.round((localDay(slot.nextAt) - localDay(now)) / DAY_MS);
|
|
1263
|
+
parts.push(`next expected ${slot.hhmm} ${d === 0 ? "today" : d === 1 ? "tomorrow" : WEEKDAY[new Date(slot.nextAt).getDay()]}`);
|
|
1264
|
+
}
|
|
1265
|
+
return parts.join(" · ");
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1159
1268
|
/**
|
|
1160
1269
|
* May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
|
|
1161
1270
|
* that declares `spotClean`, and a robot confirmed online. (A busy robot keeps
|
|
@@ -1544,14 +1653,21 @@ export function scheduleElsewhereText(type: unknown, brand: unknown, profile?: T
|
|
|
1544
1653
|
*/
|
|
1545
1654
|
export function commandConfirm(
|
|
1546
1655
|
kind: unknown,
|
|
1547
|
-
input: { type?: unknown; name?: unknown; point?: unknown } = {},
|
|
1656
|
+
input: { type?: unknown; name?: unknown; point?: unknown; route?: { name?: unknown; stops?: unknown } | null; map?: unknown } = {},
|
|
1548
1657
|
): { title: string; body: string; action: string } {
|
|
1549
1658
|
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1550
1659
|
const at = typeof input.point === "string" && input.point ? input.point : "";
|
|
1551
|
-
const
|
|
1660
|
+
const patrol = input.type === "security" && (kind === "full" || kind === "area");
|
|
1661
|
+
const label = patrol ? "Start patrol" : commandLabel(kind);
|
|
1662
|
+
const route = input.route && typeof input.route.name === "string" ? input.route : null;
|
|
1663
|
+
const stops = Array.isArray(route?.stops) ? (route!.stops as unknown[]).filter((s) => typeof s === "string" && s) : [];
|
|
1664
|
+
const onMap = typeof input.map === "string" && input.map ? ` on ${input.map}` : "";
|
|
1665
|
+
const walk = route
|
|
1666
|
+
? `${who} walks "${route.name}" once${stops.length ? ` (${stops.join(" → ")})` : ""}${onMap}, then stops.`
|
|
1667
|
+
: `${who} starts walking its default patrol route once.`;
|
|
1552
1668
|
const body: Record<string, string> = {
|
|
1553
|
-
full: input.type === "security" ?
|
|
1554
|
-
area: `${who} starts cleaning the chosen areas.`,
|
|
1669
|
+
full: input.type === "security" ? walk : `${who} starts cleaning the whole floor it is on.`,
|
|
1670
|
+
area: input.type === "security" ? walk : `${who} starts cleaning the chosen areas.`,
|
|
1555
1671
|
return: `${who} stops what it is doing and drives back to its charger.`,
|
|
1556
1672
|
pause: `${who} stops where it is and waits. Press Resume to carry on.`,
|
|
1557
1673
|
resume: `${who} carries on with the job it paused.`,
|
|
@@ -1565,6 +1681,72 @@ export function commandConfirm(
|
|
|
1565
1681
|
return { title: `${label}?`, body: body[String(kind)] ?? `${who}: ${label}.`, action: label };
|
|
1566
1682
|
}
|
|
1567
1683
|
|
|
1684
|
+
/**
|
|
1685
|
+
* Start patrol's route choices: the routes on the map the robot is on (a route
|
|
1686
|
+
* on another map is refused by the server - the robot cannot walk it from
|
|
1687
|
+
* here). Unknown current map, or routes that say no map: all of them. The
|
|
1688
|
+
* default pick is the first (the server lists the walked / current-map /
|
|
1689
|
+
* default route first).
|
|
1690
|
+
*/
|
|
1691
|
+
export function patrolStartRoutes(routes: TPatrolRouteView[] | null | undefined, currentMapId: unknown): TPatrolRouteView[] {
|
|
1692
|
+
const all = (Array.isArray(routes) ? routes : []).filter((r) => r && typeof r.id === "string" && r.id);
|
|
1693
|
+
if (typeof currentMapId !== "string" || !currentMapId) return all;
|
|
1694
|
+
return all.filter((r) => !r.mapId || r.mapId === currentMapId);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/** Last patrol run not finished, so Resume patrol has something to carry on (PadBot §6.7 completionState 0/1/3). */
|
|
1698
|
+
export function patrolResumable(runs: Array<{ result?: unknown }> | null | undefined): boolean {
|
|
1699
|
+
const r = Array.isArray(runs) ? runs[0]?.result : undefined;
|
|
1700
|
+
return r === "in-progress" || r === "partial" || r === "failed";
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** How long after "accepted" we wait for the robot to show it acted. */
|
|
1704
|
+
export const COMMAND_WATCH_MS = 30_000;
|
|
1705
|
+
|
|
1706
|
+
const GOING = ["WORKING", "IN_TRANSIT", "PAUSED"];
|
|
1707
|
+
const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
1708
|
+
full: new Set(GOING),
|
|
1709
|
+
area: new Set(GOING),
|
|
1710
|
+
"patrol-resume": new Set(GOING),
|
|
1711
|
+
"go-to-point": new Set(GOING),
|
|
1712
|
+
navigate: new Set(GOING),
|
|
1713
|
+
return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
|
|
1714
|
+
};
|
|
1715
|
+
const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* What an accepted command actually did, from a reading taken AFTER it was
|
|
1719
|
+
* sent (never the optimistic status). The robot cloud's "accepted" is not
|
|
1720
|
+
* "done": PadBot answers code 0 and the robot may still not move.
|
|
1721
|
+
* - started: the robot shows it acted -> `${who} started ${what}.`
|
|
1722
|
+
* - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
|
|
1723
|
+
* (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
|
|
1724
|
+
* - otherwise: keep watching (null).
|
|
1725
|
+
* Commands without a visible outcome (cancel, set position, pause) -> null.
|
|
1726
|
+
*/
|
|
1727
|
+
export function commandOutcome(
|
|
1728
|
+
kind: unknown,
|
|
1729
|
+
reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
|
|
1730
|
+
elapsedMs: number,
|
|
1731
|
+
input: { name?: unknown; what?: unknown } = {},
|
|
1732
|
+
): { ok: boolean; text: string } | null {
|
|
1733
|
+
const started = STARTED[String(kind)];
|
|
1734
|
+
if (!started || !reading) return null;
|
|
1735
|
+
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1736
|
+
const what = typeof input.what === "string" && input.what ? input.what : kind === "return" ? "going back to its charger" : "moving";
|
|
1737
|
+
if (typeof reading.mode === "string" && started.has(reading.mode)) return { ok: true, text: `${who} started ${what}.` };
|
|
1738
|
+
if (elapsedMs < COMMAND_WATCH_MS) return null;
|
|
1739
|
+
const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
|
|
1740
|
+
const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
|
|
1741
|
+
const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
|
|
1742
|
+
return {
|
|
1743
|
+
ok: false,
|
|
1744
|
+
text: reason
|
|
1745
|
+
? `${who} didn't start — ${reason}.`
|
|
1746
|
+
: `${who} didn't start — the robot cloud accepted the command, but the robot is still ${said ? said.toLowerCase() : "idle"}. Check the robot or its maker's app.`,
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1568
1750
|
/** The Brand column: "PadBot S2", "Demo S1 (simulated)", or just "Ounitech" when no model is reported. */
|
|
1569
1751
|
export function brandModelText(
|
|
1570
1752
|
brand: unknown,
|