@7365admin1/layer-common 4.1.3-staging.251 → 4.1.3-staging.253
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/components/CameraBulkImportDialog.vue +330 -0
- package/components/CameraMain.vue +125 -9
- package/components/HidAccessLogDashboard.vue +2 -66
- package/components/HidReaderUserRoster.vue +3 -41
- package/components/HidUserEnrollment.vue +45 -62
- package/composables/useHidAmico.ts +0 -6
- package/composables/useSiteSettings.ts +37 -0
- package/package.json +1 -1
- package/utils/camera-csv.ts +169 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card width="100%" class="screen-modal">
|
|
3
|
+
<v-card-title class="text-capitalize">Import Cameras</v-card-title>
|
|
4
|
+
|
|
5
|
+
<v-card-text class="screen-modal__body pa-5">
|
|
6
|
+
<!-- Step 1 — the file. -->
|
|
7
|
+
<template v-if="!results.length">
|
|
8
|
+
<p class="text-body-2 mb-4">
|
|
9
|
+
Upload the camera survey sheet as a CSV file. Each row is one camera:
|
|
10
|
+
its name, the recorder it is on, and its channel number on that
|
|
11
|
+
recorder. The live-view address is worked out for you — do not type
|
|
12
|
+
one.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<v-file-input
|
|
16
|
+
v-model="file"
|
|
17
|
+
accept=".csv,text/csv"
|
|
18
|
+
density="comfortable"
|
|
19
|
+
variant="outlined"
|
|
20
|
+
prepend-icon=""
|
|
21
|
+
prepend-inner-icon="mdi-file-delimited-outline"
|
|
22
|
+
label="Camera survey sheet (.csv)"
|
|
23
|
+
:disabled="busy"
|
|
24
|
+
@update:model-value="readFile"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<v-btn
|
|
28
|
+
variant="text"
|
|
29
|
+
size="small"
|
|
30
|
+
class="text-none px-0"
|
|
31
|
+
prepend-icon="mdi-download"
|
|
32
|
+
:disabled="busy"
|
|
33
|
+
@click="downloadTemplate"
|
|
34
|
+
>
|
|
35
|
+
Download a blank sheet
|
|
36
|
+
</v-btn>
|
|
37
|
+
|
|
38
|
+
<v-alert
|
|
39
|
+
v-if="fileError"
|
|
40
|
+
type="error"
|
|
41
|
+
variant="tonal"
|
|
42
|
+
density="comfortable"
|
|
43
|
+
class="mt-4"
|
|
44
|
+
:text="fileError"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<v-alert
|
|
48
|
+
v-if="notes.length"
|
|
49
|
+
type="info"
|
|
50
|
+
variant="tonal"
|
|
51
|
+
density="comfortable"
|
|
52
|
+
class="mt-4"
|
|
53
|
+
>
|
|
54
|
+
<div v-for="note in notes" :key="note">{{ note }}</div>
|
|
55
|
+
</v-alert>
|
|
56
|
+
|
|
57
|
+
<p class="text-caption mt-4 mb-0">
|
|
58
|
+
Required columns: camera_name, recorder_id, channel_number,
|
|
59
|
+
for_cctv_wall, for_virtual_patrol, is_anpr. ANPR cameras are set up in
|
|
60
|
+
the ANPR section — mark them <strong>is_anpr = Yes</strong> and they
|
|
61
|
+
will be left out of this import.
|
|
62
|
+
</p>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<!-- Step 2 — what will happen, or what happened. -->
|
|
66
|
+
<template v-else>
|
|
67
|
+
<v-alert
|
|
68
|
+
:type="committed ? (summary.rejected ? 'warning' : 'success') : 'info'"
|
|
69
|
+
variant="tonal"
|
|
70
|
+
density="comfortable"
|
|
71
|
+
class="mb-4"
|
|
72
|
+
>
|
|
73
|
+
{{ summaryText }}
|
|
74
|
+
</v-alert>
|
|
75
|
+
|
|
76
|
+
<v-alert
|
|
77
|
+
v-if="!committed && notes.length"
|
|
78
|
+
type="info"
|
|
79
|
+
variant="tonal"
|
|
80
|
+
density="comfortable"
|
|
81
|
+
class="mb-4"
|
|
82
|
+
>
|
|
83
|
+
<div v-for="note in notes" :key="note">{{ note }}</div>
|
|
84
|
+
</v-alert>
|
|
85
|
+
|
|
86
|
+
<v-data-table
|
|
87
|
+
:headers="resultHeaders"
|
|
88
|
+
:items="results"
|
|
89
|
+
density="compact"
|
|
90
|
+
fixed-header
|
|
91
|
+
hide-default-footer
|
|
92
|
+
:items-per-page="-1"
|
|
93
|
+
style="max-height: 420px"
|
|
94
|
+
>
|
|
95
|
+
<template #item.outcome="{ value }">
|
|
96
|
+
<v-chip size="small" :color="outcomeColour(value)" variant="tonal">
|
|
97
|
+
{{ outcomeLabel(value) }}
|
|
98
|
+
</v-chip>
|
|
99
|
+
</template>
|
|
100
|
+
<template #item.channel="{ value }">
|
|
101
|
+
{{ value ?? "—" }}
|
|
102
|
+
</template>
|
|
103
|
+
<template #item.reason="{ value }">
|
|
104
|
+
<span class="text-body-2">{{ value ?? "" }}</span>
|
|
105
|
+
</template>
|
|
106
|
+
</v-data-table>
|
|
107
|
+
</template>
|
|
108
|
+
</v-card-text>
|
|
109
|
+
|
|
110
|
+
<v-card-actions class="screen-modal__footer">
|
|
111
|
+
<v-row no-gutters>
|
|
112
|
+
<v-col cols="6" class="pr-2">
|
|
113
|
+
<v-btn
|
|
114
|
+
block
|
|
115
|
+
variant="outlined"
|
|
116
|
+
class="text-none screen-btn-ghost"
|
|
117
|
+
:disabled="busy"
|
|
118
|
+
@click="close"
|
|
119
|
+
:text="committed ? 'Close' : 'Cancel'"
|
|
120
|
+
/>
|
|
121
|
+
</v-col>
|
|
122
|
+
<v-col cols="6" class="pl-2">
|
|
123
|
+
<v-btn
|
|
124
|
+
v-if="!committed"
|
|
125
|
+
block
|
|
126
|
+
variant="flat"
|
|
127
|
+
class="text-none screen-btn-primary"
|
|
128
|
+
:disabled="busy || !summary.ok"
|
|
129
|
+
:loading="busy"
|
|
130
|
+
@click="commit"
|
|
131
|
+
:text="summary.ok ? `Add ${summary.ok} camera(s)` : 'Nothing to add'"
|
|
132
|
+
/>
|
|
133
|
+
<v-btn
|
|
134
|
+
v-else
|
|
135
|
+
block
|
|
136
|
+
variant="flat"
|
|
137
|
+
class="text-none screen-btn-primary"
|
|
138
|
+
@click="startOver"
|
|
139
|
+
text="Import another file"
|
|
140
|
+
/>
|
|
141
|
+
</v-col>
|
|
142
|
+
</v-row>
|
|
143
|
+
</v-card-actions>
|
|
144
|
+
</v-card>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<script setup lang="ts">
|
|
148
|
+
import useSiteSettings from "../composables/useSiteSettings";
|
|
149
|
+
import { cameraCsvTemplate, parseCameraCsv } from "../utils/camera-csv";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Adding a site's cameras from the technician's survey sheet.
|
|
153
|
+
*
|
|
154
|
+
* A ~150-camera site could only ever be entered one camera at a time, through a
|
|
155
|
+
* two-field modal, using a live-view address that nobody outside the video
|
|
156
|
+
* service can work out. This screen takes the survey sheet instead.
|
|
157
|
+
*
|
|
158
|
+
* **The server decides everything.** This component parses the file and shows
|
|
159
|
+
* what came back; it does not judge a row. The preview and the commit are the
|
|
160
|
+
* same server call with `commit` flipped, so what a person approves here cannot
|
|
161
|
+
* disagree with what is then written — the failure that makes a bulk import
|
|
162
|
+
* frightening. That is also why there is no "fix it here" editing: a corrected
|
|
163
|
+
* sheet is re-uploaded, and the file on disk stays the record of what was
|
|
164
|
+
* asked for.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/** The rows one file may carry. The server enforces the same number. */
|
|
168
|
+
const MAX_ROWS = 200;
|
|
169
|
+
|
|
170
|
+
const prop = defineProps({
|
|
171
|
+
site: {
|
|
172
|
+
type: String,
|
|
173
|
+
required: true,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const emit = defineEmits(["cancel", "imported"]);
|
|
178
|
+
|
|
179
|
+
const { bulkImportSiteCameras } = useSiteSettings();
|
|
180
|
+
|
|
181
|
+
const file = ref<File | File[] | null>(null);
|
|
182
|
+
const fileError = ref("");
|
|
183
|
+
const notes = ref<string[]>([]);
|
|
184
|
+
const rows = ref<Array<Record<string, string>>>([]);
|
|
185
|
+
const results = ref<Array<Record<string, any>>>([]);
|
|
186
|
+
const summary = ref<Record<string, number>>({
|
|
187
|
+
total: 0,
|
|
188
|
+
ok: 0,
|
|
189
|
+
created: 0,
|
|
190
|
+
skipped: 0,
|
|
191
|
+
rejected: 0,
|
|
192
|
+
});
|
|
193
|
+
const committed = ref(false);
|
|
194
|
+
const busy = ref(false);
|
|
195
|
+
|
|
196
|
+
const resultHeaders = [
|
|
197
|
+
{ title: "Row", key: "row", width: 70 },
|
|
198
|
+
{ title: "Camera Name", key: "name" },
|
|
199
|
+
{ title: "Channel", key: "channel", width: 90 },
|
|
200
|
+
{ title: "Result", key: "outcome", width: 140 },
|
|
201
|
+
{ title: "Reason", key: "reason" },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const summaryText = computed(() => {
|
|
205
|
+
if (!committed.value) {
|
|
206
|
+
return `${summary.value.ok} camera(s) will be added, ${summary.value.skipped} already at this site, ${summary.value.rejected} cannot be added. Nothing has been saved yet.`;
|
|
207
|
+
}
|
|
208
|
+
return `${summary.value.created} camera(s) added, ${summary.value.skipped} already at this site, ${summary.value.rejected} not added.`;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function outcomeLabel(outcome: string) {
|
|
212
|
+
if (outcome === "ok") return "Will be added";
|
|
213
|
+
if (outcome === "created") return "Added";
|
|
214
|
+
if (outcome === "skipped-duplicate") return "Already there";
|
|
215
|
+
return "Not added";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function outcomeColour(outcome: string) {
|
|
219
|
+
if (outcome === "created") return "success";
|
|
220
|
+
if (outcome === "ok") return "info";
|
|
221
|
+
if (outcome === "skipped-duplicate") return "warning";
|
|
222
|
+
return "error";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function reset() {
|
|
226
|
+
fileError.value = "";
|
|
227
|
+
notes.value = [];
|
|
228
|
+
rows.value = [];
|
|
229
|
+
results.value = [];
|
|
230
|
+
summary.value = { total: 0, ok: 0, created: 0, skipped: 0, rejected: 0 };
|
|
231
|
+
committed.value = false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function startOver() {
|
|
235
|
+
reset();
|
|
236
|
+
file.value = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function close() {
|
|
240
|
+
emit("cancel");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function downloadTemplate() {
|
|
244
|
+
const blob = new Blob([cameraCsvTemplate()], { type: "text/csv" });
|
|
245
|
+
const url = URL.createObjectURL(blob);
|
|
246
|
+
const link = document.createElement("a");
|
|
247
|
+
link.href = url;
|
|
248
|
+
link.download = "camera-survey-sheet.csv";
|
|
249
|
+
link.click();
|
|
250
|
+
URL.revokeObjectURL(url);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What the sheet carries that a camera record has no field for. */
|
|
254
|
+
function describeUnstored(columns: string[]) {
|
|
255
|
+
if (!columns.length) return [];
|
|
256
|
+
return [
|
|
257
|
+
`These columns are read but not stored, because a camera record has nowhere to keep them: ${columns.join(", ")}. Use them to name the cameras and to build the patrol route in the right order.`,
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readFile(selected: File | File[] | null) {
|
|
262
|
+
reset();
|
|
263
|
+
|
|
264
|
+
const chosen = Array.isArray(selected) ? selected[0] : selected;
|
|
265
|
+
if (!chosen) return;
|
|
266
|
+
|
|
267
|
+
const parsed = parseCameraCsv(await chosen.text());
|
|
268
|
+
|
|
269
|
+
if (parsed.missingColumns.length) {
|
|
270
|
+
fileError.value = `This file is missing: ${parsed.missingColumns.join(", ")}. Download the blank sheet to see the columns it needs.`;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!parsed.rows.length) {
|
|
275
|
+
fileError.value = "This file has column headings but no cameras in it.";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (parsed.rows.length > MAX_ROWS) {
|
|
280
|
+
fileError.value = `This file has ${parsed.rows.length} rows. Import up to ${MAX_ROWS} at a time — split it and import each part.`;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
notes.value = [
|
|
285
|
+
...describeUnstored([...parsed.unstoredColumns]),
|
|
286
|
+
...(parsed.unknownColumns.length
|
|
287
|
+
? [
|
|
288
|
+
`These columns are not recognised and are ignored: ${parsed.unknownColumns.join(", ")}.`,
|
|
289
|
+
]
|
|
290
|
+
: []),
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
rows.value = parsed.rows;
|
|
294
|
+
await send(false);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function send(commitRows: boolean) {
|
|
298
|
+
busy.value = true;
|
|
299
|
+
try {
|
|
300
|
+
const response = await bulkImportSiteCameras({
|
|
301
|
+
site: prop.site,
|
|
302
|
+
rows: rows.value,
|
|
303
|
+
commit: commitRows,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
results.value = response?.results ?? [];
|
|
307
|
+
summary.value = response?.results
|
|
308
|
+
? response.summary
|
|
309
|
+
: { total: 0, ok: 0, created: 0, skipped: 0, rejected: 0 };
|
|
310
|
+
committed.value = Boolean(response?.committed);
|
|
311
|
+
|
|
312
|
+
if (committed.value) emit("imported", summary.value);
|
|
313
|
+
} catch (error: any) {
|
|
314
|
+
// A refusal from the server is shown as it was written — it is a sentence
|
|
315
|
+
// for the person at this screen, and rewriting it here would lose the one
|
|
316
|
+
// thing that tells them what to do next.
|
|
317
|
+
fileError.value =
|
|
318
|
+
error?.data?.message ||
|
|
319
|
+
error?.message ||
|
|
320
|
+
"The cameras could not be checked. Try again.";
|
|
321
|
+
results.value = [];
|
|
322
|
+
} finally {
|
|
323
|
+
busy.value = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function commit() {
|
|
328
|
+
return send(true);
|
|
329
|
+
}
|
|
330
|
+
</script>
|
|
@@ -16,9 +16,29 @@
|
|
|
16
16
|
<AppButton v-if="!prop.readOnly" @click="dialogAdd = true">
|
|
17
17
|
Add
|
|
18
18
|
</AppButton>
|
|
19
|
+
<AppButton
|
|
20
|
+
v-if="!prop.readOnly && prop.type === 'ip'"
|
|
21
|
+
variant="ghost"
|
|
22
|
+
@click="dialogImport = true"
|
|
23
|
+
>
|
|
24
|
+
Import
|
|
25
|
+
</AppButton>
|
|
19
26
|
</template>
|
|
20
27
|
|
|
21
28
|
<template #right>
|
|
29
|
+
<!-- Searching the loaded list, not the server: the endpoint takes
|
|
30
|
+
no search parameter, and every camera at the site is already
|
|
31
|
+
here. -->
|
|
32
|
+
<v-text-field
|
|
33
|
+
v-model.trim="search"
|
|
34
|
+
class="filter-field mr-3"
|
|
35
|
+
density="compact"
|
|
36
|
+
hide-details
|
|
37
|
+
variant="outlined"
|
|
38
|
+
prepend-inner-icon="mdi-magnify"
|
|
39
|
+
placeholder="Search name or address"
|
|
40
|
+
style="max-width: 260px"
|
|
41
|
+
/>
|
|
22
42
|
<local-pagination
|
|
23
43
|
v-model="page"
|
|
24
44
|
:length="pages"
|
|
@@ -29,7 +49,7 @@
|
|
|
29
49
|
|
|
30
50
|
<v-data-table
|
|
31
51
|
:headers="computedHeaders"
|
|
32
|
-
:items="
|
|
52
|
+
:items="visibleItems"
|
|
33
53
|
fixed-header
|
|
34
54
|
hide-default-footer
|
|
35
55
|
@click:row="handleRowClick"
|
|
@@ -55,6 +75,14 @@
|
|
|
55
75
|
/>
|
|
56
76
|
</v-dialog>
|
|
57
77
|
|
|
78
|
+
<v-dialog v-model="dialogImport" persistent width="900" scrollable>
|
|
79
|
+
<CameraBulkImportDialog
|
|
80
|
+
:site="prop.site"
|
|
81
|
+
@cancel="dialogImport = false"
|
|
82
|
+
@imported="handleImported"
|
|
83
|
+
/>
|
|
84
|
+
</v-dialog>
|
|
85
|
+
|
|
58
86
|
<v-dialog v-model="dialogEdit" persistent width="450">
|
|
59
87
|
<CameraForm
|
|
60
88
|
title="Edit Camera"
|
|
@@ -312,31 +340,112 @@ const computedHeaders = computed(() => {
|
|
|
312
340
|
return prop.headers;
|
|
313
341
|
});
|
|
314
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Every camera at the site, paged locally.
|
|
345
|
+
*
|
|
346
|
+
* This asked the server for page 1 with no `limit`, against a server default of
|
|
347
|
+
* 10 — so a site with more than ten cameras showed ten, and the pager below was
|
|
348
|
+
* driven by the server's page count while the search box did not exist. At 150
|
|
349
|
+
* cameras that is fifteen server pages and no way to find one by name.
|
|
350
|
+
*
|
|
351
|
+
* Now the list is fetched once, in server pages of `PAGE_SIZE`, and searched
|
|
352
|
+
* and paged in the browser. Deliberately NOT one unbounded request: the
|
|
353
|
+
* endpoint accepts a `limit` with no upper bound, and asking it for everything
|
|
354
|
+
* is how one large site becomes a slow page for every site. `MAX_PAGES` is the
|
|
355
|
+
* backstop, and when it is reached the toolbar says so rather than quietly
|
|
356
|
+
* showing part of the list.
|
|
357
|
+
*
|
|
358
|
+
* ponytail: searching and paging in the browser, because the endpoint has no
|
|
359
|
+
* search parameter and a site's camera list is small enough to hold. If a site
|
|
360
|
+
* ever passes `PAGE_SIZE * MAX_PAGES`, this wants a server-side search instead.
|
|
361
|
+
*/
|
|
362
|
+
const PAGE_SIZE = 100;
|
|
363
|
+
const MAX_PAGES = 5;
|
|
364
|
+
const ROWS_PER_PAGE = 10;
|
|
365
|
+
|
|
315
366
|
const items = ref<Array<TCamera>>([]);
|
|
316
367
|
const page = ref(1);
|
|
317
368
|
const pages = ref(0);
|
|
318
369
|
const pageRange = ref("-- - -- of --");
|
|
370
|
+
const search = ref("");
|
|
371
|
+
const truncated = ref(false);
|
|
319
372
|
|
|
320
373
|
const { getAllSiteCameras, deleteSiteCameraById } = useSiteSettings();
|
|
374
|
+
|
|
375
|
+
async function getAllCamerasAtSite() {
|
|
376
|
+
const all: Array<TCamera> = [];
|
|
377
|
+
let serverPages = 1;
|
|
378
|
+
let serverPage = 1;
|
|
379
|
+
|
|
380
|
+
while (serverPage <= serverPages && serverPage <= MAX_PAGES) {
|
|
381
|
+
const response = await getAllSiteCameras({
|
|
382
|
+
site: prop.site,
|
|
383
|
+
type: prop.type,
|
|
384
|
+
page: serverPage,
|
|
385
|
+
limit: PAGE_SIZE,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
all.push(...((response?.items ?? []) as Array<TCamera>));
|
|
389
|
+
serverPages = Number(response?.pages) || 1;
|
|
390
|
+
serverPage += 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
truncated.value = serverPages > MAX_PAGES;
|
|
394
|
+
return all;
|
|
395
|
+
}
|
|
396
|
+
|
|
321
397
|
const { data: getCameraReq, refresh: getCameraRefresh } =
|
|
322
398
|
await useLazyAsyncData(
|
|
323
399
|
`get-site-${prop.site}-${prop.type}-cameras`,
|
|
324
|
-
|
|
325
|
-
getAllSiteCameras({ site: prop.site, type: prop.type, page: page.value }),
|
|
326
|
-
{
|
|
327
|
-
watch: [page],
|
|
328
|
-
}
|
|
400
|
+
getAllCamerasAtSite
|
|
329
401
|
);
|
|
330
402
|
|
|
403
|
+
/** The site's cameras matching the search box, in the order they came back. */
|
|
404
|
+
const matchingItems = computed(() => {
|
|
405
|
+
const needle = search.value.toLowerCase();
|
|
406
|
+
if (!needle) return items.value;
|
|
407
|
+
|
|
408
|
+
return items.value.filter((camera: any) =>
|
|
409
|
+
[camera?.name, camera?.host].some((field) =>
|
|
410
|
+
String(field ?? "").toLowerCase().includes(needle)
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** One page of them. */
|
|
416
|
+
const visibleItems = computed(() =>
|
|
417
|
+
matchingItems.value.slice(
|
|
418
|
+
(page.value - 1) * ROWS_PER_PAGE,
|
|
419
|
+
page.value * ROWS_PER_PAGE
|
|
420
|
+
)
|
|
421
|
+
);
|
|
422
|
+
|
|
331
423
|
watchEffect(() => {
|
|
332
424
|
if (getCameraReq.value) {
|
|
333
|
-
items.value = getCameraReq.value
|
|
334
|
-
pageRange.value = getCameraReq.value.pageRange;
|
|
335
|
-
pages.value = getCameraReq.value.pages;
|
|
425
|
+
items.value = getCameraReq.value as Array<TCamera>;
|
|
336
426
|
}
|
|
337
427
|
});
|
|
338
428
|
|
|
429
|
+
// A search that leaves fewer pages than the one being looked at would otherwise
|
|
430
|
+
// show an empty table with no explanation.
|
|
431
|
+
watch(search, () => {
|
|
432
|
+
page.value = 1;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
watchEffect(() => {
|
|
436
|
+
const total = matchingItems.value.length;
|
|
437
|
+
pages.value = Math.max(1, Math.ceil(total / ROWS_PER_PAGE));
|
|
438
|
+
|
|
439
|
+
const first = total ? (page.value - 1) * ROWS_PER_PAGE + 1 : 0;
|
|
440
|
+
const last = Math.min(page.value * ROWS_PER_PAGE, total);
|
|
441
|
+
|
|
442
|
+
pageRange.value = total
|
|
443
|
+
? `${first} - ${last} of ${total}${truncated.value ? "+" : ""}`
|
|
444
|
+
: "0 - 0 of 0";
|
|
445
|
+
});
|
|
446
|
+
|
|
339
447
|
const dialogAdd = ref(false);
|
|
448
|
+
const dialogImport = ref(false);
|
|
340
449
|
const dialogEdit = ref(false);
|
|
341
450
|
const dialogPreview = ref(false);
|
|
342
451
|
const dialogDelete = ref(false);
|
|
@@ -363,6 +472,13 @@ function openDialogDelete() {
|
|
|
363
472
|
if (dialogPreview.value) dialogPreview.value = false;
|
|
364
473
|
}
|
|
365
474
|
|
|
475
|
+
function handleImported(summary: { created?: number }) {
|
|
476
|
+
// The dialog stays open on purpose — it is showing the per-row result, which
|
|
477
|
+
// is the only record of what happened to a 150-row file. The list behind it
|
|
478
|
+
// is refreshed so closing it shows the truth.
|
|
479
|
+
if (summary?.created) getCameraRefresh();
|
|
480
|
+
}
|
|
481
|
+
|
|
366
482
|
function handleError(msg: string) {
|
|
367
483
|
showMessage(msg, "error");
|
|
368
484
|
}
|
|
@@ -89,15 +89,9 @@
|
|
|
89
89
|
|
|
90
90
|
<!-- Staging's hover-the-full-name tooltip, kept. -->
|
|
91
91
|
<template #[`item.name`]="{ item }">
|
|
92
|
+
<!-- Client requirement (2026-08-26): no face photo on an access log row.
|
|
93
|
+
The reader keeps the image; we neither fetch nor hold one. -->
|
|
92
94
|
<span class="hid-log-name app-cell--strong">
|
|
93
|
-
<v-avatar
|
|
94
|
-
v-if="item.faceImage"
|
|
95
|
-
size="34"
|
|
96
|
-
rounded="lg"
|
|
97
|
-
class="hid-face-avatar"
|
|
98
|
-
>
|
|
99
|
-
<v-img :src="item.faceImage" cover />
|
|
100
|
-
</v-avatar>
|
|
101
95
|
<span>{{ item.name }}</span>
|
|
102
96
|
<v-tooltip
|
|
103
97
|
v-if="item.name && item.name !== 'N/A'"
|
|
@@ -314,7 +308,6 @@ type AccessRow = {
|
|
|
314
308
|
name: string;
|
|
315
309
|
hidUserId: string;
|
|
316
310
|
facialData: string;
|
|
317
|
-
faceImage?: string;
|
|
318
311
|
unit: string;
|
|
319
312
|
method: string;
|
|
320
313
|
lastAccessTime: string;
|
|
@@ -371,7 +364,6 @@ type JsPdf = {
|
|
|
371
364
|
const {
|
|
372
365
|
getReaders,
|
|
373
366
|
getLogs,
|
|
374
|
-
getUserImage,
|
|
375
367
|
getReaderUsers,
|
|
376
368
|
getReaderAccessLogs,
|
|
377
369
|
} = useHidAmico();
|
|
@@ -384,7 +376,6 @@ const visitorRecords = ref<Record<string, HidAccessRecord>>({});
|
|
|
384
376
|
const logs = ref<HidAccessRecord[]>([]);
|
|
385
377
|
const liveAccessLogs = ref<HidAccessRecord[]>([]);
|
|
386
378
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
387
|
-
const userImages = ref<Record<string, string>>({});
|
|
388
379
|
const selectedReaderId = ref("");
|
|
389
380
|
const activeTab = ref<AccessTab>("all");
|
|
390
381
|
const search = ref("");
|
|
@@ -470,7 +461,6 @@ const selectedHistoryRows = computed<AccessRow[]>(() => {
|
|
|
470
461
|
name: selectedHistoryRow.value?.name || "N/A",
|
|
471
462
|
hidUserId: selectedHistoryRow.value?.hidUserId || "N/A",
|
|
472
463
|
facialData: selectedHistoryRow.value?.facialData || "N/A",
|
|
473
|
-
faceImage: selectedHistoryRow.value?.faceImage,
|
|
474
464
|
unit: selectedHistoryRow.value?.unit || "N/A",
|
|
475
465
|
method: getAccessMethod({}, log),
|
|
476
466
|
lastAccessTime: formatDate(getLogAccessDateValue(log)),
|
|
@@ -486,7 +476,6 @@ const selectedHistoryRows = computed<AccessRow[]>(() => {
|
|
|
486
476
|
name: selectedHistoryRow.value?.name || "N/A",
|
|
487
477
|
hidUserId: selectedHistoryRow.value?.hidUserId || "N/A",
|
|
488
478
|
facialData: selectedHistoryRow.value?.facialData || "N/A",
|
|
489
|
-
faceImage: selectedHistoryRow.value?.faceImage,
|
|
490
479
|
unit: selectedHistoryRow.value?.unit || "N/A",
|
|
491
480
|
method: getAccessMethod({}, { payload: { rawAccessLog: log } }),
|
|
492
481
|
lastAccessTime: formatDate(getAccessLogTime(log, {})),
|
|
@@ -700,14 +689,6 @@ async function loadDashboard() {
|
|
|
700
689
|
serverPageRange.value = String(
|
|
701
690
|
hidAccessLogResponse?.pageRange ?? hidAccessLogResponse?.data?.pageRange ?? "",
|
|
702
691
|
);
|
|
703
|
-
const accessLogUserIds = getAccessLogUserIds(liveItems);
|
|
704
|
-
const accessLogImageCandidates = [...accessLogUserIds].map((hidUserId) => ({
|
|
705
|
-
hidUserId: String(hidUserId),
|
|
706
|
-
}));
|
|
707
|
-
await loadUserImages(
|
|
708
|
-
[...readerUsers, ...accessLogImageCandidates],
|
|
709
|
-
accessLogUserIds,
|
|
710
|
-
);
|
|
711
692
|
} finally {
|
|
712
693
|
if (sequence === loadSequence.value) {
|
|
713
694
|
loading.value = false;
|
|
@@ -724,47 +705,8 @@ async function loadDashboardSource<T>(request: Promise<T>, fallback: T, label: s
|
|
|
724
705
|
}
|
|
725
706
|
}
|
|
726
707
|
|
|
727
|
-
async function loadUserImages(items: HidAccessRecord[], forceUserIds = new Set<number>()) {
|
|
728
|
-
if (!selectedReaderId.value) return;
|
|
729
|
-
|
|
730
|
-
await Promise.all(items.map(async (identity) => {
|
|
731
|
-
const hidUserId = toHidNumericId(identity.hidUserId);
|
|
732
|
-
const key = String(hidUserId || "");
|
|
733
|
-
if (
|
|
734
|
-
!hidUserId ||
|
|
735
|
-
key in userImages.value ||
|
|
736
|
-
(!forceUserIds.has(hidUserId) && !hasHidImageHint(identity))
|
|
737
|
-
) return;
|
|
738
|
-
|
|
739
|
-
try {
|
|
740
|
-
const response = await getUserImage(selectedReaderId.value, hidUserId);
|
|
741
|
-
const image = response?.data || response;
|
|
742
|
-
userImages.value[key] = image?.base64
|
|
743
|
-
? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
|
|
744
|
-
: "";
|
|
745
|
-
} catch {
|
|
746
|
-
userImages.value[key] = "";
|
|
747
|
-
}
|
|
748
|
-
}));
|
|
749
|
-
}
|
|
750
708
|
|
|
751
|
-
function getAccessLogUserIds(items: HidAccessRecord[]) {
|
|
752
|
-
return new Set(
|
|
753
|
-
items
|
|
754
|
-
.map((item) => toHidNumericId(item.user_id ?? item.userId ?? item.hidUserId))
|
|
755
|
-
.filter((userId): userId is number => userId !== undefined),
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
709
|
|
|
759
|
-
function hasHidImageHint(identity: HidAccessRecord) {
|
|
760
|
-
return Boolean(
|
|
761
|
-
identity?.metadata?.imageTimestamp ||
|
|
762
|
-
identity?.metadata?.facialData ||
|
|
763
|
-
identity?.imageTimestamp ||
|
|
764
|
-
identity?.image_timestamp ||
|
|
765
|
-
identity?.facialData,
|
|
766
|
-
);
|
|
767
|
-
}
|
|
768
710
|
|
|
769
711
|
async function onTabChange(tab: AccessTab) {
|
|
770
712
|
if (tab) {
|
|
@@ -783,7 +725,6 @@ function resetDashboardData() {
|
|
|
783
725
|
visitorRecords.value = {};
|
|
784
726
|
logs.value = [];
|
|
785
727
|
liveAccessLogs.value = [];
|
|
786
|
-
userImages.value = {};
|
|
787
728
|
administratorUserIds.value = new Set();
|
|
788
729
|
serverPages.value = 1;
|
|
789
730
|
serverPageRange.value = "";
|
|
@@ -1128,7 +1069,6 @@ function mapAccessLogToRow(rawLog: HidAccessRecord, event: HidAccessRecord, inde
|
|
|
1128
1069
|
name: getRawLogName(rawLog, event, identity),
|
|
1129
1070
|
hidUserId: formatHidUid(rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? identity?.hidUserId),
|
|
1130
1071
|
facialData: identity ? getFacialData(identity) : "N/A",
|
|
1131
|
-
faceImage: logHidUserId ? userImages.value[String(logHidUserId)] || "" : "",
|
|
1132
1072
|
unit: identity ? getUnit(identity) : "N/A",
|
|
1133
1073
|
method: getAccessMethod(identity || {}, { ...event, payload: { ...event.payload, rawAccessLog: rawLog } }),
|
|
1134
1074
|
lastAccessTime: formatDate(rawAccessTime),
|
|
@@ -1773,10 +1713,6 @@ function escapeHtml(value: unknown) {
|
|
|
1773
1713
|
border-radius: 0;
|
|
1774
1714
|
}
|
|
1775
1715
|
|
|
1776
|
-
.hid-face-avatar {
|
|
1777
|
-
border: 1px solid var(--border);
|
|
1778
|
-
background: var(--hover);
|
|
1779
|
-
}
|
|
1780
1716
|
|
|
1781
1717
|
.hid-log-name {
|
|
1782
1718
|
align-items: center;
|
|
@@ -56,16 +56,10 @@
|
|
|
56
56
|
<span class="app-cell--num">{{ item.registration || "N/A" }}</span>
|
|
57
57
|
</template>
|
|
58
58
|
|
|
59
|
+
<!-- Client requirement (2026-08-26): the face photo is not shown once
|
|
60
|
+
registration is complete. Enrollment STATE only. -->
|
|
59
61
|
<template #[`item.facialData`]="{ item }">
|
|
60
|
-
<
|
|
61
|
-
v-if="getUserImageSrc(item)"
|
|
62
|
-
size="34"
|
|
63
|
-
rounded="lg"
|
|
64
|
-
class="hid-face-avatar"
|
|
65
|
-
>
|
|
66
|
-
<v-img :src="getUserImageSrc(item)" cover />
|
|
67
|
-
</v-avatar>
|
|
68
|
-
<span v-else :class="hasFacialData(item) ? 'facial-data--available' : 'app-cell--muted'">
|
|
62
|
+
<span :class="hasFacialData(item) ? 'facial-data--available' : 'app-cell--muted'">
|
|
69
63
|
<v-icon
|
|
70
64
|
:icon="hasFacialData(item) ? 'mdi-face-recognition' : 'mdi-face-man-shimmer-outline'"
|
|
71
65
|
size="19"
|
|
@@ -208,14 +202,12 @@ type HidReaderUser = Record<string, unknown> & {
|
|
|
208
202
|
const {
|
|
209
203
|
getReaders,
|
|
210
204
|
getReaderUsers,
|
|
211
|
-
getUserImage,
|
|
212
205
|
getUserPinStatus,
|
|
213
206
|
setUserPin,
|
|
214
207
|
deleteUserPin,
|
|
215
208
|
} = useHidAmico();
|
|
216
209
|
const readers = ref<HidReader[]>([]);
|
|
217
210
|
const users = ref<HidReaderUser[]>([]);
|
|
218
|
-
const userImages = ref<Record<string, string>>({});
|
|
219
211
|
const selectedReaderId = ref("");
|
|
220
212
|
const search = ref("");
|
|
221
213
|
const userType = ref<"all" | "user" | "visitor">("all");
|
|
@@ -328,7 +320,6 @@ async function loadUsers() {
|
|
|
328
320
|
? matchingItems.slice(pageStart, pageStart + 10)
|
|
329
321
|
: matchingItems;
|
|
330
322
|
users.value = pageItems.map(toReaderUser);
|
|
331
|
-
await loadUserImages(users.value);
|
|
332
323
|
total.value = clientFiltersUserType
|
|
333
324
|
? matchingItems.length
|
|
334
325
|
: toNumber(response.total ?? data.total, users.value.length);
|
|
@@ -356,7 +347,6 @@ function goToPage(nextPage: number) {
|
|
|
356
347
|
|
|
357
348
|
function reloadFromFirstPage() {
|
|
358
349
|
page.value = 1;
|
|
359
|
-
userImages.value = {};
|
|
360
350
|
loadUsers();
|
|
361
351
|
}
|
|
362
352
|
|
|
@@ -364,31 +354,6 @@ function hasFacialData(user: HidReaderUser) {
|
|
|
364
354
|
return user.metadata?.facialEnrolled === true || Boolean(toText(user.metadata?.facialData));
|
|
365
355
|
}
|
|
366
356
|
|
|
367
|
-
async function loadUserImages(items: HidReaderUser[]) {
|
|
368
|
-
if (!selectedReaderId.value) return;
|
|
369
|
-
|
|
370
|
-
await Promise.all(items.map(async (user) => {
|
|
371
|
-
const hidUserId = toHidNumericId(user.hidUserId);
|
|
372
|
-
const key = String(hidUserId || "");
|
|
373
|
-
if (!hidUserId || key in userImages.value) return;
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
const response = toRecord(await getUserImage(selectedReaderId.value, hidUserId));
|
|
377
|
-
const image = toRecord(response.data ?? response);
|
|
378
|
-
const base64 = toText(image.base64);
|
|
379
|
-
userImages.value[key] = base64
|
|
380
|
-
? `data:${toText(image.contentType) || "image/jpeg"};base64,${base64}`
|
|
381
|
-
: "";
|
|
382
|
-
} catch {
|
|
383
|
-
userImages.value[key] = "";
|
|
384
|
-
}
|
|
385
|
-
}));
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function getUserImageSrc(user: HidReaderUser) {
|
|
389
|
-
const hidUserId = toHidNumericId(user.hidUserId);
|
|
390
|
-
return (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
|
|
391
|
-
}
|
|
392
357
|
|
|
393
358
|
async function openPinDialog(user: HidReaderUser) {
|
|
394
359
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
@@ -583,9 +548,6 @@ function toNumber(value: unknown, fallback: number) {
|
|
|
583
548
|
color: var(--warning, #9a6b00);
|
|
584
549
|
}
|
|
585
550
|
|
|
586
|
-
.hid-face-avatar {
|
|
587
|
-
border: 1px solid var(--border-color, #e0e3e8);
|
|
588
|
-
}
|
|
589
551
|
|
|
590
552
|
.facial-data--available {
|
|
591
553
|
align-items: center;
|
|
@@ -85,18 +85,11 @@
|
|
|
85
85
|
<span class="app-cell--strong">{{ getName(item) }}</span>
|
|
86
86
|
</template>
|
|
87
87
|
|
|
88
|
-
<!--
|
|
89
|
-
|
|
88
|
+
<!-- Client requirement (2026-08-26): the face photo is not shown once
|
|
89
|
+
registration is complete. The column reports enrollment STATE only --
|
|
90
|
+
the reader holds the image and the biometric template, we hold neither. -->
|
|
90
91
|
<template #[`item.facialData`]="{ item }">
|
|
91
|
-
<
|
|
92
|
-
v-if="getUserImageSrc(item)"
|
|
93
|
-
size="34"
|
|
94
|
-
rounded="lg"
|
|
95
|
-
class="hid-face-avatar"
|
|
96
|
-
>
|
|
97
|
-
<v-img :src="getUserImageSrc(item)" cover />
|
|
98
|
-
</v-avatar>
|
|
99
|
-
<span v-else class="app-cell--muted">{{ getFacialData(item) }}</span>
|
|
92
|
+
<span class="app-cell--muted">{{ getFacialData(item) }}</span>
|
|
100
93
|
</template>
|
|
101
94
|
|
|
102
95
|
<template #[`item.hidUserId`]="{ item }">
|
|
@@ -192,6 +185,12 @@
|
|
|
192
185
|
<span v-if="form.photoPreview" class="photo-edit-icon">
|
|
193
186
|
<v-icon icon="mdi-pencil" size="16" />
|
|
194
187
|
</span>
|
|
188
|
+
<template v-else-if="facialEnrolled">
|
|
189
|
+
<!-- Enrolled, but the photo is deliberately not shown or held.
|
|
190
|
+
Re-enrolling needs a fresh capture. -->
|
|
191
|
+
<v-icon icon="mdi-face-recognition" size="40" />
|
|
192
|
+
<span>Face Enrolled</span>
|
|
193
|
+
</template>
|
|
195
194
|
<template v-else>
|
|
196
195
|
<!-- Was `color="#a4acb5"`, the same grey on both themes.
|
|
197
196
|
`.photo-button` already sets `--muted`, so it inherits. -->
|
|
@@ -207,7 +206,7 @@
|
|
|
207
206
|
@click="triggerCameraInput"
|
|
208
207
|
/>
|
|
209
208
|
<v-list-item
|
|
210
|
-
v-if="form.photoPreview"
|
|
209
|
+
v-if="form.photoPreview || facialEnrolled"
|
|
211
210
|
title="Remove Photo"
|
|
212
211
|
class="text-error"
|
|
213
212
|
@click="removePhoto"
|
|
@@ -538,7 +537,6 @@ const {
|
|
|
538
537
|
getReaders,
|
|
539
538
|
getIdentities,
|
|
540
539
|
getReaderUsers,
|
|
541
|
-
getUserImage,
|
|
542
540
|
setUserImage,
|
|
543
541
|
deleteUserImage,
|
|
544
542
|
getUserCards,
|
|
@@ -617,7 +615,6 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
617
615
|
|
|
618
616
|
const readers = ref<HidReader[]>([]);
|
|
619
617
|
const users = ref<HidUser[]>([]);
|
|
620
|
-
const userImages = ref<Record<string, string>>({});
|
|
621
618
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
622
619
|
const selectedReaderId = ref("");
|
|
623
620
|
const search = ref("");
|
|
@@ -650,6 +647,20 @@ const photoInput = ref<HTMLInputElement | null>(null);
|
|
|
650
647
|
const cameraDialog = ref(false);
|
|
651
648
|
const selectedPhotoFile = ref<File | null>(null);
|
|
652
649
|
const removePhotoRequested = ref(false);
|
|
650
|
+
/**
|
|
651
|
+
* Client requirement (2026-08-26): the photo is neither shown nor kept once registration
|
|
652
|
+
* completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
|
|
653
|
+
* fresh capture -- there is no stored image to re-send.
|
|
654
|
+
*/
|
|
655
|
+
const facialEnrolled = ref(false);
|
|
656
|
+
|
|
657
|
+
function hasFacialEnrollment(user?: HidUser | null) {
|
|
658
|
+
return Boolean(
|
|
659
|
+
user?.metadata?.facialEnrolled ||
|
|
660
|
+
user?.metadata?.facialData ||
|
|
661
|
+
user?.metadata?.imageTimestamp
|
|
662
|
+
);
|
|
663
|
+
}
|
|
653
664
|
const removePinRequested = ref(false);
|
|
654
665
|
const snackbar = reactive({
|
|
655
666
|
show: false,
|
|
@@ -832,8 +843,6 @@ async function loadUsers() {
|
|
|
832
843
|
serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
|
|
833
844
|
if (props.cardManagement) {
|
|
834
845
|
await loadVisibleUserCards(users.value);
|
|
835
|
-
} else {
|
|
836
|
-
await loadUserImages(users.value);
|
|
837
846
|
}
|
|
838
847
|
} catch (error) {
|
|
839
848
|
console.error("Unable to load HID reader users:", error);
|
|
@@ -850,27 +859,6 @@ async function loadUsers() {
|
|
|
850
859
|
}
|
|
851
860
|
}
|
|
852
861
|
|
|
853
|
-
async function loadUserImages(items: HidUser[]) {
|
|
854
|
-
if (!selectedReaderId.value) return;
|
|
855
|
-
|
|
856
|
-
await Promise.all(
|
|
857
|
-
items.map(async (user) => {
|
|
858
|
-
const hidUserId = toHidNumericId(user.hidUserId);
|
|
859
|
-
const key = String(hidUserId || "");
|
|
860
|
-
if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
|
|
861
|
-
|
|
862
|
-
try {
|
|
863
|
-
const response = await getUserImage(selectedReaderId.value, hidUserId);
|
|
864
|
-
const image = response?.data || response;
|
|
865
|
-
userImages.value[key] = image?.base64
|
|
866
|
-
? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
|
|
867
|
-
: "";
|
|
868
|
-
} catch {
|
|
869
|
-
userImages.value[key] = "";
|
|
870
|
-
}
|
|
871
|
-
})
|
|
872
|
-
);
|
|
873
|
-
}
|
|
874
862
|
|
|
875
863
|
function resetForm() {
|
|
876
864
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
@@ -888,6 +876,7 @@ function resetForm() {
|
|
|
888
876
|
form.photoPreview = "";
|
|
889
877
|
selectedPhotoFile.value = null;
|
|
890
878
|
removePhotoRequested.value = false;
|
|
879
|
+
facialEnrolled.value = false;
|
|
891
880
|
form.subjectCategory = "resident";
|
|
892
881
|
form.subjectId = "";
|
|
893
882
|
}
|
|
@@ -944,7 +933,8 @@ async function openEdit(user: HidUser) {
|
|
|
944
933
|
form.accessPin = "";
|
|
945
934
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
946
935
|
removePinRequested.value = false;
|
|
947
|
-
form.photoPreview =
|
|
936
|
+
form.photoPreview = "";
|
|
937
|
+
facialEnrolled.value = hasFacialEnrollment(user);
|
|
948
938
|
form.subjectCategory = user.person
|
|
949
939
|
? "resident"
|
|
950
940
|
: user.serviceProvider
|
|
@@ -966,12 +956,6 @@ async function openEdit(user: HidUser) {
|
|
|
966
956
|
// Preserve the last known status if the reader is temporarily offline.
|
|
967
957
|
}
|
|
968
958
|
|
|
969
|
-
if (!form.photoPreview) {
|
|
970
|
-
await loadUserImages([user]);
|
|
971
|
-
if (selectedUser.value?._rowKey === user._rowKey) {
|
|
972
|
-
form.photoPreview = getUserImageSrc(user);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
959
|
}
|
|
976
960
|
|
|
977
961
|
function openView(user: HidUser) {
|
|
@@ -1017,6 +1001,7 @@ function removePhoto() {
|
|
|
1017
1001
|
form.photoPreview = "";
|
|
1018
1002
|
selectedPhotoFile.value = null;
|
|
1019
1003
|
removePhotoRequested.value = true;
|
|
1004
|
+
facialEnrolled.value = false;
|
|
1020
1005
|
}
|
|
1021
1006
|
|
|
1022
1007
|
function onPhotoChange(event: Event) {
|
|
@@ -1094,7 +1079,12 @@ async function saveUser() {
|
|
|
1094
1079
|
level: form.level,
|
|
1095
1080
|
unit: form.unit,
|
|
1096
1081
|
unitLabel: buildUnitLabel(),
|
|
1097
|
-
|
|
1082
|
+
// Enrollment STATE carries forward on its own. It used to be derived from the
|
|
1083
|
+
// photo loaded into the form, which now stays empty -- editing a name would have
|
|
1084
|
+
// silently un-enrolled the person.
|
|
1085
|
+
facialEnrolled: hasFacialEnrollment(selectedUser.value),
|
|
1086
|
+
facialEnrolledAt: selectedUser.value?.metadata?.facialEnrolledAt || "",
|
|
1087
|
+
facialData: selectedUser.value?.metadata?.facialData || "",
|
|
1098
1088
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
1099
1089
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
1100
1090
|
pinEnrolled: pinEnrolled.value,
|
|
@@ -1145,6 +1135,11 @@ async function saveUser() {
|
|
|
1145
1135
|
showToast(getHidErrorMessage(error), "error");
|
|
1146
1136
|
} finally {
|
|
1147
1137
|
saving.value = false;
|
|
1138
|
+
// Discard the captured image on every path. On a part-way failure this is what stops
|
|
1139
|
+
// the photo lingering in the open form after a retry or a cancel.
|
|
1140
|
+
form.photoPreview = "";
|
|
1141
|
+
selectedPhotoFile.value = null;
|
|
1142
|
+
removePhotoRequested.value = false;
|
|
1148
1143
|
}
|
|
1149
1144
|
}
|
|
1150
1145
|
|
|
@@ -1185,13 +1180,11 @@ async function syncFacialImage(
|
|
|
1185
1180
|
}
|
|
1186
1181
|
);
|
|
1187
1182
|
const result = response?.data;
|
|
1188
|
-
userImages.value[String(hidUserId)] = form.photoPreview;
|
|
1189
1183
|
return { action: "enrolled", timestamp, scores: result?.scores };
|
|
1190
1184
|
}
|
|
1191
1185
|
|
|
1192
1186
|
if (removePhotoRequested.value) {
|
|
1193
1187
|
await deleteUserImage(readerId, hidUserId);
|
|
1194
|
-
delete userImages.value[String(hidUserId)];
|
|
1195
1188
|
return { action: "removed" };
|
|
1196
1189
|
}
|
|
1197
1190
|
|
|
@@ -1203,12 +1196,15 @@ function applyFacialResult(
|
|
|
1203
1196
|
result: FacialSyncResult
|
|
1204
1197
|
) {
|
|
1205
1198
|
if (result.action === "enrolled") {
|
|
1199
|
+
metadata.facialEnrolled = true;
|
|
1200
|
+
metadata.facialEnrolledAt = new Date().toISOString();
|
|
1206
1201
|
metadata.facialData = form.hidUserId;
|
|
1207
1202
|
metadata.imageTimestamp = String(result.timestamp || "");
|
|
1208
1203
|
metadata.facialScores = result.scores || {};
|
|
1209
1204
|
} else if (result.action === "removed") {
|
|
1205
|
+
metadata.facialEnrolled = false;
|
|
1206
|
+
metadata.facialEnrolledAt = "";
|
|
1210
1207
|
metadata.facialData = "";
|
|
1211
|
-
metadata.photo = "";
|
|
1212
1208
|
metadata.imageTimestamp = "";
|
|
1213
1209
|
metadata.facialScores = {};
|
|
1214
1210
|
}
|
|
@@ -1675,16 +1671,7 @@ function getFacialData(user: HidUser) {
|
|
|
1675
1671
|
return (
|
|
1676
1672
|
user?.metadata?.facialData ||
|
|
1677
1673
|
user?.metadata?.imageTimestamp ||
|
|
1678
|
-
(user?.metadata?.
|
|
1679
|
-
);
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
function getUserImageSrc(user: HidUser) {
|
|
1683
|
-
const hidUserId = toHidNumericId(user?.hidUserId);
|
|
1684
|
-
return (
|
|
1685
|
-
user?.metadata?.photo ||
|
|
1686
|
-
(hidUserId ? userImages.value[String(hidUserId)] : "") ||
|
|
1687
|
-
""
|
|
1674
|
+
(user?.metadata?.facialEnrolled ? user.hidUserId : "N/A")
|
|
1688
1675
|
);
|
|
1689
1676
|
}
|
|
1690
1677
|
|
|
@@ -2016,10 +2003,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2016
2003
|
box-shadow: var(--shadow-menu);
|
|
2017
2004
|
}
|
|
2018
2005
|
|
|
2019
|
-
.hid-face-avatar {
|
|
2020
|
-
border: 1px solid var(--border);
|
|
2021
|
-
background: var(--hover);
|
|
2022
|
-
}
|
|
2023
2006
|
|
|
2024
2007
|
.admin-rule-indicator {
|
|
2025
2008
|
display: inline-flex;
|
|
@@ -299,11 +299,6 @@ export default function useHidAmico() {
|
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
function getUserImage(readerId: string, hidUserId: string | number) {
|
|
303
|
-
return useNuxtApp().$api<HidApiRecord>(`${basePath}/readers/${readerId}/users/${hidUserId}/image`, {
|
|
304
|
-
method: "GET",
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
302
|
|
|
308
303
|
function setUserImage(
|
|
309
304
|
readerId: string,
|
|
@@ -525,7 +520,6 @@ export default function useHidAmico() {
|
|
|
525
520
|
getReaderConfiguration,
|
|
526
521
|
setReaderConfiguration,
|
|
527
522
|
getLogs,
|
|
528
|
-
getUserImage,
|
|
529
523
|
setUserImage,
|
|
530
524
|
deleteUserImage,
|
|
531
525
|
getUserCards,
|
|
@@ -88,10 +88,20 @@ export default function () {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* One page of a site's cameras.
|
|
93
|
+
*
|
|
94
|
+
* `limit` is the reason this signature changed: the server defaults to 10,
|
|
95
|
+
* and every caller that omitted it was showing the first ten cameras of a
|
|
96
|
+
* site and no way to reach the rest. The server accepts a `limit` of 10 or
|
|
97
|
+
* more with no upper bound, so a caller picks a real page size rather than
|
|
98
|
+
* asking for everything.
|
|
99
|
+
*/
|
|
91
100
|
async function getAllSiteCameras(payload: {
|
|
92
101
|
site: string;
|
|
93
102
|
type?: string;
|
|
94
103
|
page?: number;
|
|
104
|
+
limit?: number;
|
|
95
105
|
}) {
|
|
96
106
|
return await useNuxtApp().$api<Record<string, any>>(`/api/site-cameras`, {
|
|
97
107
|
method: "GET",
|
|
@@ -99,6 +109,32 @@ export default function () {
|
|
|
99
109
|
});
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Many cameras in one request, from the technician's survey sheet.
|
|
114
|
+
*
|
|
115
|
+
* The same call previews and commits, and the server decides both — `commit:
|
|
116
|
+
* false` validates every row and writes nothing, `commit: true` writes the
|
|
117
|
+
* rows that passed. Every row comes back with an outcome and, when it was not
|
|
118
|
+
* written, a sentence saying why.
|
|
119
|
+
*
|
|
120
|
+
* Rows carry the sheet's own column names; the server derives each camera's
|
|
121
|
+
* live-view address from the recorder and channel number, because that
|
|
122
|
+
* address is a relay page nobody outside the video service can work out.
|
|
123
|
+
*/
|
|
124
|
+
async function bulkImportSiteCameras(payload: {
|
|
125
|
+
site: string;
|
|
126
|
+
rows: Array<Record<string, string>>;
|
|
127
|
+
commit: boolean;
|
|
128
|
+
}) {
|
|
129
|
+
return await useNuxtApp().$api<Record<string, any>>(
|
|
130
|
+
`/api/site-cameras/bulk`,
|
|
131
|
+
{
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: payload,
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
102
138
|
/**
|
|
103
139
|
* The monitoring wall for one site.
|
|
104
140
|
*
|
|
@@ -223,6 +259,7 @@ export default function () {
|
|
|
223
259
|
updateSite,
|
|
224
260
|
addCamera,
|
|
225
261
|
getAllSiteCameras,
|
|
262
|
+
bulkImportSiteCameras,
|
|
226
263
|
getSiteWall,
|
|
227
264
|
getSiteHealth,
|
|
228
265
|
setSiteGuardPosts,
|
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.1.3-staging.
|
|
5
|
+
"version": "4.1.3-staging.253",
|
|
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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the technician's camera survey sheet.
|
|
3
|
+
*
|
|
4
|
+
* The file a person uploads is the file they were asked to fill in: the column
|
|
5
|
+
* names here are the survey sheet's own, so nothing has to be renamed by hand
|
|
6
|
+
* between surveying a site and importing it.
|
|
7
|
+
*
|
|
8
|
+
* This does one job — turn text into rows — and deliberately judges nothing.
|
|
9
|
+
* Whether a row is valid, whether it is a duplicate, whether its recorder is
|
|
10
|
+
* one the video service knows about: all of that is decided by the server, once,
|
|
11
|
+
* and shown back per row. A second opinion here would be a second opinion to
|
|
12
|
+
* disagree with the first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The columns the sheet is asked for. Order is the order shown in a preview. */
|
|
16
|
+
export const CAMERA_CSV_COLUMNS = [
|
|
17
|
+
"camera_name",
|
|
18
|
+
"recorder_id",
|
|
19
|
+
"channel_number",
|
|
20
|
+
"physical_location",
|
|
21
|
+
"for_cctv_wall",
|
|
22
|
+
"for_virtual_patrol",
|
|
23
|
+
"patrol_order",
|
|
24
|
+
"zone_or_block",
|
|
25
|
+
"is_anpr",
|
|
26
|
+
"notes",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/** Columns a camera record has no field for. Surveyed, shown, never stored. */
|
|
30
|
+
export const CAMERA_CSV_UNSTORED_COLUMNS = [
|
|
31
|
+
"physical_location",
|
|
32
|
+
"patrol_order",
|
|
33
|
+
"zone_or_block",
|
|
34
|
+
"notes",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** Columns the sheet cannot do without. */
|
|
38
|
+
export const CAMERA_CSV_REQUIRED_COLUMNS = [
|
|
39
|
+
"camera_name",
|
|
40
|
+
"recorder_id",
|
|
41
|
+
"channel_number",
|
|
42
|
+
"for_cctv_wall",
|
|
43
|
+
"for_virtual_patrol",
|
|
44
|
+
"is_anpr",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type CameraCsvRow = Record<string, string>;
|
|
48
|
+
|
|
49
|
+
export type CameraCsvParse = {
|
|
50
|
+
/** The header, as spelled in the file, in file order. */
|
|
51
|
+
columns: string[];
|
|
52
|
+
rows: CameraCsvRow[];
|
|
53
|
+
/** Required columns the file does not have. Empty means it is usable. */
|
|
54
|
+
missingColumns: string[];
|
|
55
|
+
/** Columns present that we will read but cannot store. */
|
|
56
|
+
unstoredColumns: string[];
|
|
57
|
+
/** Columns present that mean nothing to us at all. Harmless, worth saying. */
|
|
58
|
+
unknownColumns: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One line of CSV, respecting quotes.
|
|
63
|
+
*
|
|
64
|
+
* Written rather than pulled in, because the whole of it is this: a quoted
|
|
65
|
+
* field may contain commas and `""` is an escaped quote. A spreadsheet export
|
|
66
|
+
* of ten short columns needs nothing else, and a parser dependency here would
|
|
67
|
+
* be a build-time cost for a hundred lines of text typed by a technician.
|
|
68
|
+
*/
|
|
69
|
+
function splitLine(line: string): string[] {
|
|
70
|
+
const values: string[] = [];
|
|
71
|
+
let value = "";
|
|
72
|
+
let quoted = false;
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
75
|
+
const char = line[index];
|
|
76
|
+
|
|
77
|
+
if (quoted) {
|
|
78
|
+
if (char !== '"') {
|
|
79
|
+
value += char;
|
|
80
|
+
} else if (line[index + 1] === '"') {
|
|
81
|
+
value += '"';
|
|
82
|
+
index += 1;
|
|
83
|
+
} else {
|
|
84
|
+
quoted = false;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (char === '"') {
|
|
90
|
+
quoted = true;
|
|
91
|
+
} else if (char === ",") {
|
|
92
|
+
values.push(value);
|
|
93
|
+
value = "";
|
|
94
|
+
} else {
|
|
95
|
+
value += char;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
values.push(value);
|
|
100
|
+
return values.map((item) => item.trim());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A header cell, normalised to the sheet's snake_case spelling. */
|
|
104
|
+
function normaliseColumn(value: string): string {
|
|
105
|
+
return value
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/^/, "")
|
|
109
|
+
.replace(/[\s-]+/g, "_")
|
|
110
|
+
.replace(/[^a-z0-9_]/g, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Turn an uploaded CSV into rows.
|
|
115
|
+
*
|
|
116
|
+
* Tolerant where tolerance is free — a UTF-8 BOM from Excel, `Camera Name` for
|
|
117
|
+
* `camera_name`, CRLF endings, a trailing blank line — and silent about
|
|
118
|
+
* nothing: a missing required column or an extra unrecognised one is reported
|
|
119
|
+
* rather than quietly worked around.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCameraCsv(text: string): CameraCsvParse {
|
|
122
|
+
const lines = String(text ?? "")
|
|
123
|
+
.replace(/^/, "")
|
|
124
|
+
.split(/\r?\n/)
|
|
125
|
+
.filter((line) => line.trim().length > 0);
|
|
126
|
+
|
|
127
|
+
if (!lines.length) {
|
|
128
|
+
return {
|
|
129
|
+
columns: [],
|
|
130
|
+
rows: [],
|
|
131
|
+
missingColumns: [...CAMERA_CSV_REQUIRED_COLUMNS],
|
|
132
|
+
unstoredColumns: [],
|
|
133
|
+
unknownColumns: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const columns = splitLine(lines[0]).map(normaliseColumn);
|
|
138
|
+
|
|
139
|
+
const rows = lines.slice(1).map((line) => {
|
|
140
|
+
const values = splitLine(line);
|
|
141
|
+
const row: CameraCsvRow = {};
|
|
142
|
+
columns.forEach((column, index) => {
|
|
143
|
+
if (column) row[column] = values[index] ?? "";
|
|
144
|
+
});
|
|
145
|
+
return row;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const known = new Set<string>(CAMERA_CSV_COLUMNS);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
columns,
|
|
152
|
+
rows,
|
|
153
|
+
missingColumns: CAMERA_CSV_REQUIRED_COLUMNS.filter(
|
|
154
|
+
(column) => !columns.includes(column),
|
|
155
|
+
),
|
|
156
|
+
unstoredColumns: CAMERA_CSV_UNSTORED_COLUMNS.filter((column) =>
|
|
157
|
+
columns.includes(column),
|
|
158
|
+
),
|
|
159
|
+
unknownColumns: columns.filter((column) => column && !known.has(column)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A blank sheet, so nobody has to guess the column names. */
|
|
164
|
+
export function cameraCsvTemplate(): string {
|
|
165
|
+
return [
|
|
166
|
+
CAMERA_CSV_COLUMNS.join(","),
|
|
167
|
+
"Block A Lobby,nvr-1.example.org,7,\"Block A, Level 1 lift lobby\",Yes,Yes,1,Block A,No,Faces west",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|