@7365admin1/layer-common 4.51.1 → 4.53.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 +4 -1
- package/components/Robot/Detail.vue +59 -3
- package/components/Robot/Form.vue +74 -9
- package/components/Robot/PatrolHistory.vue +100 -0
- package/composables/useRobots.ts +59 -3
- package/package.json +1 -1
- package/utils/robot-credential-form.ts +42 -0
- package/utils/robot-display.ts +42 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.53.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 74aabc6: PadBot security robots on the HERO robot screens: Add robot labels the fields "AccessKey ID" / "AccessKey Secret" and picks the robot from the account; robot detail offers Start patrol / Resume patrol (brands with `patrolControl`), Live video (minted per click), Mark resolved on an alarm, an ALARM badge for security alerts, and a Patrol history panel. Pause / Resume now need the brand's `pauseResume`, Cancel its `pauseProcess` (no change for existing brands).
|
|
8
|
+
|
|
9
|
+
## 4.52.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- a983a42: Add / Edit robot: Ounitech credential fields read "AppID" / "Secret" (demo: "any value"); a Robot picker lists the robots the company credential covers (Find robots) so the serial is picked, and adding with no pair typed reuses the client's saved Ounitech credentials.
|
|
14
|
+
|
|
3
15
|
## 4.51.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1551,11 +1563,11 @@
|
|
|
1551
1563
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1552
1564
|
own rule from two endpoints the console already calls, unprojected:
|
|
1553
1565
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1566
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1567
|
+
GET /api/roles/id/:role that membership's role document
|
|
1556
1568
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1569
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1570
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1559
1571
|
|
|
1560
1572
|
`role.default` is the marker because it is the only property of a platform
|
|
1561
1573
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -757,7 +757,7 @@
|
|
|
757
757
|
.hero .v-btn.hero-ghost { color: var(--hero-accent); padding-inline: 4px; }
|
|
758
758
|
.hero .v-btn.hero-ghost:hover { background: color-mix(in srgb, var(--hero-accent) 10%, transparent); }
|
|
759
759
|
|
|
760
|
-
.hero-faultbar { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
|
|
760
|
+
.hero-faultbar { display: grid; grid-template-columns: auto 1fr; grid-auto-flow: column; grid-auto-columns: auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
|
|
761
761
|
.hero-faultbar__code { font-family: var(--hero-font); font-weight: 800; color: var(--hero-accent); font-size: 18px; }
|
|
762
762
|
.hero-faultbar__msg { font-weight: 600; }
|
|
763
763
|
.hero-faultbar__since { opacity: 0.65; font-size: 12px; }
|
|
@@ -1234,3 +1234,6 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1234
1234
|
}
|
|
1235
1235
|
.hero-cell__shimmer { display: none; }
|
|
1236
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
/* PadBot security extras: live video button row under the facts. */
|
|
1239
|
+
.hero-detail__video { margin: 10px 0 4px; }
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
<div class="hero-faultbar__msg">{{ fault.message || fault.code }}</div>
|
|
28
28
|
<div class="hero-faultbar__since">Since {{ faultSince }} · notified once</div>
|
|
29
29
|
</div>
|
|
30
|
-
<
|
|
30
|
+
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" :loading="videoBusy" @click="openVideo">Live video</v-btn>
|
|
31
|
+
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
32
|
+
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
31
33
|
</div>
|
|
32
34
|
|
|
33
35
|
<RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
|
|
@@ -39,6 +41,9 @@
|
|
|
39
41
|
</div>
|
|
40
42
|
</div>
|
|
41
43
|
<div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
|
|
44
|
+
<div v-if="caps?.liveVideo === true && online && !fault" class="hero-detail__video">
|
|
45
|
+
<v-btn variant="outlined" :loading="videoBusy" @click="openVideo">Live video</v-btn>
|
|
46
|
+
</div>
|
|
42
47
|
|
|
43
48
|
<div v-if="commands.length">
|
|
44
49
|
<div class="hero-section__title">Command bar</div>
|
|
@@ -97,6 +102,8 @@
|
|
|
97
102
|
</div>
|
|
98
103
|
</div>
|
|
99
104
|
|
|
105
|
+
<RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
|
|
106
|
+
|
|
100
107
|
<v-dialog v-model="editOpen" width="520" persistent>
|
|
101
108
|
<RobotForm
|
|
102
109
|
v-if="editOpen && record"
|
|
@@ -136,6 +143,7 @@ import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
|
|
|
136
143
|
import RobotLivePlan from "./LivePlan.vue";
|
|
137
144
|
import RobotToast from "./Toast.vue";
|
|
138
145
|
import RobotForm from "./Form.vue";
|
|
146
|
+
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
139
147
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
140
148
|
import {
|
|
141
149
|
brandName,
|
|
@@ -143,6 +151,7 @@ import {
|
|
|
143
151
|
cleaningScheduleRows,
|
|
144
152
|
commandBody,
|
|
145
153
|
commandLabel,
|
|
154
|
+
faultBadge,
|
|
146
155
|
headingDeg,
|
|
147
156
|
isMoving,
|
|
148
157
|
mergePose,
|
|
@@ -188,8 +197,19 @@ const emit = defineEmits<{
|
|
|
188
197
|
status: [status: { connected: boolean }];
|
|
189
198
|
}>();
|
|
190
199
|
|
|
191
|
-
const {
|
|
192
|
-
|
|
200
|
+
const {
|
|
201
|
+
getRobot,
|
|
202
|
+
getRobotBrands,
|
|
203
|
+
getRobotPlans,
|
|
204
|
+
getPatrolRoutes,
|
|
205
|
+
sendRobotCommand,
|
|
206
|
+
trackRobot,
|
|
207
|
+
deleteRobot,
|
|
208
|
+
getRobotMaps,
|
|
209
|
+
getRobotMapImages,
|
|
210
|
+
getLiveVideo,
|
|
211
|
+
resolveRobotAlerts,
|
|
212
|
+
} = useRobots();
|
|
193
213
|
|
|
194
214
|
const record = ref<Record<string, any> | null>(null);
|
|
195
215
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
@@ -379,6 +399,42 @@ async function sendSpot() {
|
|
|
379
399
|
}
|
|
380
400
|
}
|
|
381
401
|
|
|
402
|
+
/* ---- security-robot extras: live video (minted per click, never kept), resolve an alarm ---- */
|
|
403
|
+
const videoBusy = ref(false);
|
|
404
|
+
async function openVideo() {
|
|
405
|
+
// Opened before the await so the browser treats it as the click's own window.
|
|
406
|
+
const win = typeof window !== "undefined" ? window.open("", "_blank") : null;
|
|
407
|
+
videoBusy.value = true;
|
|
408
|
+
try {
|
|
409
|
+
const { url } = await getLiveVideo(props.robotId);
|
|
410
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("The robot's cloud sent a video link this page cannot open.");
|
|
411
|
+
if (win) {
|
|
412
|
+
win.opener = null;
|
|
413
|
+
win.location.href = url;
|
|
414
|
+
} else toast.value = "Allow pop-ups to watch the robot's live video.";
|
|
415
|
+
} catch (e) {
|
|
416
|
+
win?.close();
|
|
417
|
+
toast.value = robotErrorMessage(e, "Could not open the live video.");
|
|
418
|
+
} finally {
|
|
419
|
+
videoBusy.value = false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const canResolve = computed(
|
|
423
|
+
() => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
|
|
424
|
+
);
|
|
425
|
+
const resolving = ref(false);
|
|
426
|
+
async function resolveFault() {
|
|
427
|
+
resolving.value = true;
|
|
428
|
+
try {
|
|
429
|
+
const res = await resolveRobotAlerts(props.robotId, String(fault.value?.code ?? ""));
|
|
430
|
+
toast.value = res?.resolved ? `Marked resolved · ${res.resolved} alert${res.resolved === 1 ? "" : "s"}` : "Nothing open to resolve.";
|
|
431
|
+
} catch (e) {
|
|
432
|
+
toast.value = robotErrorMessage(e, "Could not mark the alert resolved.");
|
|
433
|
+
} finally {
|
|
434
|
+
resolving.value = false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
382
438
|
/* ---- edit / remove ---- */
|
|
383
439
|
function onSaved() {
|
|
384
440
|
editOpen.value = false;
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
<!--
|
|
3
3
|
Add / edit robot (HERO README §8): a 520px dialog - Name · Type (only when
|
|
4
4
|
the account presents both types) · Brand (filtered by type; each option
|
|
5
|
-
shows its capability) · Site · App key · Token (password fields
|
|
5
|
+
shows its capability) · Site · App key · Token (password fields; named in
|
|
6
|
+
the brand's own words - Ounitech: AppID · Secret), Robot (Ounitech: pick the
|
|
7
|
+
serial from the company account; with no pair typed, the client's saved
|
|
8
|
+
pair is reused server-side), the
|
|
6
9
|
credentials note with the brand's capability, Cancel / Save robot.
|
|
7
10
|
|
|
8
11
|
Credentials are write-only: the stored pair never reaches the browser, so
|
|
@@ -46,7 +49,7 @@
|
|
|
46
49
|
</select>
|
|
47
50
|
</div>
|
|
48
51
|
<div class="hero-field">
|
|
49
|
-
<label :for="`${uid}-key`">
|
|
52
|
+
<label :for="`${uid}-key`">{{ labels.key }}</label>
|
|
50
53
|
<input
|
|
51
54
|
:id="`${uid}-key`"
|
|
52
55
|
v-model="appKey"
|
|
@@ -59,7 +62,7 @@
|
|
|
59
62
|
/>
|
|
60
63
|
</div>
|
|
61
64
|
<div class="hero-field">
|
|
62
|
-
<label :for="`${uid}-token`">
|
|
65
|
+
<label :for="`${uid}-token`">{{ labels.token }}</label>
|
|
63
66
|
<input
|
|
64
67
|
:id="`${uid}-token`"
|
|
65
68
|
v-model="appToken"
|
|
@@ -71,6 +74,18 @@
|
|
|
71
74
|
:disabled="saving"
|
|
72
75
|
/>
|
|
73
76
|
</div>
|
|
77
|
+
<!-- One company credential covers many robots: pick the machine by serial. -->
|
|
78
|
+
<div v-if="discoverable" class="hero-field hero-form-grid__full">
|
|
79
|
+
<label :for="`${uid}-serial`">Robot</label>
|
|
80
|
+
<div class="d-flex ga-2 align-center">
|
|
81
|
+
<select :id="`${uid}-serial`" v-model="robotSn" class="hero-input" :disabled="saving || !found.length">
|
|
82
|
+
<option value="">{{ found.length ? "Choose the robot" : robotSn || findHint }}</option>
|
|
83
|
+
<option v-if="robotSn && !found.some((o) => o.value === robotSn)" :value="robotSn">{{ robotSn }}</option>
|
|
84
|
+
<option v-for="o in found" :key="o.value" :value="o.value" :disabled="o.disabled">{{ o.label }}</option>
|
|
85
|
+
</select>
|
|
86
|
+
<v-btn variant="outlined" :loading="finding" :disabled="saving || (!editing && !site)" @click="find">Find robots</v-btn>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
74
89
|
</div>
|
|
75
90
|
<div class="hero-dialog__note">{{ credentialsNote(credentialsEncrypted) }} Capability: {{ note }}</div>
|
|
76
91
|
<div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
|
|
@@ -93,6 +108,7 @@ import {
|
|
|
93
108
|
type TRobotType,
|
|
94
109
|
} from "../../utils/robot-display";
|
|
95
110
|
import { credentialsNote } from "../../utils/robot-floorplan";
|
|
111
|
+
import { credentialLabels, credentialMissingMessage, discoveredRobotOptions } from "../../utils/robot-credential-form";
|
|
96
112
|
|
|
97
113
|
const props = withDefaults(
|
|
98
114
|
defineProps<{
|
|
@@ -119,7 +135,7 @@ const emit = defineEmits<{
|
|
|
119
135
|
"update:client": [org: string];
|
|
120
136
|
}>();
|
|
121
137
|
|
|
122
|
-
const { createRobot, updateRobot, getRobotBrands } = useRobots();
|
|
138
|
+
const { createRobot, updateRobot, getRobotBrands, discoverRobots } = useRobots();
|
|
123
139
|
const uid = useId();
|
|
124
140
|
const titleId = `${uid}-title`;
|
|
125
141
|
const editing = computed(() => !!props.robot?._id);
|
|
@@ -133,6 +149,23 @@ const org = ref(props.organization);
|
|
|
133
149
|
const site = ref(String(props.robot?.site?._id ?? props.site ?? ""));
|
|
134
150
|
const appKey = ref("");
|
|
135
151
|
const appToken = ref("");
|
|
152
|
+
const savedSn = String(props.robot?.robotSn ?? "");
|
|
153
|
+
const robotSn = ref(savedSn);
|
|
154
|
+
const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]);
|
|
155
|
+
const finding = ref(false);
|
|
156
|
+
const labels = computed(() => credentialLabels(brand.value));
|
|
157
|
+
const typed = computed(() => !!appKey.value.trim() && !!appToken.value.trim());
|
|
158
|
+
// Ounitech / PadBot: one credential pair per company account, so the robot is picked from its list.
|
|
159
|
+
const discoverable = computed(() => brand.value === "ounitech" || brand.value === "padbot");
|
|
160
|
+
const findHint = computed(() =>
|
|
161
|
+
editing.value || typed.value
|
|
162
|
+
? "Press Find robots"
|
|
163
|
+
: `Press Find robots to use this client's saved ${labels.value.key} and ${labels.value.token}, or enter them first`,
|
|
164
|
+
);
|
|
165
|
+
watch([brand, site], () => {
|
|
166
|
+
found.value = [];
|
|
167
|
+
if (!editing.value) robotSn.value = "";
|
|
168
|
+
});
|
|
136
169
|
const saving = ref(false);
|
|
137
170
|
const error = ref("");
|
|
138
171
|
|
|
@@ -184,15 +217,47 @@ onMounted(async () => {
|
|
|
184
217
|
}
|
|
185
218
|
});
|
|
186
219
|
|
|
220
|
+
async function find() {
|
|
221
|
+
error.value = "";
|
|
222
|
+
if (!!appKey.value.trim() !== !!appToken.value.trim()) {
|
|
223
|
+
return void (error.value = `Enter both the ${labels.value.key} and the ${labels.value.token}.`);
|
|
224
|
+
}
|
|
225
|
+
finding.value = true;
|
|
226
|
+
try {
|
|
227
|
+
const pair = typed.value ? { appKey: appKey.value.trim(), appToken: appToken.value.trim() } : null;
|
|
228
|
+
const body = pair
|
|
229
|
+
? { site: site.value, brand: brand.value, ...pair }
|
|
230
|
+
: editing.value
|
|
231
|
+
? { robot: String(props.robot!._id) }
|
|
232
|
+
: { site: site.value, brand: brand.value };
|
|
233
|
+
const res = await discoverRobots(body);
|
|
234
|
+
found.value = discoveredRobotOptions(res?.items);
|
|
235
|
+
const free = found.value.filter((o) => !o.disabled);
|
|
236
|
+
if (!found.value.length) error.value = "No robots are on this account.";
|
|
237
|
+
else if (!robotSn.value && free.length === 1) robotSn.value = free[0]!.value;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
found.value = [];
|
|
240
|
+
error.value = robotErrorMessage(e, "Could not list the robots.");
|
|
241
|
+
} finally {
|
|
242
|
+
finding.value = false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
187
246
|
async function submit() {
|
|
188
247
|
error.value = "";
|
|
189
248
|
if (!name.value.trim()) return void (error.value = "Give the robot a name.");
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
// Adding with no pair typed = reuse this client's saved one; needs the picked robot.
|
|
250
|
+
const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !appToken.value.trim();
|
|
251
|
+
if (useSaved && !robotSn.value) {
|
|
252
|
+
return void (error.value = `Enter the ${labels.value.key} and ${labels.value.token}, or press Find robots to use this client's saved ones and pick the robot.`);
|
|
253
|
+
}
|
|
254
|
+
const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, appToken.value, editing.value);
|
|
255
|
+
if (!creds.ok) return void (error.value = credentialMissingMessage(labels.value, editing.value));
|
|
256
|
+
const serial = robotSn.value && robotSn.value !== savedSn ? { robotSn: robotSn.value } : {};
|
|
192
257
|
saving.value = true;
|
|
193
258
|
try {
|
|
194
259
|
if (editing.value) {
|
|
195
|
-
await updateRobot(String(props.robot!._id), { name: name.value.trim(), ...creds.body });
|
|
260
|
+
await updateRobot(String(props.robot!._id), { name: name.value.trim(), ...creds.body, ...serial });
|
|
196
261
|
emit("saved", name.value.trim());
|
|
197
262
|
} else {
|
|
198
263
|
if (!brand.value) throw new Error("Choose the robot's brand.");
|
|
@@ -201,8 +266,8 @@ async function submit() {
|
|
|
201
266
|
name: name.value.trim(),
|
|
202
267
|
brand: brand.value,
|
|
203
268
|
type: type.value,
|
|
204
|
-
appKey: creds.body.appKey!,
|
|
205
|
-
|
|
269
|
+
...(useSaved ? { useClientCredentials: true } : { appKey: creds.body.appKey!, appToken: creds.body.appToken! }),
|
|
270
|
+
...serial,
|
|
206
271
|
metadata: { orgId: org.value, siteId: site.value },
|
|
207
272
|
});
|
|
208
273
|
emit("created", { name: name.value.trim(), site: site.value });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Patrol history (brands with `patrolHistory`, e.g. PadBot): the robot's past
|
|
4
|
+
patrols from its own cloud, newest first; a row opens what was captured at
|
|
5
|
+
each checkpoint. Read only; photo links open the brand's image.
|
|
6
|
+
-->
|
|
7
|
+
<div class="hero-patrolhist">
|
|
8
|
+
<div class="hero-aside__head">
|
|
9
|
+
<span class="hero-aside__title">Patrol history</span>
|
|
10
|
+
<span class="hero-aside__hint">route · result · time · distance · alerts</span>
|
|
11
|
+
</div>
|
|
12
|
+
<div v-if="error" class="hero-sched__empty">{{ error }}</div>
|
|
13
|
+
<div v-else-if="loaded && !rows.length" class="hero-sched__empty">No patrols recorded yet.</div>
|
|
14
|
+
<template v-for="r in rows" :key="r.key">
|
|
15
|
+
<button type="button" class="hero-sched__row hero-patrolhist__row" :aria-expanded="open === r.key" @click="toggle(r.key)">
|
|
16
|
+
<b>{{ r.when }}</b><span>{{ r.route }} · <span :class="`hero-patrolhist__${r.tone}`">{{ r.result }}</span></span><span>{{ r.detail }}</span>
|
|
17
|
+
</button>
|
|
18
|
+
<div v-if="open === r.key" class="hero-patrolhist__visits">
|
|
19
|
+
<div v-if="visitsLoading" class="hero-sched__empty">Loading checkpoints…</div>
|
|
20
|
+
<div v-else-if="!visits.length" class="hero-sched__empty">No checkpoint captures for this patrol.</div>
|
|
21
|
+
<div v-for="(v, i) in visits" :key="i" class="hero-patrolhist__visit">
|
|
22
|
+
<b>{{ v.checkpoint || "Checkpoint" }}</b>
|
|
23
|
+
<span>{{ v.skipped ? "Skipped" : activityWhen(v.at, now) }}</span>
|
|
24
|
+
<span v-if="v.centerTempC !== null">{{ v.centerTempC }}°C (max {{ v.maxTempC ?? "—" }}°C)</span>
|
|
25
|
+
<a v-if="safeUrl(v.imageUrl)" :href="safeUrl(v.imageUrl)" target="_blank" rel="noopener noreferrer">Photo</a>
|
|
26
|
+
<a v-if="safeUrl(v.thermalImageUrl)" :href="safeUrl(v.thermalImageUrl)" target="_blank" rel="noopener noreferrer">Thermal</a>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
<div v-if="pages > 1" class="hero-patrolhist__foot">
|
|
31
|
+
<v-btn variant="text" size="small" :disabled="page <= 1" @click="load(page - 1)">Newer</v-btn>
|
|
32
|
+
<span>Page {{ page }} of {{ pages }}</span>
|
|
33
|
+
<v-btn variant="text" size="small" :disabled="page >= pages" @click="load(page + 1)">Older</v-btn>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { computed, ref, watch } from "vue";
|
|
40
|
+
import { activityWhen, patrolRecordRow, robotErrorMessage } from "../../utils/robot-display";
|
|
41
|
+
|
|
42
|
+
const props = defineProps<{ robotId: string }>();
|
|
43
|
+
const { getPatrolHistory, getPatrolVisits } = useRobots();
|
|
44
|
+
|
|
45
|
+
const items = ref<any[]>([]);
|
|
46
|
+
const page = ref(1);
|
|
47
|
+
const pages = ref(0);
|
|
48
|
+
const loaded = ref(false);
|
|
49
|
+
const error = ref("");
|
|
50
|
+
const open = ref("");
|
|
51
|
+
const visits = ref<any[]>([]);
|
|
52
|
+
const visitsLoading = ref(false);
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const rows = computed(() => items.value.map((r) => patrolRecordRow(r, now)));
|
|
55
|
+
|
|
56
|
+
/** Only http(s) links from the brand are rendered. */
|
|
57
|
+
const safeUrl = (u: unknown) => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : "");
|
|
58
|
+
|
|
59
|
+
async function load(p = 1) {
|
|
60
|
+
error.value = "";
|
|
61
|
+
try {
|
|
62
|
+
const res = await getPatrolHistory(props.robotId, { page: p, limit: 10 });
|
|
63
|
+
items.value = res?.items ?? [];
|
|
64
|
+
pages.value = res?.pages ?? 0;
|
|
65
|
+
page.value = p;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
error.value = robotErrorMessage(e, "Could not load the patrol history.");
|
|
68
|
+
} finally {
|
|
69
|
+
loaded.value = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function toggle(key: string) {
|
|
74
|
+
if (open.value === key) return void (open.value = "");
|
|
75
|
+
open.value = key;
|
|
76
|
+
visits.value = [];
|
|
77
|
+
visitsLoading.value = true;
|
|
78
|
+
try {
|
|
79
|
+
visits.value = (await getPatrolVisits(props.robotId, key))?.items ?? [];
|
|
80
|
+
} catch {
|
|
81
|
+
visits.value = [];
|
|
82
|
+
} finally {
|
|
83
|
+
visitsLoading.value = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
watch(() => props.robotId, () => load(1), { immediate: true });
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<style scoped>
|
|
91
|
+
.hero-patrolhist { margin-top: 20px; }
|
|
92
|
+
.hero-patrolhist__row { width: 100%; text-align: left; background: none; border: 0; cursor: pointer; color: inherit; font: inherit; }
|
|
93
|
+
.hero-patrolhist__visits { padding: 4px 0 8px 16px; }
|
|
94
|
+
.hero-patrolhist__visit { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 0; font-size: 12px; }
|
|
95
|
+
.hero-patrolhist__visit a { color: inherit; text-decoration: underline; }
|
|
96
|
+
.hero-patrolhist__foot { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-top: 6px; }
|
|
97
|
+
.hero-patrolhist__success { color: rgb(var(--v-theme-success)); }
|
|
98
|
+
.hero-patrolhist__warning { color: rgb(var(--v-theme-warning)); }
|
|
99
|
+
.hero-patrolhist__info { color: rgb(var(--v-theme-info)); }
|
|
100
|
+
</style>
|
package/composables/useRobots.ts
CHANGED
|
@@ -86,8 +86,10 @@ export default function useRobots() {
|
|
|
86
86
|
function createRobot(payload: {
|
|
87
87
|
name: string;
|
|
88
88
|
brand?: string;
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/** Omitted with `useClientCredentials` - the server copies the client's saved pair. */
|
|
90
|
+
appKey?: string;
|
|
91
|
+
appToken?: string;
|
|
92
|
+
useClientCredentials?: boolean;
|
|
91
93
|
type?: "cleaning" | "security";
|
|
92
94
|
robotSn?: string;
|
|
93
95
|
metadata: { orgId: string; siteId: string; administratorId?: string };
|
|
@@ -111,6 +113,19 @@ export default function useRobots() {
|
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The robots a company credential covers (Ounitech: one AppID/Secret per
|
|
118
|
+
* company), for the serial picker. Pass a typed pair, a saved robot's id
|
|
119
|
+
* (`robot`, when editing), or just the site to use the client's saved pair.
|
|
120
|
+
* POST so a typed secret never goes in a URL; the answer holds no credential.
|
|
121
|
+
*/
|
|
122
|
+
function discoverRobots(body: { site?: string; brand?: string; robot?: string; appKey?: string; appToken?: string }) {
|
|
123
|
+
return useNuxtApp().$api<{ items: Array<{ robotSn: string; name: string; online: boolean; statusName: string; added: boolean }> }>(
|
|
124
|
+
"/api/robots/discover",
|
|
125
|
+
{ method: "POST", body },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
/** Remove a robot. It stops listing and answering; the record is kept. */
|
|
115
130
|
function deleteRobot(id: string) {
|
|
116
131
|
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}`, {
|
|
@@ -258,7 +273,7 @@ export default function useRobots() {
|
|
|
258
273
|
| { kind: "full"; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
|
|
259
274
|
| { kind: "area"; areas: string[]; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
|
|
260
275
|
| { kind: "spot"; points: Array<{ x: number; y: number }>; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
|
|
261
|
-
| { kind: "pause" | "resume" | "cancel" }
|
|
276
|
+
| { kind: "pause" | "resume" | "cancel" | "patrol-resume" }
|
|
262
277
|
| { kind: "navigate"; x: number; y: number; theta?: number }
|
|
263
278
|
| { kind: "relocalize"; floorNo: number; x: number; y: number; theta: number },
|
|
264
279
|
) {
|
|
@@ -268,6 +283,42 @@ export default function useRobots() {
|
|
|
268
283
|
);
|
|
269
284
|
}
|
|
270
285
|
|
|
286
|
+
/* ---------------------------------------- security-robot extras (brand capability-gated) */
|
|
287
|
+
|
|
288
|
+
/** `liveVideo`: the robot's camera URL, minted now (valid until `expiresAt`). Open it; never store it. */
|
|
289
|
+
function getLiveVideo(id: string) {
|
|
290
|
+
return useNuxtApp().$api<{ url: string; expiresAt: string | null }>(`/api/robots/${id}/live-video`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
query: { appSlug },
|
|
293
|
+
body: {},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** `resolveAlerts`: mark the robot's open alerts (of one code, or all) resolved in the brand's system. */
|
|
298
|
+
function resolveRobotAlerts(id: string, code?: string) {
|
|
299
|
+
return useNuxtApp().$api<{ resolved: number }>(`/api/robots/${id}/alerts/resolve`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
query: { appSlug },
|
|
302
|
+
body: code ? { code } : {},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `patrolHistory`: past patrols, newest first. */
|
|
307
|
+
function getPatrolHistory(id: string, { page = 1, limit = 10 } = {}) {
|
|
308
|
+
return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(`/api/robots/${id}/patrol-history`, {
|
|
309
|
+
method: "GET",
|
|
310
|
+
query: { appSlug, page, limit },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** `patrolHistory`: one past patrol's checkpoint visits. */
|
|
315
|
+
function getPatrolVisits(id: string, recordId: string, { page = 1, limit = 50 } = {}) {
|
|
316
|
+
return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(
|
|
317
|
+
`/api/robots/${id}/patrol-history/${encodeURIComponent(recordId)}`,
|
|
318
|
+
{ method: "GET", query: { appSlug, page, limit } },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
271
322
|
/* ---------------------------------------- building maps and lifts */
|
|
272
323
|
|
|
273
324
|
/**
|
|
@@ -498,10 +549,15 @@ export default function useRobots() {
|
|
|
498
549
|
createRobot,
|
|
499
550
|
updateRobot,
|
|
500
551
|
deleteRobot,
|
|
552
|
+
discoverRobots,
|
|
501
553
|
getRobotState,
|
|
502
554
|
getRobotMaps,
|
|
503
555
|
getRobotMapImages,
|
|
504
556
|
getRobotPlans,
|
|
505
557
|
sendRobotCommand,
|
|
558
|
+
getLiveVideo,
|
|
559
|
+
resolveRobotAlerts,
|
|
560
|
+
getPatrolHistory,
|
|
561
|
+
getPatrolVisits,
|
|
506
562
|
};
|
|
507
563
|
}
|
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.53.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.",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add / Edit robot: the credential fields in each brand's own words, and the
|
|
3
|
+
* company-account robot picker. Kept out of `robot-display.ts` because that file
|
|
4
|
+
* is vendored into the mobile apps, which have no Add robot.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ounitech issues an "AppID" and a "Secret" (one pair per company - vendor
|
|
9
|
+
* answer of 2026-09-26); the stored fields are still `appKey` / `appToken`.
|
|
10
|
+
* The demo brand accepts anything.
|
|
11
|
+
*/
|
|
12
|
+
export function credentialLabels(brand: unknown): { key: string; token: string } {
|
|
13
|
+
if (brand === "ounitech") return { key: "AppID", token: "Secret" };
|
|
14
|
+
// PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
|
|
15
|
+
if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
|
|
16
|
+
if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
|
|
17
|
+
return { key: "App key", token: "Token" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "Enter the AppID and the Secret ..." - `credentialPatch`'s refusal, in the brand's words. */
|
|
21
|
+
export function credentialMissingMessage(labels: { key: string; token: string }, editing: boolean): string {
|
|
22
|
+
return editing
|
|
23
|
+
? `Enter both the ${labels.key} and the ${labels.token} to replace them, or leave both empty.`
|
|
24
|
+
: `Enter the ${labels.key} and the ${labels.token} from the brand portal.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Discovered robots as picker options: "Lobby Q3 · OT-SN-A", marking ones this
|
|
29
|
+
* client already added (disabled - one serial, one record) and offline ones.
|
|
30
|
+
*/
|
|
31
|
+
export function discoveredRobotOptions(
|
|
32
|
+
items: Array<{ robotSn?: unknown; name?: unknown; online?: unknown; added?: unknown }> | null | undefined,
|
|
33
|
+
): Array<{ value: string; label: string; disabled: boolean }> {
|
|
34
|
+
return (items ?? [])
|
|
35
|
+
.filter((r) => typeof r?.robotSn === "string" && r.robotSn)
|
|
36
|
+
.map((r) => {
|
|
37
|
+
const sn = String(r.robotSn);
|
|
38
|
+
const name = typeof r.name === "string" && r.name.trim() ? `${r.name.trim()} · ${sn}` : sn;
|
|
39
|
+
const tags = [r.added === true ? "already added" : "", r.online === false ? "offline" : ""].filter(Boolean);
|
|
40
|
+
return { value: sn, label: tags.length ? `${name} (${tags.join(", ")})` : name, disabled: r.added === true };
|
|
41
|
+
});
|
|
42
|
+
}
|
package/utils/robot-display.ts
CHANGED
|
@@ -286,8 +286,10 @@ const COMMAND_NEEDS: Readonly<Record<string, string>> = {
|
|
|
286
286
|
area: "startProcess",
|
|
287
287
|
spot: "spotClean",
|
|
288
288
|
return: "sendToDock",
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
// Pause / resume = pausing a robot mid-way; cancel = stopping the job. Split
|
|
290
|
+
// so a brand that can stop but not pause (PadBot) shows Cancel with no Pause.
|
|
291
|
+
pause: "pauseResume",
|
|
292
|
+
resume: "pauseResume",
|
|
291
293
|
cancel: "pauseProcess",
|
|
292
294
|
navigate: "navigateToPoint",
|
|
293
295
|
relocalize: "relocalize",
|
|
@@ -383,7 +385,8 @@ export type TRobotCommandKind =
|
|
|
383
385
|
| "resume"
|
|
384
386
|
| "cancel"
|
|
385
387
|
| "navigate"
|
|
386
|
-
| "relocalize"
|
|
388
|
+
| "relocalize"
|
|
389
|
+
| "patrol-resume";
|
|
387
390
|
|
|
388
391
|
/**
|
|
389
392
|
* The command bar's buttons, in bar order; each needs `COMMAND_NEEDS[kind]`.
|
|
@@ -398,6 +401,7 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
|
|
|
398
401
|
{ kind: "cancel", label: "Cancel" },
|
|
399
402
|
{ kind: "navigate", label: "Go to point" },
|
|
400
403
|
{ kind: "relocalize", label: "Set position" },
|
|
404
|
+
{ kind: "patrol-resume", label: "Resume patrol" },
|
|
401
405
|
];
|
|
402
406
|
|
|
403
407
|
export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
|
|
@@ -409,7 +413,9 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
|
|
|
409
413
|
* - role: only a Manager (`canCommand`, i.e. the app's command permission);
|
|
410
414
|
* - online: only a robot confirmed online (`online === true`);
|
|
411
415
|
* - context: Pause only while working, Resume only while paused;
|
|
412
|
-
* - type: a security robot has no cleaning jobs, so no Clean all / Clean area
|
|
416
|
+
* - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
|
|
417
|
+
* but a brand with `patrolControl` gets Start patrol (the default route) and
|
|
418
|
+
* Resume patrol (while it is not already patrolling).
|
|
413
419
|
*
|
|
414
420
|
* Returns [] whenever the bar should not render at all.
|
|
415
421
|
*/
|
|
@@ -422,13 +428,20 @@ export function visibleCommands(input: {
|
|
|
422
428
|
} | null | undefined): TRobotCommand[] {
|
|
423
429
|
if (!input || input.canCommand !== true || input.online !== true) return [];
|
|
424
430
|
const caps = input.capabilities ?? {};
|
|
431
|
+
const patrol = input.type === "security" && caps.patrolControl === true;
|
|
425
432
|
return COMMANDS.filter((c) => {
|
|
433
|
+
if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
|
|
426
434
|
if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
|
|
427
435
|
if (c.kind === "pause" && input.mode !== "WORKING") return false;
|
|
428
436
|
if (c.kind === "resume" && input.mode !== "PAUSED") return false;
|
|
429
|
-
if (
|
|
437
|
+
if (c.kind === "full" && input.type === "security") return patrol;
|
|
438
|
+
if (c.kind === "area" && input.type === "security") return false;
|
|
430
439
|
return true;
|
|
431
|
-
}).map((c) => ({
|
|
440
|
+
}).map((c) => ({
|
|
441
|
+
kind: c.kind,
|
|
442
|
+
label: c.kind === "full" && input.type === "security" ? "Start patrol" : c.label,
|
|
443
|
+
primary: c.kind === "full",
|
|
444
|
+
}));
|
|
432
445
|
}
|
|
433
446
|
|
|
434
447
|
/**
|
|
@@ -947,6 +960,7 @@ export function optimisticMode(kind: unknown, mode: unknown): string {
|
|
|
947
960
|
case "area":
|
|
948
961
|
case "spot":
|
|
949
962
|
case "resume":
|
|
963
|
+
case "patrol-resume":
|
|
950
964
|
return "WORKING";
|
|
951
965
|
case "return":
|
|
952
966
|
return "CHARGING_CYCLE";
|
|
@@ -1184,6 +1198,28 @@ export function activityRow(entry: Record<string, any> | null | undefined, now:
|
|
|
1184
1198
|
* type; option shows capability"): only brands that make that class (a brand
|
|
1185
1199
|
* declaring no `types` makes cleaning robots), as "Ounitech · Basic control".
|
|
1186
1200
|
*/
|
|
1201
|
+
/** "Open alarm" / "Machine fault" badge for the fault bar: a security robot's alarm is something it SAW. */
|
|
1202
|
+
export function faultBadge(fault: { code?: unknown; alarm?: unknown } | null | undefined): "ALARM" | "FAULT" {
|
|
1203
|
+
return fault?.alarm === true || (typeof fault?.code === "string" && fault.code.startsWith("ALARM-")) ? "ALARM" : "FAULT";
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** One past patrol as a row: "Completed · 1 min 41 s · 19.9 m · 1 alert". */
|
|
1207
|
+
export function patrolRecordRow(r: Record<string, any> | null | undefined, now: number = Date.now()) {
|
|
1208
|
+
const RESULT: Record<string, string> = { completed: "Completed", partial: "Partly completed", failed: "Failed", "in-progress": "In progress" };
|
|
1209
|
+
const secs = typeof r?.durationS === "number" && Number.isFinite(r.durationS) ? Math.max(0, Math.round(r.durationS)) : null;
|
|
1210
|
+
const dur = secs === null ? "—" : secs >= 60 ? `${Math.floor(secs / 60)} min ${secs % 60} s` : `${secs} s`;
|
|
1211
|
+
const dist = typeof r?.distanceM === "number" && Number.isFinite(r.distanceM) ? `${Math.round(r.distanceM * 10) / 10} m` : "—";
|
|
1212
|
+
const alerts = Number(r?.alarmCount) || 0;
|
|
1213
|
+
return {
|
|
1214
|
+
key: String(r?.id ?? ""),
|
|
1215
|
+
when: activityWhen(r?.startedAt, now),
|
|
1216
|
+
route: String(r?.routeName || "Patrol"),
|
|
1217
|
+
result: RESULT[String(r?.result)] ?? "Unknown",
|
|
1218
|
+
tone: (r?.result === "completed" ? "success" : r?.result === "in-progress" ? "info" : "warning") as THeroTone,
|
|
1219
|
+
detail: `${dur} · ${dist} · ${alerts === 1 ? "1 alert" : `${alerts} alerts`}`,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1187
1223
|
export function brandOptions(
|
|
1188
1224
|
brands: Array<Record<string, any>> | null | undefined,
|
|
1189
1225
|
type: unknown,
|