@7365admin1/layer-common 4.97.1-staging.566 → 4.97.1-staging.568

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.
@@ -30,8 +30,14 @@
30
30
  </div>
31
31
  </div>
32
32
 
33
- <!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot today). -->
34
- <RobotEmergencyStop v-if="cmdOk && caps?.emergencyStop === true" :robot-id="robotId" :name="record?.name" @stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS" />
33
+ <!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot, OneWay). OneWay's is a software stop: Release lifts it. -->
34
+ <RobotEmergencyStop
35
+ v-if="cmdOk && caps?.emergencyStop === true"
36
+ :robot-id="robotId"
37
+ :name="record?.name"
38
+ :releasable="caps?.emergencyRelease === true && fault?.code === 'E-STOP'"
39
+ @stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS"
40
+ />
35
41
 
36
42
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
37
43
  <div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
@@ -4,12 +4,15 @@
4
4
  command this robot. One tap, NO confirm - a stop must never wait on a
5
5
  dialog or on a state read. The server sends PadBot's "full machine stop"
6
6
  over the live link AND its stop command, and says which reached the robot.
7
+ OneWay's stop is a software one that stays on: while the robot reports it
8
+ (`releasable`), Release lifts it - after a confirm, since the robot may move again.
7
9
  -->
8
10
  <div class="hero-estop" role="group" aria-label="Emergency stop">
9
11
  <button type="button" class="hero-estop__btn" :disabled="busy" @click="stop">
10
12
  <span class="hero-estop__icon" aria-hidden="true">■</span>
11
13
  {{ busy ? "Stopping…" : "Emergency stop" }}
12
14
  </button>
15
+ <v-btn v-if="releasable" variant="outlined" :loading="releasing" :disabled="busy" @click="release">Release emergency stop</v-btn>
13
16
  <div v-if="result" class="hero-estop__msg" :class="{ 'hero-estop__msg--error': failed }" role="status" aria-live="assertive">{{ result }}</div>
14
17
  </div>
15
18
  </template>
@@ -18,11 +21,12 @@
18
21
  import { ref } from "vue";
19
22
  import { estopResultText } from "../../utils/robot-config";
20
23
 
21
- const props = defineProps<{ robotId: string; name?: string }>();
24
+ const props = defineProps<{ robotId: string; name?: string; releasable?: boolean }>();
22
25
  const emit = defineEmits<{ stopped: [] }>();
23
- const { emergencyStopRobot } = useRobots();
26
+ const { emergencyStopRobot, releaseEmergencyStopRobot } = useRobots();
24
27
 
25
28
  const busy = ref(false);
29
+ const releasing = ref(false);
26
30
  const result = ref("");
27
31
  const failed = ref(false);
28
32
 
@@ -42,6 +46,23 @@ async function stop() {
42
46
  busy.value = false;
43
47
  }
44
48
  }
49
+
50
+ async function release() {
51
+ if (!window.confirm(`Release the emergency stop on ${props.name || "this robot"}? It can take commands and move again.`)) return;
52
+ releasing.value = true;
53
+ failed.value = false;
54
+ result.value = "";
55
+ try {
56
+ const r = await releaseEmergencyStopRobot(props.robotId);
57
+ result.value = r?.message || "Emergency stop released.";
58
+ emit("stopped");
59
+ } catch (e: any) {
60
+ failed.value = true;
61
+ result.value = e?.data?.message || e?.message || "The release did not reach the robot. The emergency stop is still on.";
62
+ } finally {
63
+ releasing.value = false;
64
+ }
65
+ }
45
66
  </script>
46
67
 
47
68
  <style scoped>
@@ -67,7 +67,7 @@
67
67
  :id="`${uid}-token`"
68
68
  v-model="appToken"
69
69
  class="hero-input"
70
- type="password"
70
+ :type="tokenSecret ? 'password' : 'text'"
71
71
  autocomplete="new-password"
72
72
  spellcheck="false"
73
73
  :placeholder="credentialPlaceholder(editing)"
@@ -108,7 +108,14 @@ import {
108
108
  type TRobotType,
109
109
  } from "../../utils/robot-display";
110
110
  import { credentialsNote } from "../../utils/robot-floorplan";
111
- import { credentialLabels, credentialMissingMessage, discoveredRobotOptions } from "../../utils/robot-credential-form";
111
+ import {
112
+ credentialDiscoverable,
113
+ credentialLabels,
114
+ credentialMissingMessage,
115
+ credentialTokenDefault,
116
+ credentialTokenIsSecret,
117
+ discoveredRobotOptions,
118
+ } from "../../utils/robot-credential-form";
112
119
 
113
120
  const props = withDefaults(
114
121
  defineProps<{
@@ -154,9 +161,17 @@ const robotSn = ref(savedSn);
154
161
  const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]);
155
162
  const finding = ref(false);
156
163
  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");
164
+ // A prefilled server address with no key typed is not a typed pair (Find robots then uses the client's saved one).
165
+ const tokenValue = computed(() => (!appKey.value.trim() && appToken.value.trim() === credentialTokenDefault(brand.value) ? "" : appToken.value.trim()));
166
+ const typed = computed(() => !!appKey.value.trim() && !!tokenValue.value);
167
+ // Ounitech / PadBot / OneWay: one company credential lists its robots, so the robot is picked from that list.
168
+ const discoverable = computed(() => credentialDiscoverable(brand.value));
169
+ // OneWay's second field is its server's address: plain text, prefilled with OneWay's cloud when adding.
170
+ const tokenSecret = computed(() => credentialTokenIsSecret(brand.value));
171
+ watch(brand, (b, was) => {
172
+ if (editing.value) return;
173
+ if (!appToken.value.trim() || appToken.value === credentialTokenDefault(was)) appToken.value = credentialTokenDefault(b);
174
+ });
160
175
  const findHint = computed(() =>
161
176
  editing.value || typed.value
162
177
  ? "Press Find robots"
@@ -219,12 +234,12 @@ onMounted(async () => {
219
234
 
220
235
  async function find() {
221
236
  error.value = "";
222
- if (!!appKey.value.trim() !== !!appToken.value.trim()) {
237
+ if (!!appKey.value.trim() !== !!tokenValue.value) {
223
238
  return void (error.value = `Enter both the ${labels.value.key} and the ${labels.value.token}.`);
224
239
  }
225
240
  finding.value = true;
226
241
  try {
227
- const pair = typed.value ? { appKey: appKey.value.trim(), appToken: appToken.value.trim() } : null;
242
+ const pair = typed.value ? { appKey: appKey.value.trim(), appToken: tokenValue.value } : null;
228
243
  const body = pair
229
244
  ? { site: site.value, brand: brand.value, ...pair }
230
245
  : editing.value
@@ -247,11 +262,11 @@ async function submit() {
247
262
  error.value = "";
248
263
  if (!name.value.trim()) return void (error.value = "Give the robot a name.");
249
264
  // 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();
265
+ const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !tokenValue.value;
251
266
  if (useSaved && !robotSn.value) {
252
267
  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
268
  }
254
- const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, appToken.value, editing.value);
269
+ const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, tokenValue.value, editing.value);
255
270
  if (!creds.ok) return void (error.value = credentialMissingMessage(labels.value, editing.value));
256
271
  const serial = robotSn.value && robotSn.value !== savedSn ? { robotSn: robotSn.value } : {};
257
272
  saving.value = true;
@@ -1,33 +1,55 @@
1
1
  <template>
2
2
  <div data-test="visitor-pass-sms-settings">
3
- <v-switch v-model="enabled" :disabled="readOnly" label="Send visitor pass by SMS" color="primary" hide-details
4
- data-test="visitor-pass-sms-switch" />
5
- <p class="text-caption text-medium-emphasis mt-1 mb-3">
6
- Staff can text an approved visitor their pass link (the QR they show at the guardhouse). The text has the site
7
- name, the visit date and the link only.
8
- </p>
9
- <v-btn v-if="!readOnly" class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
10
- :loading="saving" :disabled="!loaded" data-test="visitor-pass-sms-save" @click="save" />
3
+ <template v-if="canEdit">
4
+ <v-switch v-model="enabled" label="Send visitor pass by SMS" color="primary" hide-details
5
+ data-test="visitor-pass-sms-switch" />
6
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
7
+ Staff can text an approved visitor their pass link (the QR they show at the guardhouse). The text has the site
8
+ name, the visit date and the link only. Only Seven365 can switch this on or off.
9
+ </p>
10
+ <v-btn class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
11
+ :loading="saving" :disabled="!loaded" data-test="visitor-pass-sms-save" @click="save" />
12
+ </template>
13
+ <template v-else>
14
+ <div class="text-body-2" data-test="visitor-pass-sms-readonly">
15
+ SMS pass: <strong>{{ savedOn ? "On" : "Off" }}</strong> — managed by Seven365.
16
+ </div>
17
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
18
+ Contact Seven365 to {{ savedOn ? "switch it off" : "enable" }}. When on, staff can text an approved visitor
19
+ their pass link (site name, visit date and link only).
20
+ </p>
21
+ </template>
11
22
  <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
12
23
  </div>
13
24
  </template>
14
25
 
15
26
  <script setup lang="ts">
16
27
  import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
28
+ import useConsoleTier from "../composables/useConsoleTier";
17
29
 
18
30
  /**
19
31
  * Site setting `metadata.visitorPassSms` (core site-feature-settings).
20
32
  * Off = today: no "Send pass by SMS" action anywhere.
33
+ *
34
+ * SMS is billed to Seven365, so only the Seven365 Super Admin switches it
35
+ * (owner 2026-10-19). Everyone else sees On/Off, read-only. The server
36
+ * refuses a change by anyone else (403); this only decides what to draw.
21
37
  */
22
38
  const props = defineProps({
23
39
  siteId: { type: String, required: true },
24
40
  saved: { type: Object as PropType<{ enabled?: boolean } | null>, default: null },
41
+ /** Kept for callers; editing now depends on being Seven365 Super Admin only. */
25
42
  readOnly: { type: Boolean, default: false },
26
43
  /** The site has been read; Save stays off until then. */
27
44
  loaded: { type: Boolean, default: false },
28
45
  });
29
46
  const emit = defineEmits(["saved"]);
30
47
 
48
+ const { isStaff, load: loadTier } = useConsoleTier();
49
+ const canEdit = computed(() => isStaff.value);
50
+ onMounted(() => loadTier());
51
+
52
+ const savedOn = computed(() => props.saved?.enabled === true);
31
53
  const enabled = ref(false);
32
54
  watch(
33
55
  () => props.saved,
@@ -42,7 +64,7 @@ const saving = ref(false);
42
64
  const toast = reactive({ show: false, message: "", color: "" });
43
65
 
44
66
  async function save() {
45
- if (!props.loaded) return;
67
+ if (!props.loaded || !canEdit.value) return;
46
68
  saving.value = true;
47
69
  try {
48
70
  await saveSiteFeature(props.siteId, "visitorPassSms", { enabled: enabled.value });
@@ -358,6 +358,15 @@ export default function useRobots() {
358
358
  );
359
359
  }
360
360
 
361
+ /** `emergencyRelease` (OneWay): lift a software emergency stop so the robot takes commands again. */
362
+ function releaseEmergencyStopRobot(id: string) {
363
+ return useNuxtApp().$api<{ ok: boolean; message: string }>(`/api/robots/${id}/emergency-stop`, {
364
+ method: "POST",
365
+ query: { appSlug },
366
+ body: { release: true },
367
+ });
368
+ }
369
+
361
370
  /** Super Admin setup: what can be changed now, read fresh from the maker's cloud (`mapId` picks the map). */
362
371
  function getRobotSetup(id: string, mapId?: string | null) {
363
372
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/setup`, {
@@ -656,6 +665,7 @@ export default function useRobots() {
656
665
  saveSiteFleetCore,
657
666
  createSiteRelayKey,
658
667
  emergencyStopRobot,
668
+ releaseEmergencyStopRobot,
659
669
  getRobotSetup,
660
670
  applyRobotSetup,
661
671
  listBuildingMaps,
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.97.1-staging.566",
5
+ "version": "4.97.1-staging.568",
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.",
@@ -14,9 +14,29 @@ export function credentialLabels(brand: unknown): { key: string; token: string }
14
14
  // PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
15
15
  if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
16
16
  if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
17
+ // OneWay (Anbot RMS): one API key per company; the second field is the RMS server's address.
18
+ if (brand === "oneway") return { key: "API key", token: "Server address" };
17
19
  return { key: "App key", token: "Token" };
18
20
  }
19
21
 
22
+ /** OneWay's cloud, prefilled in "Server address" when adding (change it only for a company's own RMS). */
23
+ export const ONEWAY_DEFAULT_SERVER = "https://www.anbotcloud.cn";
24
+
25
+ /** What the second field starts with when adding a robot of this brand ("" = empty). */
26
+ export function credentialTokenDefault(brand: unknown): string {
27
+ return brand === "oneway" ? ONEWAY_DEFAULT_SERVER : "";
28
+ }
29
+
30
+ /** False when the second field is not a secret (OneWay's server address): shown as plain text. */
31
+ export function credentialTokenIsSecret(brand: unknown): boolean {
32
+ return brand !== "oneway";
33
+ }
34
+
35
+ /** Brands whose one company credential lists its robots, so Add robot picks the serial (Find robots). */
36
+ export function credentialDiscoverable(brand: unknown): boolean {
37
+ return brand === "ounitech" || brand === "padbot" || brand === "oneway";
38
+ }
39
+
20
40
  /** "Enter the AppID and the Secret ..." - `credentialPatch`'s refusal, in the brand's words. */
21
41
  export function credentialMissingMessage(labels: { key: string; token: string }, editing: boolean): string {
22
42
  return editing