295 lines
12 KiB
Vue
295 lines
12 KiB
Vue
<template>
|
|
<BaseLayout>
|
|
<main class="content">
|
|
<div class="container-fluid p-0">
|
|
<h1 class="h3 mb-3">Scan a code</h1>
|
|
|
|
<div class="form-check form-switch mb-3">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
|
|
v-model="visitFirstMatch">
|
|
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
|
|
</div>
|
|
|
|
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
|
|
|
<div v-if="!insecureContext" class="alert alert-warning">
|
|
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
|
|
unavailable. From another device, use https:// (or http://localhost) instead.
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card h-100">
|
|
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Results</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<ul class="scan-results">
|
|
<li v-for="(c, i) in cameraLog" :key="i">
|
|
<span class="scan-result-type">{{ c.type }}</span>
|
|
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
|
|
<span class="text-muted"> @ {{ c.time }}</span>
|
|
<div class="text-break">
|
|
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
|
|
rel="noopener noreferrer">{{ c.text }}</a>
|
|
<template v-else-if="c.link">
|
|
<router-link :to="c.link.to">{{ c.text }}
|
|
<span v-if="c.error" class="text-danger"> → {{
|
|
c.error
|
|
}}</span>
|
|
<span v-else-if="c.description"> → {{ c.description }}</span>
|
|
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</span>
|
|
</router-link>
|
|
</template>
|
|
<template v-else>{{ c.text }}</template>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Scan from camera</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</BaseLayout>
|
|
</template>
|
|
|
|
<script>
|
|
import {mapActions} from "vuex";
|
|
import BaseLayout from "@/components/BaseLayout.vue";
|
|
import CameraScanner from "@/components/CameraScanner.vue";
|
|
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
|
import {decodeShortId, deserializeShortId} from "@/short-id";
|
|
|
|
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
|
|
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
|
|
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
|
|
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
|
|
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
|
|
// human-readable "what this points at" - not needed for the two URL cases, which link to
|
|
// somewhere already showing that.
|
|
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
|
|
|
|
function classifyScanText(text) {
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
try {
|
|
const url = new URL(text);
|
|
// Same-origin URLs (the common case - a label printed by this same app) get routed
|
|
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
|
|
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
|
|
// it's already "resolved" the moment it's classified - see resolveDescription and
|
|
// maybeVisitFirstMatch.
|
|
return url.origin === window.location.origin
|
|
? {to: url.pathname + url.search + url.hash, immediate: true}
|
|
: {href: url.href};
|
|
} catch {
|
|
// Not an absolute URL - fall through to the other known formats below.
|
|
}
|
|
if (text.startsWith('~')) {
|
|
try {
|
|
const decoded = deserializeShortId(decodeShortId(text));
|
|
// expandedRoute needs idmap already loaded to resolve item/group_item (see
|
|
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
|
|
// it from there, same as a cold-opened short link) when it can't yet.
|
|
return {to: expandedRoute(decoded) || '/' + text, decoded};
|
|
} catch {
|
|
return null; // starts with '~' but isn't a real short id - leave as plain text
|
|
}
|
|
}
|
|
const handleMatch = text.match(ITEM_HANDLE_RE);
|
|
if (handleMatch) {
|
|
const [, handle, id] = handleMatch;
|
|
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
|
|
// "(v7, ec=M, mask=3)".
|
|
function metaSummary(metadata) {
|
|
const parts = [];
|
|
if (metadata.version != null) {
|
|
parts.push(`v${metadata.version}`);
|
|
}
|
|
if (metadata.size) {
|
|
parts.push(metadata.size);
|
|
}
|
|
if (metadata.ecc) {
|
|
parts.push(`ec=${metadata.ecc}`);
|
|
}
|
|
if (metadata.mask != null) {
|
|
parts.push(`mask=${metadata.mask}`);
|
|
}
|
|
return parts.length ? `(${parts.join(", ")})` : "";
|
|
}
|
|
|
|
export default {
|
|
name: "Scan",
|
|
components: {
|
|
BaseLayout,
|
|
CameraScanner,
|
|
},
|
|
data() {
|
|
return {
|
|
error: null,
|
|
insecureContext: window.isSecureContext,
|
|
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
|
visitFirstMatch: this.$route.query.navigate === "immediate",
|
|
|
|
cameraLog: [],
|
|
};
|
|
},
|
|
methods: {
|
|
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
|
|
|
|
resolveDescription(entry) {
|
|
const {link, text} = entry;
|
|
if (!link || link.href) {
|
|
return; // full URLs already show where they go - see this feature's ask
|
|
}
|
|
if (link.immediate) {
|
|
this.maybeVisitFirstMatch(link);
|
|
return;
|
|
}
|
|
if (!this.descriptionCache.has(text)) {
|
|
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
|
|
}
|
|
this.descriptionCache.get(text).then(result => {
|
|
if (result && typeof result === "object") {
|
|
entry.description = null;
|
|
entry.error = result.error;
|
|
return;
|
|
}
|
|
entry.description = result;
|
|
if (result) {
|
|
this.maybeVisitFirstMatch(link);
|
|
}
|
|
});
|
|
},
|
|
|
|
maybeVisitFirstMatch(link) {
|
|
if (!this.visitFirstMatch || !link?.to) {
|
|
return;
|
|
}
|
|
this.visitFirstMatch = false;
|
|
this.$router.push(link.to);
|
|
},
|
|
|
|
async describeLink(link) {
|
|
if (link.itemHandle) {
|
|
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
|
|
}
|
|
const decoded = link.decoded;
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
if (decoded.kind === "item" || decoded.kind === "group_item") {
|
|
const byId = decoded.kind === "item"
|
|
? this.$store.getters.identityHandleById
|
|
: this.$store.getters.groupHandleById;
|
|
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
|
|
let handle = byId[ownerId];
|
|
if (handle === undefined) {
|
|
await this.fetchIdMap();
|
|
handle = (decoded.kind === "item"
|
|
? this.$store.getters.identityHandleById
|
|
: this.$store.getters.groupHandleById)[ownerId];
|
|
}
|
|
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
|
|
}
|
|
if (decoded.kind === "group") {
|
|
let handle = this.$store.getters.groupHandleById[decoded.group_id];
|
|
if (handle === undefined) {
|
|
await this.fetchIdMap();
|
|
handle = this.$store.getters.groupHandleById[decoded.group_id];
|
|
}
|
|
return handle === undefined ? null : handle;
|
|
}
|
|
if (decoded.kind === "storage_location") {
|
|
if (!this.$store.state.storage_locations.length) {
|
|
await this.fetchStorageLocations();
|
|
}
|
|
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
|
|
return location ? `[#${location.id}] ${location.name}` : null;
|
|
}
|
|
return null; // workflow, category, file: no per-item title lookup wired up yet
|
|
},
|
|
|
|
async describeItem(handle, id) {
|
|
const item = await this.fetchItemByHandle({handle, id});
|
|
return item ? `[#${item.id}] ${item.name}` : null;
|
|
},
|
|
|
|
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
|
|
onCameraScan(codes) {
|
|
codes.forEach(this.logDecode);
|
|
},
|
|
|
|
logDecode(code) {
|
|
const text = code.text ?? "";
|
|
const entry = {
|
|
type: code.type,
|
|
text,
|
|
metaText: metaSummary(code.metadata),
|
|
time: new Date().toLocaleTimeString(),
|
|
link: classifyScanText(text),
|
|
description: undefined,
|
|
error: null,
|
|
};
|
|
this.cameraLog.unshift(entry);
|
|
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
|
|
// tracks property sets through the reactive proxy unshift() just installed, and
|
|
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
|
|
// description would never appear to update (stuck on "resolving...") even once the
|
|
// lookup actually finished.
|
|
this.resolveDescription(this.cameraLog[0]);
|
|
// Caps the log (old entries trimmed) rather than growing forever, matching
|
|
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
|
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
|
},
|
|
},
|
|
created() {
|
|
// Not component data: resolveDescription's cached values are read back into each entry's
|
|
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
|
this.descriptionCache = new Map();
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.scan-results {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .4rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.scan-results li {
|
|
border: 1px solid var(--bs-gray-300);
|
|
border-radius: .35rem;
|
|
padding: .4rem .6rem;
|
|
font-size: .85rem;
|
|
}
|
|
|
|
.scan-result-type {
|
|
font-weight: 600;
|
|
color: var(--bs-success);
|
|
}
|
|
</style>
|