@7365admin1/layer-common 4.76.0 → 4.77.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 +10 -4
- package/assets/css/robot-hero.css +9 -0
- package/components/Robot/CleaningRecords.vue +122 -0
- package/components/Robot/Detail.vue +55 -1
- package/components/Robot/LivePlan.vue +7 -1
- package/components/Robot/SiteFleet.vue +6 -5
- package/composables/useRobots.ts +6 -0
- package/package.json +1 -1
- package/utils/robot-display.ts +2 -1
- package/utils/robot-maps.ts +13 -1
- package/utils/robot-records.ts +135 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.77.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e3682f3: Robot detail: an Ounitech robot shows its real floor map with its area layer, area names and live pin ("position approximate" until Ounitech confirms the map corner); Clean area picks the areas to clean; new records card: recent cleaning jobs, alert history, every robot and base-station reading, parts wear, timed switch on/off and lifts. The site mini-map shows cleaning robots on their own map.
|
|
8
|
+
|
|
3
9
|
## 4.76.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1914,11 +1920,11 @@
|
|
|
1914
1920
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1915
1921
|
own rule from two endpoints the console already calls, unprojected:
|
|
1916
1922
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1923
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1924
|
+
GET /api/roles/id/:role that membership's role document
|
|
1919
1925
|
|
|
1920
|
-
|
|
1921
|
-
|
|
1926
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1927
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1922
1928
|
|
|
1923
1929
|
`role.default` is the marker because it is the only property of a platform
|
|
1924
1930
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -670,6 +670,15 @@
|
|
|
670
670
|
.hero-mapsel__hint { font-size: 12px; opacity: 0.75; }
|
|
671
671
|
.hero-plan__pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
|
|
672
672
|
.hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
|
|
673
|
+
.hero-plan__overlay { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.35; }
|
|
674
|
+
.hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
|
|
675
|
+
.hero-rec { margin-top: 16px; }
|
|
676
|
+
.hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
677
|
+
.hero-rec__table th { text-align: left; font-weight: 600; font-size: 12px; opacity: 0.75; padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); }
|
|
678
|
+
.hero-rec__table td { padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); vertical-align: top; }
|
|
679
|
+
.hero-rec__wrap { overflow-x: auto; }
|
|
680
|
+
.hero-rec__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 2px 16px; }
|
|
681
|
+
.hero-rec__attn { color: var(--hero-accent); font-weight: 600; }
|
|
673
682
|
.hero-plan:fullscreen { background-color: var(--hero-bg); }
|
|
674
683
|
.hero-plan__corner { position: absolute; width: 14px; height: 14px; }
|
|
675
684
|
.hero-plan__corner--tl { left: -1px; top: -1px; border-top: 2px solid var(--hero-accent); border-left: 2px solid var(--hero-accent); }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
|
|
4
|
+
jobs, alert history, then - folded - every reading the robot sends, parts
|
|
5
|
+
wear, the power schedule and lifts. Brands without records render nothing.
|
|
6
|
+
A part the maker refused is said in words, never left blank.
|
|
7
|
+
-->
|
|
8
|
+
<section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
|
|
9
|
+
<div class="hero-aside__head">
|
|
10
|
+
<span class="hero-section__title">Recent cleaning jobs</span>
|
|
11
|
+
<span class="hero-aside__hint">last 14 days · from {{ makerApp }}</span>
|
|
12
|
+
<span class="hero-head__spacer" />
|
|
13
|
+
<v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
|
|
14
|
+
</div>
|
|
15
|
+
<div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
|
|
16
|
+
<div v-if="!jobs.length" class="hero-sched__empty">No cleaning jobs in the last 14 days.</div>
|
|
17
|
+
<div v-else class="hero-rec__wrap">
|
|
18
|
+
<table class="hero-rec__table">
|
|
19
|
+
<thead>
|
|
20
|
+
<tr><th>Started</th><th>Area</th><th>Job</th><th>Took</th><th>Cleaned</th><th>Done</th><th>Result</th><th>Started by</th></tr>
|
|
21
|
+
</thead>
|
|
22
|
+
<tbody>
|
|
23
|
+
<tr v-for="j in shownJobs" :key="j.key">
|
|
24
|
+
<td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}</td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
|
|
25
|
+
</tr>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
<v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="hero-section__title mt-4">Alert history</div>
|
|
32
|
+
<div class="hero-sched__note">What the robot reported in the last 30 days (open alerts are listed above).</div>
|
|
33
|
+
<div v-if="!alerts.length" class="hero-sched__empty">No alerts in the last 30 days.</div>
|
|
34
|
+
<div v-for="a in shownAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert" :title="a.vendorText">
|
|
35
|
+
<b class="hero-detail__alertcode">{{ a.code }}</b>
|
|
36
|
+
<span>{{ a.level ? `${a.level} · ` : "" }}{{ a.message }}</span>
|
|
37
|
+
<span>{{ a.when }} · {{ a.state }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
|
|
40
|
+
|
|
41
|
+
<details class="hero-can hero-can--fold" :open="attention.length > 0">
|
|
42
|
+
<summary class="hero-can__title">
|
|
43
|
+
Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
|
|
44
|
+
</summary>
|
|
45
|
+
<div v-if="!groups.length" class="hero-sched__empty">The robot sent no readings.</div>
|
|
46
|
+
<div v-for="g in groups" :key="g.group" class="mb-2">
|
|
47
|
+
<div class="hero-sched__note">{{ g.group }}</div>
|
|
48
|
+
<div class="hero-rec__grid">
|
|
49
|
+
<div v-for="r in g.rows" :key="r.key" class="hero-can__row">
|
|
50
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
51
|
+
<span :class="{ 'hero-rec__attn': r.attention }">{{ r.value }}</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<details class="hero-can hero-can--fold">
|
|
58
|
+
<summary class="hero-can__title">Parts wear, power schedule and lifts</summary>
|
|
59
|
+
<div class="hero-sched__note">Parts wear (hours worked since last reset in {{ makerApp }})</div>
|
|
60
|
+
<div v-if="!parts.length" class="hero-sched__empty">No parts reported.</div>
|
|
61
|
+
<div v-for="p in parts" :key="p.key" class="hero-sched__row"><b>{{ p.part }}</b><span>{{ p.worked }}</span><span>{{ p.days }}</span></div>
|
|
62
|
+
<div class="hero-sched__note mt-2">Timed switch on / off</div>
|
|
63
|
+
<div v-if="!power.length" class="hero-sched__empty">No timed switch on / off.</div>
|
|
64
|
+
<div v-for="p in power" :key="p.key" class="hero-sched__row">
|
|
65
|
+
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.action }}{{ p.enabled ? "" : " · off" }}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="hero-sched__note mt-2">Lifts linked in {{ makerApp }}</div>
|
|
68
|
+
<div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
|
|
69
|
+
<div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
|
|
70
|
+
</details>
|
|
71
|
+
</section>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
import { computed, ref, watch } from "vue";
|
|
76
|
+
import { robotErrorMessage } from "../../utils/robot-display";
|
|
77
|
+
import { alertHistoryRows, attentionReadings, jobRows, liftRows, partRows, powerRows, readingGroups, recordErrors } from "../../utils/robot-records";
|
|
78
|
+
|
|
79
|
+
const props = defineProps<{ robotId: string; makerApp: string }>();
|
|
80
|
+
const { getRobotRecords } = useRobots();
|
|
81
|
+
|
|
82
|
+
const records = ref<Record<string, any> | null>(null);
|
|
83
|
+
const loading = ref(false);
|
|
84
|
+
const allJobs = ref(false);
|
|
85
|
+
const allAlerts = ref(false);
|
|
86
|
+
|
|
87
|
+
// ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
|
|
88
|
+
async function load(force = false) {
|
|
89
|
+
if (loading.value && !force) return;
|
|
90
|
+
loading.value = true;
|
|
91
|
+
try {
|
|
92
|
+
records.value = await getRobotRecords(props.robotId);
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
// An API without the records route (404) shows no card; any other failure is said.
|
|
95
|
+
const status = e?.statusCode ?? e?.status ?? e?.response?.status;
|
|
96
|
+
records.value = status === 404 ? null : { supported: true, error: `Couldn't read the records: ${robotErrorMessage(e)}` };
|
|
97
|
+
} finally {
|
|
98
|
+
loading.value = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
watch(
|
|
102
|
+
() => props.robotId,
|
|
103
|
+
() => {
|
|
104
|
+
records.value = null;
|
|
105
|
+
allJobs.value = false;
|
|
106
|
+
allAlerts.value = false;
|
|
107
|
+
load(true);
|
|
108
|
+
},
|
|
109
|
+
{ immediate: true },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const jobs = computed(() => jobRows(records.value));
|
|
113
|
+
const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
|
|
114
|
+
const alerts = computed(() => alertHistoryRows(records.value));
|
|
115
|
+
const shownAlerts = computed(() => (allAlerts.value ? alerts.value : alerts.value.slice(0, 5)));
|
|
116
|
+
const groups = computed(() => readingGroups(records.value));
|
|
117
|
+
const attention = computed(() => attentionReadings(records.value));
|
|
118
|
+
const parts = computed(() => partRows(records.value));
|
|
119
|
+
const power = computed(() => powerRows(records.value));
|
|
120
|
+
const lifts = computed(() => liftRows(records.value));
|
|
121
|
+
const errors = computed(() => recordErrors(records.value));
|
|
122
|
+
</script>
|
|
@@ -111,12 +111,17 @@
|
|
|
111
111
|
:image="selectedMap?.imageUrl ?? null"
|
|
112
112
|
:aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
|
|
113
113
|
:points="selectedMap?.points ?? []"
|
|
114
|
+
:overlay="selectedMap?.overlay ?? null"
|
|
115
|
+
:areas="selectedMap?.areas ?? []"
|
|
114
116
|
:route-moving="statusMode === 'WORKING' && robotOnShownMap"
|
|
115
117
|
:spot="spot"
|
|
116
118
|
:pick="spotOn"
|
|
117
119
|
:tick-ms="tickMs"
|
|
118
120
|
@pick="(p) => spot.push(p)"
|
|
119
121
|
/>
|
|
122
|
+
<p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
|
|
123
|
+
Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
|
|
124
|
+
</p>
|
|
120
125
|
<div v-if="spotOn" class="hero-spotbar">
|
|
121
126
|
<v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
|
|
122
127
|
Send spot clean · {{ spot.length }} points
|
|
@@ -187,6 +192,9 @@
|
|
|
187
192
|
</div>
|
|
188
193
|
</section>
|
|
189
194
|
|
|
195
|
+
<!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
|
|
196
|
+
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" />
|
|
197
|
+
|
|
190
198
|
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
191
199
|
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
192
200
|
<summary class="hero-can__title">What this robot can do</summary>
|
|
@@ -199,6 +207,19 @@
|
|
|
199
207
|
<v-dialog v-model="confirmOpen" width="440">
|
|
200
208
|
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
|
|
201
209
|
<div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
|
|
210
|
+
<v-select
|
|
211
|
+
v-if="confirming.zones?.length"
|
|
212
|
+
v-model="confirming.zoneNames"
|
|
213
|
+
:items="confirming.zones"
|
|
214
|
+
label="Areas to clean"
|
|
215
|
+
multiple
|
|
216
|
+
chips
|
|
217
|
+
closable-chips
|
|
218
|
+
density="compact"
|
|
219
|
+
hide-details
|
|
220
|
+
class="mb-2"
|
|
221
|
+
@update:model-value="pickZones"
|
|
222
|
+
/>
|
|
202
223
|
<v-select
|
|
203
224
|
v-if="confirming.routes?.length"
|
|
204
225
|
v-model="confirming.routeId"
|
|
@@ -214,7 +235,7 @@
|
|
|
214
235
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
215
236
|
<div class="hero-dialog__actions">
|
|
216
237
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
217
|
-
<v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
|
|
238
|
+
<v-btn color="primary" variant="flat" :disabled="!!confirming.zones && !confirming.zoneNames?.length" @click="confirmSend">{{ confirming.text.action }}</v-btn>
|
|
218
239
|
</div>
|
|
219
240
|
</div>
|
|
220
241
|
</v-dialog>
|
|
@@ -272,6 +293,7 @@ import RobotNoMonitoring from "./NoMonitoring.vue";
|
|
|
272
293
|
import RobotCommandBar from "./CommandBar.vue";
|
|
273
294
|
import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
|
|
274
295
|
import RobotLivePlan from "./LivePlan.vue";
|
|
296
|
+
import RobotCleaningRecords from "./CleaningRecords.vue";
|
|
275
297
|
import RobotToast from "./Toast.vue";
|
|
276
298
|
import RobotForm from "./Form.vue";
|
|
277
299
|
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
@@ -495,6 +517,10 @@ const planTitle = computed(() => {
|
|
|
495
517
|
const planHint = computed(() => {
|
|
496
518
|
if (spotOn.value) return "Click points on the plan, then send";
|
|
497
519
|
if (noInterface.value) return "No position feed for this model";
|
|
520
|
+
if (selectedMap.value && robotType.value !== "security") {
|
|
521
|
+
const n = selectedMap.value.areas.length;
|
|
522
|
+
return `${n} cleaning area${n === 1 ? "" : "s"}`;
|
|
523
|
+
}
|
|
498
524
|
if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
|
|
499
525
|
if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
|
|
500
526
|
return ""; // the Floor cell above already says it
|
|
@@ -689,6 +715,19 @@ async function send(kind: TRobotCommandKind) {
|
|
|
689
715
|
return;
|
|
690
716
|
}
|
|
691
717
|
}
|
|
718
|
+
// Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
|
|
719
|
+
if (kind === "area" && robotType.value !== "security") {
|
|
720
|
+
const map = mapViews.value.find((m) => m.current) ?? selectedMap.value;
|
|
721
|
+
const zones = map?.areas.map((a) => a.name) ?? [];
|
|
722
|
+
if (!zones.length) {
|
|
723
|
+
commandResult.value = { text: `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp.value}.`, warn: true };
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
|
|
727
|
+
pickZones([zones[0]!]);
|
|
728
|
+
confirmOpen.value = true;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
692
731
|
const body = commandBody(kind, live.value);
|
|
693
732
|
if (!body) {
|
|
694
733
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -705,8 +744,23 @@ const confirming = ref<{
|
|
|
705
744
|
text: { title: string; body: string; action: string };
|
|
706
745
|
routes?: TPatrolRouteView[];
|
|
707
746
|
routeId?: string;
|
|
747
|
+
/** Clean area: the map's area names, and the ones picked. */
|
|
748
|
+
zones?: string[];
|
|
749
|
+
zoneNames?: string[];
|
|
708
750
|
what?: string;
|
|
709
751
|
} | null>(null);
|
|
752
|
+
/** The areas picked in Clean area's confirm: the body names them and the sentence says them. */
|
|
753
|
+
function pickZones(names: unknown) {
|
|
754
|
+
const c = confirming.value;
|
|
755
|
+
if (!c?.zones) return;
|
|
756
|
+
const picked = (Array.isArray(names) ? names : []).filter((n): n is string => typeof n === "string" && c.zones!.includes(n));
|
|
757
|
+
const list = picked.length > 2 ? `${picked.slice(0, -1).join(", ")} and ${picked.at(-1)}` : picked.join(" and ");
|
|
758
|
+
c.zoneNames = picked;
|
|
759
|
+
c.body = { kind: "area", areas: picked };
|
|
760
|
+
c.what = picked.length ? `cleaning ${list}` : undefined;
|
|
761
|
+
const text = commandConfirm("area", { type: robotType.value, name: record.value?.name });
|
|
762
|
+
c.text = { ...text, body: picked.length ? `${record.value?.name ?? "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area." };
|
|
763
|
+
}
|
|
710
764
|
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
711
765
|
function pickRoute(id: unknown) {
|
|
712
766
|
const c = confirming.value;
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
is lost - never a frozen dot.
|
|
10
10
|
-->
|
|
11
11
|
<RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
|
|
12
|
+
<img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
|
|
13
|
+
<span v-for="a in placedAreas" :key="a.name" class="hero-plan__area" :style="{ left: `${a.at!.x}%`, top: `${a.at!.y}%` }">{{ a.name }}</span>
|
|
12
14
|
<template v-for="p in points" :key="p.targetPointId">
|
|
13
15
|
<span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
|
|
14
16
|
<span class="hero-plan__ptname" :style="{ left: `${p.x}%`, top: `${p.y}%` }">{{ p.name }}</span>
|
|
@@ -56,6 +58,9 @@ const props = withDefaults(
|
|
|
56
58
|
/** The map shown: the robot's own image and its width / height. */
|
|
57
59
|
image?: string | null;
|
|
58
60
|
aspect?: number | null;
|
|
61
|
+
/** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
|
|
62
|
+
overlay?: string | null;
|
|
63
|
+
areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
59
64
|
/** Saved points on the map shown (0-100 shares). */
|
|
60
65
|
points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
|
|
61
66
|
/** The route's dash walks only while the robot patrols. */
|
|
@@ -67,8 +72,9 @@ const props = withDefaults(
|
|
|
67
72
|
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
68
73
|
stale?: boolean;
|
|
69
74
|
}>(),
|
|
70
|
-
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
75
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
71
76
|
);
|
|
77
|
+
const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
|
|
72
78
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
73
79
|
// The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
|
|
74
80
|
const tagClass = computed(() => {
|
|
@@ -246,19 +246,20 @@ function seenText(row: any) {
|
|
|
246
246
|
|
|
247
247
|
/* ---- patrol routes for the security robots on screen ---- */
|
|
248
248
|
const routes = ref<Record<string, TPatrolRouteView[]>>({});
|
|
249
|
-
/** Each
|
|
249
|
+
/** Each robot's own maps (PadBot / Ounitech map images), read once per robot. */
|
|
250
|
+
// ponytail: read once; the maker signs image URLs for ~1 h, so a page left open longer needs a reload.
|
|
250
251
|
const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
|
|
251
252
|
async function loadRoutes() {
|
|
252
|
-
if (props.type !== "security") {
|
|
253
|
-
routes.value = {};
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
253
|
const missing = ids.value.filter((id) => !(id in robotMaps.value));
|
|
257
254
|
if (missing.length) {
|
|
258
255
|
const got: Record<string, Array<Record<string, any>>> = {};
|
|
259
256
|
await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
|
|
260
257
|
robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
|
|
261
258
|
}
|
|
259
|
+
if (props.type !== "security") {
|
|
260
|
+
routes.value = {};
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
262
263
|
const next: Record<string, TPatrolRouteView[]> = {};
|
|
263
264
|
await Promise.allSettled(
|
|
264
265
|
ids.value.map(async (id) => {
|
package/composables/useRobots.ts
CHANGED
|
@@ -150,6 +150,11 @@ export default function useRobots() {
|
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Cleaning jobs, alert history, consumables, power schedule, lifts and every reading (read-only; `supported: false` for brands without). */
|
|
154
|
+
function getRobotRecords(id: string) {
|
|
155
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/records`, { method: "GET", query: { appSlug } });
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
/**
|
|
154
159
|
* Signed image URLs for a map's layers.
|
|
155
160
|
*
|
|
@@ -592,6 +597,7 @@ export default function useRobots() {
|
|
|
592
597
|
getRobotState,
|
|
593
598
|
getRobotMaps,
|
|
594
599
|
getRobotMapImages,
|
|
600
|
+
getRobotRecords,
|
|
595
601
|
getRobotPlans,
|
|
596
602
|
sendRobotCommand,
|
|
597
603
|
getLiveVideo,
|
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.77.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-display.ts
CHANGED
|
@@ -1692,7 +1692,8 @@ export function robotAbilities(input: {
|
|
|
1692
1692
|
on("floorReporting") && "floor",
|
|
1693
1693
|
on("readLocation") && "live position",
|
|
1694
1694
|
on("thermalCamera") && "heat readings",
|
|
1695
|
-
on("waterTanks") && "water levels
|
|
1695
|
+
on("waterTanks") && "water levels",
|
|
1696
|
+
on("waterTanks") && "tank checks",
|
|
1696
1697
|
on("patrolHistory") && (on("thermalCamera") ? "past patrols with photos and temperatures" : "past patrols with photos"),
|
|
1697
1698
|
].filter(Boolean) as string[];
|
|
1698
1699
|
const p = input?.profile ?? {};
|
package/utils/robot-maps.ts
CHANGED
|
@@ -22,6 +22,12 @@ export type TRobotMapView = {
|
|
|
22
22
|
routeNames: string[];
|
|
23
23
|
/** Saved points placed on this map's image (0-100 from its top-left). */
|
|
24
24
|
points: TRobotMapPoint[];
|
|
25
|
+
/** The maker's coloured area layer (Ounitech partition image), drawn over the map; null when none. */
|
|
26
|
+
overlay: string | null;
|
|
27
|
+
/** The map's cleaning areas by name (Clean area picks from these); `at` = label spot, null when unknown. */
|
|
28
|
+
areas: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
29
|
+
/** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
|
|
30
|
+
approximate: boolean;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
@@ -47,7 +53,8 @@ export function robotMapShare(map: Record<string, any> | null | undefined, wx: u
|
|
|
47
53
|
* `/robots/:id/maps` items (PadBot: `mapId` + `imageUrl`) as selector entries,
|
|
48
54
|
* in PadBot's order. Named by PadBot's display name; without one, "Map N"
|
|
49
55
|
* with its floor, else its first route's name, as the hint ("Map 1 (B1)").
|
|
50
|
-
*
|
|
56
|
+
* Ounitech maps arrive in the same shape (the API adds mapId / imageUrl / size),
|
|
57
|
+
* plus their area layer, area names and "position approximate".
|
|
51
58
|
*/
|
|
52
59
|
export function robotMapViews(
|
|
53
60
|
items: Array<Record<string, any>> | null | undefined,
|
|
@@ -78,6 +85,11 @@ export function robotMapViews(
|
|
|
78
85
|
current: !!currentMapId && m.mapId === currentMapId,
|
|
79
86
|
routeNames,
|
|
80
87
|
points,
|
|
88
|
+
overlay: typeof m.partitionImageUrl === "string" && /^https?:\/\//i.test(m.partitionImageUrl) ? m.partitionImageUrl : null,
|
|
89
|
+
areas: (Array.isArray(m.areas) ? m.areas : [])
|
|
90
|
+
.filter((a: any) => typeof a?.name === "string" && a.name.trim())
|
|
91
|
+
.map((a: any) => ({ name: String(a.name).trim(), at: a.at && isNum(a.at.x) && isNum(a.at.y) ? { x: a.at.x, y: a.at.y } : null })),
|
|
92
|
+
approximate: m.positionApproximate === true,
|
|
81
93
|
};
|
|
82
94
|
});
|
|
83
95
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robot detail's records card (`/robots/:id/records`): cleaning jobs, alert
|
|
3
|
+
* history, parts wear, power schedule, lifts and every reading the robot
|
|
4
|
+
* sends, as display rows. Pure helpers - no Vue, no Node globals.
|
|
5
|
+
*/
|
|
6
|
+
import { dayRange } from "./robot-display.ts";
|
|
7
|
+
|
|
8
|
+
type TPart<T> = { items?: T[]; error?: string } | null | undefined;
|
|
9
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
10
|
+
const items = <T>(p: TPart<T>): T[] => (Array.isArray(p?.items) ? p!.items! : []);
|
|
11
|
+
|
|
12
|
+
/** "45 s", "16 min", "1 h 5 min"; "—" when unknown. */
|
|
13
|
+
export function durationText(sec: unknown): string {
|
|
14
|
+
if (!isNum(sec) || sec < 0) return "—";
|
|
15
|
+
if (sec < 60) return `${Math.round(sec)} s`;
|
|
16
|
+
const min = Math.round(sec / 60);
|
|
17
|
+
return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : ""}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
21
|
+
|
|
22
|
+
/** An ISO time as "30 Sep, 08:15" in the viewer's zone (or `timeZone`); "—" when unknown. */
|
|
23
|
+
export function whenText(iso: unknown, timeZone?: string): string {
|
|
24
|
+
const t = typeof iso === "string" ? Date.parse(iso) : NaN;
|
|
25
|
+
if (!Number.isFinite(t)) return "—";
|
|
26
|
+
const parts = Object.fromEntries(
|
|
27
|
+
new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", ...(timeZone ? { timeZone } : {}) })
|
|
28
|
+
.formatToParts(t)
|
|
29
|
+
.map((p) => [p.type, p.value]),
|
|
30
|
+
);
|
|
31
|
+
return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TJobRow = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
|
|
35
|
+
|
|
36
|
+
/** Cleaning jobs, newest first as the API sends them. */
|
|
37
|
+
export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
|
|
38
|
+
return items(records?.tasks).map((t, i) => {
|
|
39
|
+
const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
|
|
40
|
+
const area = [t.map, floor].filter(Boolean).join(" · ") || "—";
|
|
41
|
+
const size = isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—";
|
|
42
|
+
return {
|
|
43
|
+
key: `${t.id ?? ""}-${i}`,
|
|
44
|
+
when: whenText(t.start, timeZone),
|
|
45
|
+
area,
|
|
46
|
+
job: [t.job, t.mode].filter(Boolean).join(" · ") || "—",
|
|
47
|
+
duration: durationText(t.durationSec),
|
|
48
|
+
size,
|
|
49
|
+
done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
|
|
50
|
+
result: t.result || "—",
|
|
51
|
+
by: t.source || "—",
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TAlertRow = { key: string; when: string; code: string; level: string; message: string; state: string; vendorText: string };
|
|
57
|
+
|
|
58
|
+
export function alertHistoryRows(records: { alerts?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TAlertRow[] {
|
|
59
|
+
return items(records?.alerts).map((a, i) => ({
|
|
60
|
+
key: `${a.code ?? ""}-${a.at ?? ""}-${i}`,
|
|
61
|
+
when: whenText(a.at, timeZone),
|
|
62
|
+
code: a.code ? String(a.code) : "—",
|
|
63
|
+
level: a.level || "",
|
|
64
|
+
message: a.message || a.vendorText || "—",
|
|
65
|
+
state: a.resolved ? "Resolved" : "Reported",
|
|
66
|
+
vendorText: a.vendorText && a.vendorText !== a.message ? String(a.vendorText) : "",
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Readings grouped in the API's order ("Battery", "Water", ...), base station last. */
|
|
71
|
+
export function readingGroups(records: { readings?: TPart<Record<string, any>>; station?: TPart<Record<string, any>> } | null | undefined) {
|
|
72
|
+
const rows = [...items(records?.readings), ...items(records?.station).flatMap((s) => (Array.isArray(s?.readings) ? s.readings : []))];
|
|
73
|
+
const status = items(records?.station)[0]?.status;
|
|
74
|
+
if (status) rows.push({ key: "station-status", group: "Base station", label: "Station status", value: String(status), attention: false });
|
|
75
|
+
const groups: Array<{ group: string; rows: Array<{ key: string; label: string; value: string; attention: boolean }> }> = [];
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
const g = String(r?.group || "Other readings");
|
|
78
|
+
let hit = groups.find((x) => x.group === g);
|
|
79
|
+
if (!hit) groups.push((hit = { group: g, rows: [] }));
|
|
80
|
+
hit.rows.push({ key: String(r.key ?? r.label), label: String(r.label ?? r.key), value: String(r.value ?? ""), attention: r.attention === true });
|
|
81
|
+
}
|
|
82
|
+
return groups;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Readings that need a person ("Dust box fitted: No"), for the card's one-line summary. */
|
|
86
|
+
export function attentionReadings(records: Parameters<typeof readingGroups>[0]): string[] {
|
|
87
|
+
return readingGroups(records).flatMap((g) => g.rows.filter((r) => r.attention).map((r) => `${r.label}: ${r.value}`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function partRows(records: { consumables?: TPart<Record<string, any>> } | null | undefined) {
|
|
91
|
+
return items(records?.consumables).map((c, i) => ({
|
|
92
|
+
key: `${c.part}-${i}`,
|
|
93
|
+
part: String(c.part ?? "Part"),
|
|
94
|
+
worked: isNum(c.hours) ? `${c.hours} h` : "—",
|
|
95
|
+
days: isNum(c.days) ? `${c.days} days` : "—",
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function powerRows(records: { power?: TPart<Record<string, any>> } | null | undefined) {
|
|
100
|
+
return items(records?.power).map((p, i) => ({
|
|
101
|
+
key: `${p.time}-${i}`,
|
|
102
|
+
days: dayRange(p.days),
|
|
103
|
+
time: p.time || "—",
|
|
104
|
+
action: p.action === "on" ? "Switch on" : "Switch off",
|
|
105
|
+
enabled: p.enabled !== false,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function liftRows(records: { lifts?: TPart<Record<string, any>> } | null | undefined) {
|
|
110
|
+
return items(records?.lifts).map((l, i) => ({
|
|
111
|
+
key: `${l.name}-${i}`,
|
|
112
|
+
name: String(l.name ?? "Lift"),
|
|
113
|
+
state: `${l.online ? "Online" : "Offline"}${l.enabled === false ? " · disabled" : ""}${isNum(l.floorNo) ? ` · at L${l.floorNo}` : ""}`,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** "Couldn't read the cleaning records: …" per refused part, so a gap is said, never silent. */
|
|
118
|
+
export function recordErrors(records: Record<string, any> | null | undefined): string[] {
|
|
119
|
+
const names: Record<string, string> = {
|
|
120
|
+
tasks: "cleaning jobs",
|
|
121
|
+
alerts: "alert history",
|
|
122
|
+
consumables: "parts wear",
|
|
123
|
+
power: "power schedule",
|
|
124
|
+
lifts: "lifts",
|
|
125
|
+
readings: "robot readings",
|
|
126
|
+
station: "base station",
|
|
127
|
+
};
|
|
128
|
+
if (!records) return [];
|
|
129
|
+
const out = typeof records.error === "string" && records.error ? [records.error] : [];
|
|
130
|
+
for (const [k, name] of Object.entries(names)) {
|
|
131
|
+
const e = records[k]?.error;
|
|
132
|
+
if (typeof e === "string" && e) out.push(`Couldn't read the ${name}: ${e}`);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|