feature: Core Digitization Workstation (Entry, Verification, Approval)
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 1m7s

This commit is contained in:
2026-08-12 04:22:25 +08:00
parent 9811f2a2ed
commit 5caf928787
16 changed files with 2077 additions and 905 deletions
@@ -1,67 +1,160 @@
<template>
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm">
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in">
<div
class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
data-testid="scan-viewer"
>
<!-- Toolbar light chrome, no decorative overlays on the page -->
<div
class="flex flex-wrap items-center gap-1 px-3 py-2 bg-surface border-b border-line text-sm text-ink shrink-0"
data-testid="scan-viewer-toolbar"
>
<button
type="button"
class="scan-toolbar-btn"
title="Zoom out"
:disabled="!url"
@click="zoomOut"
>
</button>
<button
type="button"
class="scan-toolbar-btn"
title="Zoom in"
:disabled="!url"
@click="zoomIn"
>
+
</button>
<button @click="zoomOut" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom out">
-
<button
type="button"
class="scan-toolbar-btn"
title="Fit width"
:disabled="!url"
data-testid="scan-fit-width"
@click="fitWidth"
>
Fit width
</button>
<button @click="resetZoom" class="px-2 py-1 hover:bg-gray-700 rounded" title="Reset">
<button
type="button"
class="scan-toolbar-btn"
title="Reset zoom"
:disabled="!url"
@click="resetZoom"
>
1:1
</button>
<button @click="rotateCw" class="px-2 py-1 hover:bg-gray-700 rounded" title="Rotate 90">
<button
type="button"
class="scan-toolbar-btn"
title="Rotate 90°"
:disabled="!url"
@click="rotateCw"
>
Rotate
</button>
<span class="ml-auto text-gray-400 text-xs">{{ Math.round(scale * 100) }}%</span>
<span class="ml-auto text-ink-secondary text-xs tabular-nums">
{{ url ? `${Math.round(scale * 100)}%` : '' }}
</span>
</div>
<!-- Document area -->
<!-- Document surface -->
<div
ref="viewerContainer"
class="flex-1 overflow-auto cursor-grab active:cursor-grabbing"
class="flex-1 min-h-0 overflow-auto bg-[#E8ECF1]"
:class="url && !loading && !error ? 'cursor-grab active:cursor-grabbing' : ''"
@mousedown="startPan"
@mousemove="pan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="onWheel"
>
<div
:style="{
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: 'top left',
transition: isPanning ? 'none' : 'transform 0.2s',
}"
>
<!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe
v-if="isPdf"
:src="url"
class="w-[800px] h-[1100px] bg-white"
frameborder="0"
title="Scanned document"
/>
<img
v-else
:src="url"
class="max-w-none"
draggable="false"
@load="onImageLoad"
<div v-if="loading" class="flex h-full items-center justify-center p-8">
<SkeletonBlock
variant="block"
height="min(70vh, 640px)"
width="min(100%, 480px)"
class="max-w-full shadow-page bg-surface"
/>
</div>
<div v-else-if="error" class="flex h-full items-center justify-center p-6">
<InlineError
class="max-w-md w-full"
title="Could not load document"
:message="error"
preserved="Your form draft was not affected."
retry-label="Retry"
@retry="$emit('retry')"
/>
</div>
<div
v-else-if="url"
class="p-6 inline-block min-w-full"
>
<div
:style="{
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: 'top left',
transition: isPanning ? 'none' : 'transform 0.15s ease-out',
}"
>
<!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe
v-if="isPdf"
:src="url"
class="scan-page w-[800px] h-[1100px] bg-white"
frameborder="0"
title="Scanned document"
/>
<img
v-else
:src="url"
class="scan-page max-w-none bg-white"
draggable="false"
alt="Scanned document"
@load="onImageLoad"
/>
</div>
</div>
<EmptyState
v-else
title="No document available"
description="The source scan will appear here when the batch document loads."
class="h-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import SkeletonBlock from './SkeletonBlock.vue'
import InlineError from './InlineError.vue'
import EmptyState from './EmptyState.vue'
const props = defineProps<{
url: string
const props = withDefaults(
defineProps<{
url?: string | null
loading?: boolean
error?: string | null
}>(),
{
url: null,
loading: false,
error: null,
}
)
defineEmits<{
(e: 'retry'): void
}>()
const isPdf = computed(() => {
if (!props.url) return false
const lower = props.url.toLowerCase()
return lower.includes('.pdf') || lower.includes('application/pdf')
})
@@ -73,23 +166,44 @@ const panY = ref(0)
const isPanning = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const viewerContainer = ref<HTMLElement | null>(null)
const naturalPageWidth = ref(800)
function zoomIn() { scale.value = Math.min(scale.value + 0.25, 5) }
function zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) }
function zoomIn() {
scale.value = Math.min(scale.value + 0.25, 5)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.25, 0.25)
}
function resetZoom() {
scale.value = 1
panX.value = 0
panY.value = 0
rotation.value = 0
}
function rotateCw() { rotation.value = (rotation.value + 90) % 360 }
function rotateCw() {
rotation.value = (rotation.value + 90) % 360
}
function fitWidth() {
const container = viewerContainer.value
if (!container) return
const padding = 48 // matches p-6
const available = Math.max(container.clientWidth - padding, 120)
scale.value = Math.min(Math.max(available / naturalPageWidth.value, 0.25), 5)
panX.value = 0
panY.value = 0
}
function onWheel(e: WheelEvent) {
if (!props.url || props.loading || props.error) return
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
function startPan(e: MouseEvent) {
if (!props.url || props.loading || props.error) return
if ((e.target as HTMLElement)?.closest('button')) return
isPanning.value = true
lastX.value = e.clientX
lastY.value = e.clientY
@@ -103,12 +217,40 @@ function pan(e: MouseEvent) {
lastY.value = e.clientY
}
function stopPan() { isPanning.value = false }
function stopPan() {
isPanning.value = false
}
function onImageLoad() {
// Reset view when a new image loads
function onImageLoad(e: Event) {
const img = e.target as HTMLImageElement
if (img.naturalWidth > 0) {
naturalPageWidth.value = img.naturalWidth
}
scale.value = 1
panX.value = 0
panY.value = 0
nextTick(() => fitWidth())
}
</script>
watch(
() => props.url,
(url) => {
if (!url) return
if (isPdf.value) {
naturalPageWidth.value = 800
nextTick(() => fitWidth())
}
}
)
</script>
<style scoped>
.scan-toolbar-btn {
@apply px-2 py-1 rounded-control text-ink hover:bg-canvas
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent
transition-colors;
}
.scan-page {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
}
</style>