fix/spa-reconnect-no-demo-data #1
1
dist/assets/index-CU7fLO-K.css
vendored
Normal file
1
dist/assets/index-CU7fLO-K.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-Oxhdj_dg.css
vendored
1
dist/assets/index-Oxhdj_dg.css
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@ -9,8 +9,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ausschuss-Station</title>
|
||||
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css">
|
||||
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { WindowCode } from '@/types/station'
|
||||
|
||||
defineProps<{ window: WindowCode; sample: boolean }>()
|
||||
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -32,7 +32,8 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="live"><span class="led"></span>{{ t('app.live') }}</span>
|
||||
<span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
|
||||
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
|
||||
<span class="clock num">{{ clock }}</span>
|
||||
<span class="rangep" role="group" :aria-label="t('window.label')">
|
||||
<span class="rl">{{ t('window.label') }}</span>
|
||||
@ -117,6 +118,16 @@ onUnmounted(() => {
|
||||
background: var(--done);
|
||||
animation: blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
.reconnect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--transit);
|
||||
}
|
||||
.clock {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-dim);
|
||||
|
||||
@ -6,6 +6,11 @@ export default {
|
||||
live: 'Live',
|
||||
demo: 'Demo-Daten',
|
||||
},
|
||||
conn: {
|
||||
connecting: 'Verbinde mit Server …',
|
||||
lost: 'Verbindung verloren – neuer Versuch …',
|
||||
reconnecting: 'Verbinde neu …',
|
||||
},
|
||||
filter: {
|
||||
title: 'Maschinen',
|
||||
all: 'Alle',
|
||||
|
||||
@ -7,6 +7,11 @@ export default {
|
||||
live: 'Live',
|
||||
demo: 'Sample data',
|
||||
},
|
||||
conn: {
|
||||
connecting: 'Connecting to server …',
|
||||
lost: 'Connection lost – retrying …',
|
||||
reconnecting: 'Reconnecting …',
|
||||
},
|
||||
filter: {
|
||||
title: 'Presses',
|
||||
all: 'All',
|
||||
|
||||
@ -6,7 +6,12 @@ import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
|
||||
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
|
||||
|
||||
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
|
||||
// Falls back to bundled sample data when the API is unreachable (dev / no gateway).
|
||||
//
|
||||
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
|
||||
// the last known REAL payload (stale > fake — an operator must never book against a
|
||||
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
|
||||
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
|
||||
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
|
||||
|
||||
const CACHE_KEY = 'temper_rejects_payload'
|
||||
|
||||
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usingSample = ref(false)
|
||||
const disconnected = ref(false)
|
||||
const lastUpdated = ref<number | null>(null)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollMs = 20000
|
||||
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
|
||||
let filterInitialized = false
|
||||
|
||||
const hasData = computed(() => payload.value !== null)
|
||||
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
||||
}
|
||||
payload.value = res.data
|
||||
usingSample.value = res.data.sample === true
|
||||
disconnected.value = false
|
||||
} catch (e) {
|
||||
// Connection lost. Keep the last known REAL data (stale > fabricated): never
|
||||
// replace live carts with demo carts on a live terminal, or an operator could
|
||||
// book against a fabricated entry. Flag `disconnected` so the UI shows a
|
||||
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
|
||||
disconnected.value = true
|
||||
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
|
||||
// Keep the last known REAL data on a transient failure (stale > fabricated): never
|
||||
// replace live carts with bundled demo carts on a live terminal, or an operator
|
||||
// could book against a fabricated entry. Only fall back to sample on a COLD start
|
||||
// with nothing to show (dev standalone, where there is no gateway).
|
||||
if (!payload.value || usingSample.value) {
|
||||
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
|
||||
if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
|
||||
payload.value = sampleStation(window.value)
|
||||
usingSample.value = true
|
||||
}
|
||||
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
||||
async function fetchReasons() {
|
||||
try {
|
||||
const res = await api.get<{ reasons: Reason[] }>('/reasons')
|
||||
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS
|
||||
if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
|
||||
reasons.value = res.data.reasons
|
||||
return
|
||||
}
|
||||
throw new Error('no reasons')
|
||||
} catch {
|
||||
reasons.value = SAMPLE_REASONS
|
||||
// Keep any reasons already loaded; only seed the bundled sample reasons under
|
||||
// `npm run dev` — production must never offer fabricated reason codes.
|
||||
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,13 +169,22 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
|
||||
// retry while disconnected so the terminal recovers as soon as the gateway is back.
|
||||
function scheduleNext() {
|
||||
timer = setTimeout(async () => {
|
||||
await refresh()
|
||||
scheduleNext()
|
||||
}, disconnected.value ? RETRY_MS : pollMs)
|
||||
}
|
||||
function startPolling(ms = 20000) {
|
||||
if (timer) return
|
||||
timer = setInterval(refresh, ms)
|
||||
pollMs = ms
|
||||
scheduleNext()
|
||||
}
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
||||
loading,
|
||||
error,
|
||||
usingSample,
|
||||
disconnected,
|
||||
lastUpdated,
|
||||
hasData,
|
||||
allPresses,
|
||||
|
||||
@ -114,6 +114,27 @@ html:not(.dark) body {
|
||||
background-position: 11px 0;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reconnect spinner (connection-loss states). `.sm` is the inline header variant. */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid var(--line-2);
|
||||
border-top-color: var(--transit);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.spinner.sm {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
5-station temper belt and an "Ausschuss buchen" action → booking popover
|
||||
(a tooltip-style panel anchored to the button, not a centered modal).
|
||||
|
||||
Login-free by design. Data from the store (polled); no-backend falls back to
|
||||
bundled sample data so `npm run dev` renders + the booking flow is demoable.
|
||||
Login-free by design. Data from the store (polled). On connection loss the terminal
|
||||
keeps the last real carts (or shows a reconnect spinner on a cold start) and retries
|
||||
— it never shows demo carts in production. Sample data renders only under
|
||||
`npm run dev` (no gateway), where the booking flow is demoable.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useTemperRejectsStore()
|
||||
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store)
|
||||
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
|
||||
|
||||
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
|
||||
const bookingEntry = ref<RejectEntry | null>(null)
|
||||
@ -80,9 +82,15 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" />
|
||||
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
|
||||
|
||||
<div class="layout">
|
||||
<!-- Cold start / no data yet: never blank, never fake — spinner that keeps retrying. -->
|
||||
<div v-if="!hasData" class="state connect">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<PressFilter
|
||||
:presses="allPresses"
|
||||
:checked="checkedPresses"
|
||||
@ -140,6 +148,18 @@ onUnmounted(() => {
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
/* Connection spinner state (cold start / reconnecting): full-width, no dashed box. */
|
||||
.state.connect {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 60vh;
|
||||
margin-top: 14px;
|
||||
border: 0;
|
||||
}
|
||||
.state.connect .msg {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user