@7365admin1/layer-common 4.64.1 → 4.64.3
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 +1 -0
- package/components/InvitationForm.vue +47 -5
- package/components/Robot/Detail.vue +14 -3
- package/composables/useRobotLive.ts +12 -3
- package/package.json +1 -1
- package/utils/robot-display.ts +30 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.64.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ed34f76: Invite member: "Create more" keeps the same app and site, so the Role list after an invitation is the same as before it (it used to switch to the organisation-type roles). When no role exists for the site, the list says so and links to Roles & Permissions.
|
|
8
|
+
|
|
9
|
+
## 4.64.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5f0c221: Robot detail: after a command the line follows the robot ("Leaving the charger…", "On the way to P4…", "Arrived at P4", "Docking…", "Back on charger") instead of a false "didn't start"; says up front that undocking takes about 10 s, with a spinner while it follows; reads the robot every 1 s for a minute after a command (then every 4 s) so the status and map pin keep up; a note under the live view says the video is about 8 s behind and the map is real time.
|
|
14
|
+
|
|
3
15
|
## 4.64.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1703,11 +1715,11 @@
|
|
|
1703
1715
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1704
1716
|
own rule from two endpoints the console already calls, unprojected:
|
|
1705
1717
|
|
|
1706
|
-
|
|
1707
|
-
|
|
1718
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1719
|
+
GET /api/roles/id/:role that membership's role document
|
|
1708
1720
|
|
|
1709
|
-
|
|
1710
|
-
|
|
1721
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1722
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1711
1723
|
|
|
1712
1724
|
`role.default` is the marker because it is the only property of a platform
|
|
1713
1725
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1284,6 +1284,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1284
1284
|
|
|
1285
1285
|
/* PadBot security extras: live video button row under the facts. */
|
|
1286
1286
|
.hero-detail__video { margin: 10px 0 4px; }
|
|
1287
|
+
.hero-detail__lag { margin: 6px 0 0; font-size: 12px; }
|
|
1287
1288
|
|
|
1288
1289
|
/* A kept (stale) reading: faded, no ping - never drawn as live (owner 2026-09-26). */
|
|
1289
1290
|
.hero-bot--stale, .hero-me--stale { opacity: 0.45; }
|
|
@@ -53,7 +53,18 @@
|
|
|
53
53
|
:rules="[requiredRule]"
|
|
54
54
|
class="filter-field"
|
|
55
55
|
density="comfortable"
|
|
56
|
-
|
|
56
|
+
>
|
|
57
|
+
<template #no-data>
|
|
58
|
+
<v-list-item data-test="invite-no-roles">
|
|
59
|
+
<v-list-item-title class="text-wrap">
|
|
60
|
+
{{ noRolesText }}
|
|
61
|
+
</v-list-item-title>
|
|
62
|
+
<NuxtLink v-if="rolesPageLink" :to="rolesPageLink">
|
|
63
|
+
Open Roles & Permissions
|
|
64
|
+
</NuxtLink>
|
|
65
|
+
</v-list-item>
|
|
66
|
+
</template>
|
|
67
|
+
</v-autocomplete>
|
|
57
68
|
</v-col>
|
|
58
69
|
</v-row>
|
|
59
70
|
</v-col>
|
|
@@ -248,6 +259,17 @@ const loading = reactive({
|
|
|
248
259
|
verifyingEmail: false,
|
|
249
260
|
});
|
|
250
261
|
|
|
262
|
+
/*
|
|
263
|
+
* The app this form invites into when it opens. "Create more" resets to THIS,
|
|
264
|
+
* never to a literal: it used to reset to "organization", so after the first
|
|
265
|
+
* invitation the Role list switched to the organisation-type roles ("Site
|
|
266
|
+
* Admin", "Org Admin") and this site's own roles vanished (owner report
|
|
267
|
+
* 2026-09-28, Wing Tai / The LakeGarden Residences).
|
|
268
|
+
*/
|
|
269
|
+
function initialApp() {
|
|
270
|
+
return props.adminState ? (props.app || "") : "property_management_agency"
|
|
271
|
+
}
|
|
272
|
+
|
|
251
273
|
const invite = ref<Record<string, any>>({
|
|
252
274
|
email: "",
|
|
253
275
|
app: "",
|
|
@@ -274,9 +296,7 @@ watch(
|
|
|
274
296
|
* ORGANISATION-admin invitation, which is a separate flow, and changing it
|
|
275
297
|
* here would move where those members land.
|
|
276
298
|
*/
|
|
277
|
-
invite.value.app =
|
|
278
|
-
? (props.app || "")
|
|
279
|
-
: "property_management_agency"
|
|
299
|
+
invite.value.app = initialApp()
|
|
280
300
|
},
|
|
281
301
|
{ immediate: true }
|
|
282
302
|
)
|
|
@@ -481,6 +501,25 @@ watchEffect(() => {
|
|
|
481
501
|
|
|
482
502
|
const roleOptions = computed(() => inviteRoleOptions(roles.value));
|
|
483
503
|
|
|
504
|
+
const noRolesText = computed(() =>
|
|
505
|
+
fixedSite.value
|
|
506
|
+
? "No roles have been created for this site yet. Create one in Roles & Permissions first."
|
|
507
|
+
: "No roles have been created yet. Create one in Roles & Permissions first."
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
/* This screen's own Roles & Permissions page, when the app has one. */
|
|
511
|
+
const route = useRoute();
|
|
512
|
+
const router = useRouter();
|
|
513
|
+
const rolesPageLink = computed(() => {
|
|
514
|
+
const path = route.path.replace(/\/invitations(\/.*)?$/, "/roles-permissions");
|
|
515
|
+
if (path === route.path) return "";
|
|
516
|
+
try {
|
|
517
|
+
return router.resolve(path).matched.length ? path : "";
|
|
518
|
+
} catch {
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
484
523
|
async function handleUpdateApp(value: string) {
|
|
485
524
|
invite.value.role = "";
|
|
486
525
|
if (!fixedSite.value) {
|
|
@@ -499,8 +538,11 @@ const message = ref("");
|
|
|
499
538
|
|
|
500
539
|
function resetInvite() {
|
|
501
540
|
invite.value.email = "";
|
|
502
|
-
invite.value.app =
|
|
541
|
+
invite.value.app = initialApp();
|
|
503
542
|
invite.value.role = "";
|
|
543
|
+
// Same site as before: a fixed site stays fixed, a picked one is cleared.
|
|
544
|
+
invite.value.site = fixedSite.value;
|
|
545
|
+
if (!invite.value.site) invite.value.siteName = "";
|
|
504
546
|
message.value = "";
|
|
505
547
|
}
|
|
506
548
|
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
<div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
|
|
61
61
|
<section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
|
|
62
62
|
<RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
|
|
63
|
+
<p v-if="caps?.liveVideo === true && reportsPosition" class="hero-muted hero-detail__lag">{{ VIDEO_LAG_NOTE }}</p>
|
|
63
64
|
<div v-if="canDrive" class="hero-detail__video">
|
|
64
65
|
<v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
65
66
|
</div>
|
|
@@ -126,6 +127,7 @@
|
|
|
126
127
|
@command="send"
|
|
127
128
|
/>
|
|
128
129
|
<div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
|
|
130
|
+
<v-progress-circular v-if="watching" indeterminate size="14" width="2" class="mr-2" aria-hidden="true" />
|
|
129
131
|
{{ commandResult.text }}
|
|
130
132
|
</div>
|
|
131
133
|
</div>
|
|
@@ -274,7 +276,11 @@ import {
|
|
|
274
276
|
capabilityLabel,
|
|
275
277
|
cleaningScheduleRows,
|
|
276
278
|
commandBody,
|
|
279
|
+
COMMAND_FOLLOW_MS,
|
|
277
280
|
COMMAND_WATCH_MS,
|
|
281
|
+
FAST_POLL_MS,
|
|
282
|
+
VIDEO_LAG_NOTE,
|
|
283
|
+
sentText,
|
|
278
284
|
commandConfirm,
|
|
279
285
|
commandOutcome,
|
|
280
286
|
patrolResumable,
|
|
@@ -366,7 +372,9 @@ const brand = computed(() => brandName(record.value?.brand, brands.value));
|
|
|
366
372
|
|
|
367
373
|
/* ---- live: the 4 s poll (swappable feed) + the per-robot position stream ---- */
|
|
368
374
|
const ids = computed(() => (props.robotId ? [props.robotId] : []));
|
|
369
|
-
|
|
375
|
+
// Every 4 s; every 1 s for a minute after a command, so the status and the map pin keep up with the robot.
|
|
376
|
+
const fastUntil = ref(0);
|
|
377
|
+
const { states, connected, lastAt } = useRobotLive(ids, siteStatesFeed(() => (Date.now() < fastUntil.value ? FAST_POLL_MS : 4000)));
|
|
370
378
|
const polled = computed<TLiveRobotState | null>(() => states.value[props.robotId] ?? null);
|
|
371
379
|
|
|
372
380
|
const lastPose = ref<{ event: any; at: number } | null>(null);
|
|
@@ -590,7 +598,8 @@ watch([lastAt, now], () => {
|
|
|
590
598
|
if (!w || at <= w.at + 300) return;
|
|
591
599
|
const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
|
|
592
600
|
if (!out) return;
|
|
593
|
-
|
|
601
|
+
// Keep following (Leaving the charger… -> On the way to P4… -> Arrived at P4) until it is done.
|
|
602
|
+
if (out.done) watching.value = null;
|
|
594
603
|
commandResult.value = { text: out.text, warn: !out.ok };
|
|
595
604
|
if (!out.ok) optimistic.value = null;
|
|
596
605
|
});
|
|
@@ -604,6 +613,7 @@ watch(
|
|
|
604
613
|
async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
|
|
605
614
|
const name = record.value?.name ?? "the robot";
|
|
606
615
|
const before = statusMode.value;
|
|
616
|
+
const docked = before === "CHARGED" || before === "CHARGING_CYCLE" || /At charger/.test(String(live.value?.ok ? live.value.statusName ?? "" : ""));
|
|
607
617
|
busy.value = kind;
|
|
608
618
|
optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
|
|
609
619
|
const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
|
|
@@ -621,7 +631,8 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
|
|
|
621
631
|
const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
|
|
622
632
|
if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
|
|
623
633
|
watching.value = { kind: String(outcomeKind), at: Date.now(), what };
|
|
624
|
-
|
|
634
|
+
fastUntil.value = Date.now() + COMMAND_FOLLOW_MS;
|
|
635
|
+
commandResult.value = { text: sentText(docked), warn: false };
|
|
625
636
|
} else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
|
|
626
637
|
return true;
|
|
627
638
|
} catch (e) {
|
|
@@ -36,16 +36,25 @@ export type TRobotLiveFeed = {
|
|
|
36
36
|
*/
|
|
37
37
|
export function robotPollingFeed(
|
|
38
38
|
fetchStates: (ids: string[]) => Promise<{ items?: Array<Partial<TLiveRobotState> & { id: string }> }>,
|
|
39
|
-
|
|
39
|
+
/** A number, or a function read before every wait (robot detail: 1 s for a minute after a command). */
|
|
40
|
+
intervalMs: number | (() => number) = 4000,
|
|
40
41
|
): TRobotLiveFeed {
|
|
42
|
+
const every = typeof intervalMs === "function" ? intervalMs : () => intervalMs;
|
|
41
43
|
return {
|
|
42
44
|
watch(ids, push, connected) {
|
|
43
45
|
let stopped = false;
|
|
44
46
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
let lastTick = 0;
|
|
45
48
|
const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
49
|
+
// Waits in 250 ms steps, so a shorter interval (a command just sent) starts at once.
|
|
50
|
+
const wait = () => {
|
|
51
|
+
if (stopped) return;
|
|
52
|
+
timer = setTimeout(() => (Date.now() - lastTick >= every() ? tick() : wait()), 250);
|
|
53
|
+
};
|
|
46
54
|
|
|
47
55
|
async function tick() {
|
|
48
56
|
if (stopped) return;
|
|
57
|
+
lastTick = Date.now();
|
|
49
58
|
if (!hidden() && ids.length) {
|
|
50
59
|
try {
|
|
51
60
|
const res = await fetchStates(ids);
|
|
@@ -58,7 +67,7 @@ export function robotPollingFeed(
|
|
|
58
67
|
connected(false);
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
|
-
|
|
70
|
+
wait();
|
|
62
71
|
}
|
|
63
72
|
// Coming back to the tab refreshes at once instead of waiting a tick.
|
|
64
73
|
const onVisible = () => {
|
|
@@ -95,7 +104,7 @@ export function networkStatesFeed(intervalMs = 4000): TRobotLiveFeed {
|
|
|
95
104
|
* The API checks every robot against its own site and narrows by the app's
|
|
96
105
|
* `appSlug`, so a robot this screen may not see simply never arrives.
|
|
97
106
|
*/
|
|
98
|
-
export function siteStatesFeed(intervalMs = 4000): TRobotLiveFeed {
|
|
107
|
+
export function siteStatesFeed(intervalMs: number | (() => number) = 4000): TRobotLiveFeed {
|
|
99
108
|
const appSlug = useRuntimeConfig().public?.appSlug as string | undefined;
|
|
100
109
|
return robotPollingFeed(
|
|
101
110
|
(ids) =>
|
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.64.
|
|
5
|
+
"version": "4.64.3",
|
|
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
|
@@ -1713,34 +1713,60 @@ const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
|
1713
1713
|
return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
|
|
1714
1714
|
};
|
|
1715
1715
|
const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
|
|
1716
|
+
/** The robot got there: the line stops following. */
|
|
1717
|
+
const ARRIVED = /^(Arrived at|Back on charger)/;
|
|
1718
|
+
/** After a start, the line follows the robot's own words this long (then keeps the last). */
|
|
1719
|
+
export const COMMAND_FOLLOW_MS = 60_000;
|
|
1720
|
+
/** Robot detail reads the robot this often for COMMAND_FOLLOW_MS after a command (else every 4 s). */
|
|
1721
|
+
export const FAST_POLL_MS = 1000;
|
|
1722
|
+
/**
|
|
1723
|
+
* Under the live view. Measured on prod 2026-09-27: the PadBot/EZVIZ picture
|
|
1724
|
+
* is 7-10 s behind (4 s HLS segments upstream); the map pin comes from the
|
|
1725
|
+
* position read / MQTT push and leads it.
|
|
1726
|
+
*/
|
|
1727
|
+
export const VIDEO_LAG_NOTE = "Video is about 8 s behind live; the map shows the robot's position in real time.";
|
|
1728
|
+
/** The line the moment the robot cloud accepts a moving command (the S2 takes 8-13 s to undock). */
|
|
1729
|
+
export function sentText(docked: boolean): string {
|
|
1730
|
+
return docked ? "Sent to robot — it usually takes about 10 s to leave the charger." : "Sent to robot — waiting for it to move…";
|
|
1731
|
+
}
|
|
1716
1732
|
|
|
1717
1733
|
/**
|
|
1718
1734
|
* What an accepted command actually did, from a reading taken AFTER it was
|
|
1719
1735
|
* sent (never the optimistic status). The robot cloud's "accepted" is not
|
|
1720
1736
|
* "done": PadBot answers code 0 and the robot may still not move.
|
|
1721
|
-
* - started: the robot shows it acted ->
|
|
1737
|
+
* - started: the robot shows it acted -> what it is doing in its own words
|
|
1738
|
+
* (`${who}: Leaving the charger…`, `${who}: On the way to P4…`), else
|
|
1739
|
+
* `${who} started ${what}.`; `done` once it arrives ("Arrived at P4",
|
|
1740
|
+
* "Back on charger") or COMMAND_FOLLOW_MS has passed.
|
|
1722
1741
|
* - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
|
|
1723
1742
|
* (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
|
|
1724
1743
|
* - otherwise: keep watching (null).
|
|
1725
1744
|
* Commands without a visible outcome (cancel, set position, pause) -> null.
|
|
1745
|
+
* The PadBot S2 drives with its navigation state idle; the API reads the
|
|
1746
|
+
* charging state and the position instead, so "started" is real movement.
|
|
1726
1747
|
*/
|
|
1727
1748
|
export function commandOutcome(
|
|
1728
1749
|
kind: unknown,
|
|
1729
1750
|
reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
|
|
1730
1751
|
elapsedMs: number,
|
|
1731
1752
|
input: { name?: unknown; what?: unknown } = {},
|
|
1732
|
-
): { ok: boolean; text: string } | null {
|
|
1753
|
+
): { ok: boolean; text: string; done: boolean } | null {
|
|
1733
1754
|
const started = STARTED[String(kind)];
|
|
1734
1755
|
if (!started || !reading) return null;
|
|
1735
1756
|
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1736
1757
|
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
1758
|
const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
|
|
1759
|
+
if (ARRIVED.test(said)) return { ok: true, text: `${who}: ${said}.`, done: true };
|
|
1760
|
+
if (typeof reading.mode === "string" && started.has(reading.mode)) {
|
|
1761
|
+
const doing = said && !RESTING.test(said) ? said : "";
|
|
1762
|
+
return { ok: true, text: doing ? `${who}: ${doing}…` : `${who} started ${what}.`, done: elapsedMs >= COMMAND_FOLLOW_MS };
|
|
1763
|
+
}
|
|
1764
|
+
if (elapsedMs < COMMAND_WATCH_MS) return null;
|
|
1740
1765
|
const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
|
|
1741
1766
|
const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
|
|
1742
1767
|
return {
|
|
1743
1768
|
ok: false,
|
|
1769
|
+
done: true,
|
|
1744
1770
|
text: reason
|
|
1745
1771
|
? `${who} didn't start — ${reason}.`
|
|
1746
1772
|
: `${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.`,
|