Files
FEDEO/frontend/components/FileScanModal.vue
flfeders 68c0a224e5
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 29s
feat(scan): add guided Linux agent setup in administration
2026-09-17 18:56:30 +02:00

372 lines
12 KiB
Vue

<script setup>
const props = defineProps({
scanData: {
type: Object,
default: () => ({
folder: null,
type: null,
typeEnabled: true
})
}
})
const emit = defineEmits(["scanFinished"])
const modal = useModal()
const toast = useToast()
const { $api } = useNuxtApp()
const loadingAgents = ref(true)
const scanInProgress = ref(false)
const agents = ref([])
const selectedAgentId = ref(null)
const currentJob = ref(null)
const statusMessage = ref("")
let disposed = false
onBeforeUnmount(() => { disposed = true })
const scanForm = reactive({
filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`,
scannerName: "",
format: "pdf",
resolution: 300,
mode: "Color",
source: "ADF Duplex",
paperSize: "A4",
postprocess: false,
postprocessProfile: "document"
})
const isFeederScan = computed(() => /adf|document feeder/i.test(scanForm.source || ""))
const formatOptions = computed(() => isFeederScan.value
? [{ label: "PDF (alle Seiten)", value: "pdf" }]
: [{ label: "PDF", value: "pdf" }, { label: "PNG", value: "png" }, { label: "TIFF", value: "tiff" }])
watch(isFeederScan, (feeder) => { if (feeder) scanForm.format = "pdf" })
watch(() => scanForm.format, (format) => {
scanForm.filename = `${scanForm.filename.replace(/\.(pdf|png|tiff?)$/i, "")}.${format}`
})
const applyIx1600Preset = () => {
Object.assign(scanForm, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document"
})
}
const activeAgents = computed(() =>
agents.value.filter((agent) => agent.active && agent.capabilities?.scan)
)
const selectedAgent = computed(() =>
agents.value.find((agent) => agent.id === selectedAgentId.value) || null
)
const scannerOptions = computed(() =>
(selectedAgent.value?.scannerNames || []).map((scanner) => ({
label: scanner,
value: scanner
}))
)
const isOnline = (agent) => {
if (!agent.lastSeenAt) return false
return Date.now() - new Date(agent.lastSeenAt).getTime() < 2 * 60 * 1000
}
const applyAgentDefaults = (agent) => {
if (!agent) return
scanForm.scannerName = agent.preferredScannerName || agent.scannerNames?.[0] || ""
scanForm.format = agent.scanDefaults?.format || "pdf"
scanForm.resolution = Number(agent.scanDefaults?.resolution || 300)
scanForm.mode = agent.scanDefaults?.mode || "Color"
scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess)
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
if (isFeederScan.value) scanForm.format = "pdf"
if (!scanForm.filename || scanForm.filename.startsWith("scan-")) {
scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}`
}
}
watch(selectedAgent, (agent) => applyAgentDefaults(agent))
const loadAgents = async () => {
loadingAgents.value = true
try {
const response = await $api("/api/instance-agents")
agents.value = response?.agents || []
const firstOnlineAgent = activeAgents.value.find(isOnline)
selectedAgentId.value = props.scanData.agentId
? activeAgents.value.find(agent => agent.id === props.scanData.agentId)?.id || null
: firstOnlineAgent?.id || activeAgents.value[0]?.id || null
applyAgentDefaults(selectedAgent.value)
} catch (err) {
toast.add({
title: "Scanner konnten nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error"
})
} finally {
loadingAgents.value = false
}
}
const waitForJob = async (jobId) => {
while (!disposed) {
await new Promise((resolve) => setTimeout(resolve, 2000))
if (disposed) return null
let response
try {
response = await $api(`/api/scan-jobs/${jobId}`, { timeout: 15000, retry: 0 })
} catch (err) {
if ([401, 403, 404].includes(err?.statusCode || err?.response?.status)) throw err
statusMessage.value = "Verbindung unterbrochen. Der Scan läuft auf dem Rechner weiter; Status wird erneut abgefragt..."
continue
}
if (disposed) return null
const job = response?.job
currentJob.value = job
if (job?.status === "completed") return job
if (job?.status === "failed" || job?.status === "canceled") {
throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.")
}
statusMessage.value = job?.agentMessage || (job?.status === "running"
? "Scanner arbeitet..."
: "Warte auf den Agenten...")
}
return null
}
const startScan = async () => {
if (!selectedAgentId.value || scanInProgress.value) return
scanInProgress.value = true
statusMessage.value = "Scan-Auftrag wird erstellt..."
currentJob.value = null
try {
const response = await $api("/api/scan-jobs", {
method: "POST",
body: {
agentId: selectedAgentId.value,
scannerName: scanForm.scannerName || null,
requestedFilename: scanForm.filename || null,
settings: {
format: scanForm.format || "pdf",
resolution: Number(scanForm.resolution || 300),
mode: scanForm.mode || "Color",
source: scanForm.source || null,
paperSize: scanForm.paperSize,
postprocess: scanForm.postprocess,
postprocessProfile: scanForm.postprocessProfile
},
target: {
folder: props.scanData.folder || null,
type: props.scanData.type || null
}
}
})
currentJob.value = response?.job
if (!response?.job?.id) {
throw new Error("FEDEO hat keinen Scan-Auftrag zurückgegeben.")
}
statusMessage.value = "Warte auf den Agenten..."
const finishedJob = await waitForJob(response.job.id)
if (!finishedJob || disposed) return
toast.add({
title: "Scan gespeichert",
description: finishedJob.agentMessage || (props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt."),
color: "success"
})
emit("scanFinished")
modal.close()
} catch (err) {
if (disposed) return
toast.add({
title: "Scan fehlgeschlagen",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error"
})
statusMessage.value = err?.message || "Scan fehlgeschlagen"
} finally {
scanInProgress.value = false
}
}
loadAgents()
</script>
<template>
<UModal>
<template #content>
<UCard class="shadow-xl ring-1 ring-black/5">
<template #header>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
<UIcon name="i-heroicons-document-magnifying-glass" class="h-5 w-5" />
</div>
<div>
<h3 class="text-base font-semibold text-highlighted">Dokument scannen</h3>
<p class="text-xs text-muted">Der Scan wird im aktuellen Ordner gespeichert.</p>
</div>
</div>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
:disabled="scanInProgress"
@click="modal.close()"
/>
</div>
</template>
<div class="space-y-4">
<UAlert
v-if="!loadingAgents && activeAgents.length === 0"
color="warning"
variant="soft"
icon="i-heroicons-exclamation-triangle"
title="Kein aktiver Scan-Agent"
description="Lege in der Administration einen Scanner-Agenten an und starte den lokalen Agenten."
/>
<UFormField label="Scanner-Agent">
<USelectMenu
v-model="selectedAgentId"
:items="activeAgents.map((agent) => ({ label: agent.name, value: agent.id, suffix: isOnline(agent) ? 'Online' : 'Offline' }))"
value-key="value"
label-key="label"
placeholder="Agent wählen"
:loading="loadingAgents"
class="w-full"
:disabled="scanInProgress"
/>
</UFormField>
<UFormField label="Scanner">
<USelectMenu
v-if="scannerOptions.length"
v-model="scanForm.scannerName"
:items="scannerOptions"
value-key="value"
label-key="label"
placeholder="Standard-Scanner"
class="w-full"
:disabled="scanInProgress"
/>
<UInput
v-else
v-model="scanForm.scannerName"
placeholder="Standard-Scanner"
:disabled="scanInProgress"
/>
</UFormField>
<UFormField label="Dateiname">
<UInput v-model="scanForm.filename" :disabled="scanInProgress" />
</UFormField>
<UButton color="neutral" variant="soft" :disabled="scanInProgress" @click="applyIx1600Preset">
ScanSnap iX1600: A4, Duplex, PDF
</UButton>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Quelle">
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
</UFormField>
<UFormField label="Auflösung">
<UInput v-model.number="scanForm.resolution" type="number" min="75" step="25" :disabled="scanInProgress" />
</UFormField>
</div>
<UFormField label="Papierformat">
<USelectMenu v-model="scanForm.paperSize" :items="[
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
{ label: 'Scanner-Vorgabe', value: 'auto' }
]" value-key="value" label-key="label" :disabled="scanInProgress" />
</UFormField>
<p v-if="isFeederScan" class="text-xs text-muted">
Der gesamte Stapel wird als ein PDF gespeichert. Bei Duplex bleiben Vorder- und Rückseiten einschließlich Leerseiten erhalten.
</p>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Modus">
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
</UFormField>
<UFormField label="Format">
<USelectMenu
v-model="scanForm.format"
:items="formatOptions"
value-key="value"
label-key="label"
:disabled="scanInProgress"
/>
</UFormField>
</div>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox
v-model="scanForm.postprocess"
label="OpenCV-Korrektur"
:disabled="scanInProgress"
/>
<UFormField label="Profil">
<USelectMenu
v-model="scanForm.postprocessProfile"
:items="[
{ label: 'Bon', value: 'receipt' },
{ label: 'Dokument', value: 'document' },
{ label: 'Rohscan', value: 'raw' }
]"
value-key="value"
label-key="label"
:disabled="scanInProgress || !scanForm.postprocess"
/>
</UFormField>
</div>
<UAlert
v-if="statusMessage"
color="info"
variant="soft"
icon="i-heroicons-clock"
:title="statusMessage"
:description="currentJob?.status ? `Status: ${currentJob.status}` : undefined"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="modal.close()">
{{ scanInProgress ? 'Im Hintergrund weiterlaufen lassen' : 'Schließen' }}
</UButton>
<UButton
icon="i-heroicons-document-magnifying-glass"
:loading="scanInProgress"
:disabled="!selectedAgentId || loadingAgents"
@click="startScan"
>
Scannen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>