105 lines
11 KiB
Vue
105 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { CoreAPI } from '@cutos/core'
|
|
import { DeviceCapabilityIdCardReader, type IdCardReadEvent, type DeviceInfo } from '@cutos/device-id-card-reader'
|
|
import CutosTopbar from '@/components/CutosTopbar.vue'
|
|
import { loadConfig } from '@/utils/config'
|
|
|
|
type EventKind = 'data' | 'status' | 'error'
|
|
interface EventItem { id: number; time: string; kind: EventKind; value: unknown }
|
|
interface ReaderClient {
|
|
init(): Promise<unknown>; connect(): Promise<{ connected: boolean }>; disconnect(): Promise<{ connected: boolean }>
|
|
readDeviceInfo(): Promise<DeviceInfo>; startRead(params?: { image?: boolean }): Promise<{ started: boolean }>
|
|
stopRead(): Promise<{ stopped: boolean }>; onData(listener: (data: IdCardReadEvent) => void): () => void
|
|
onStatus(listener: (value: unknown) => void): () => void; onError(listener: (value: unknown) => void): () => void
|
|
dispose(): boolean
|
|
}
|
|
|
|
class MockReader implements ReaderClient {
|
|
private listeners = { data: new Set<(v: any) => void>(), status: new Set<(v: any) => void>(), error: new Set<(v: any) => void>() }
|
|
async init() { return true }
|
|
async connect() { this.emit('status', { status: 'online', updatedAt: Date.now() }); return { connected: true } }
|
|
async disconnect() { this.emit('status', { status: 'offline', updatedAt: Date.now() }); return { connected: false } }
|
|
async readDeviceInfo() { return { manufacturer: 'GHC', model: 'GHC825 Mock', version: '0.1.0', deviceType: 'id-card-reader', SAMID: 'MOCK-001' } }
|
|
async startRead(params: { image?: boolean } = {}) {
|
|
this.emit('status', { status: 'reading', updatedAt: Date.now() })
|
|
window.setTimeout(() => this.emit('data', { type: 'read-card', values: { code: '440101199001011234', name: 'CUTOS 示例', sex: '男', birthday: '19900101', address: '深圳市南山区', nation: '汉', department: '深圳市公安局', startDate: '20200101', endDate: '20300101', certType: '居民身份证', ...(params.image ? { base64BMPData: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMzAiIGhlaWdodD0iMTY2Ij48cmVjdCB3aWR0aD0iMTMwIiBoZWlnaHQ9IjE2NiIgZmlsbD0iI2U4ZjdmNCIvPjx0ZXh0IHg9IjY1IiB5PSI4MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzEzOGI4MCIgZm9udC1zaXplPSIxMiI+TW9jayBwaG90bzwvdGV4dD48L3N2Zz4=' } : {}) } }), 500)
|
|
return { started: true }
|
|
}
|
|
async stopRead() { this.emit('status', { status: 'online', updatedAt: Date.now() }); return { stopped: true } }
|
|
onData(fn: (v: IdCardReadEvent) => void) { return this.listen('data', fn) }
|
|
onStatus(fn: (v: unknown) => void) { return this.listen('status', fn) }
|
|
onError(fn: (v: unknown) => void) { return this.listen('error', fn) }
|
|
dispose() { Object.values(this.listeners).forEach(v => v.clear()); return true }
|
|
private listen(kind: EventKind, fn: (v: any) => void) { this.listeners[kind].add(fn); return () => this.listeners[kind].delete(fn) }
|
|
private emit(kind: EventKind, value: unknown) { this.listeners[kind].forEach(fn => fn(value)) }
|
|
}
|
|
|
|
const title = ref('ID Card Reader')
|
|
const lwaName = ref(''); const lwaVersion = ref(''); const coreVersion = ref(''); const host = ref('localhost')
|
|
const runtimeConnected = ref(false); const initialized = ref(false); const connected = ref(false); const reading = ref(false)
|
|
const readerState = ref<'idle' | 'reading' | 'cooldown'>('idle')
|
|
const busy = ref(''); const errorText = ref(''); const deviceInfo = ref<DeviceInfo | null>(null)
|
|
const connectResult = ref<unknown>(null)
|
|
const includeImage = ref(false)
|
|
const card = ref<IdCardReadEvent['values'] | null>(null); const events = ref<EventItem[]>([])
|
|
const statusEvents = computed(() => events.value.filter(e => e.kind === 'status'))
|
|
const errorEvents = computed(() => events.value.filter(e => e.kind === 'error'))
|
|
let device: ReaderClient | null = null; let unsubscribers: Array<() => void> = []
|
|
let lastStatusAt = 0
|
|
|
|
function format(value: unknown) { return value == null ? 'No data' : typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
|
|
function addEvent(kind: EventKind, value: unknown) { events.value.unshift({ id: Date.now() + Math.random(), time: new Date().toLocaleTimeString(), kind, value }); events.value = events.value.slice(0, 30) }
|
|
async function run(name: string, action: () => Promise<void>) { busy.value = name; errorText.value = ''; try { await action() } catch (e) { errorText.value = e instanceof Error ? e.message : String(e); addEvent('error', errorText.value) } finally { busy.value = '' } }
|
|
async function connect() { await run('connect', async () => { connectResult.value = await device!.connect(); connected.value = Boolean(connectResult.value && typeof connectResult.value === 'object' && 'connected' in connectResult.value && connectResult.value.connected) }) }
|
|
async function disconnect() { await run('disconnect', async () => { await device!.disconnect(); connected.value = false; reading.value = false; readerState.value = 'idle' }) }
|
|
async function startRead() { await run('startRead', async () => { reading.value = Boolean((await device!.startRead({ image: includeImage.value })).started); if (reading.value) readerState.value = 'reading' }) }
|
|
async function stopRead() { await run('stopRead', async () => { await device!.stopRead(); reading.value = false; readerState.value = 'idle' }) }
|
|
function clearCard() { card.value = null }
|
|
function handleStatus(value: unknown) {
|
|
addEvent('status', value)
|
|
const payload = value && typeof value === 'object' ? value as { status?: unknown; updatedAt?: unknown } : {}
|
|
const updatedAt = typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now()
|
|
if (updatedAt < lastStatusAt) return
|
|
lastStatusAt = updatedAt
|
|
const status = typeof payload.status === 'string' ? payload.status : ''
|
|
if (status === 'online') { connected.value = true; reading.value = false; readerState.value = 'idle' }
|
|
if (status === 'offline') { connected.value = false; reading.value = false; readerState.value = 'idle' }
|
|
if (status === 'reading' || status === 'cooldown') { reading.value = true; readerState.value = status }
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const config = await loadConfig(); title.value = String(config.params.title || title.value); lwaName.value = config.name; lwaVersion.value = config.version
|
|
host.value = import.meta.env.VITE_CUTOS_BROKER_URL || String(config.params.host || 'localhost')
|
|
await CoreAPI.init(host.value); runtimeConnected.value = CoreAPI.connected(); coreVersion.value = CoreAPI.getVersion()
|
|
device = host.value === 'mock' ? new MockReader() : new DeviceCapabilityIdCardReader(); await device.init()
|
|
unsubscribers = [
|
|
device.onData(data => { card.value = data.values; addEvent('data', data) }),
|
|
device.onStatus(handleStatus),
|
|
device.onError(value => addEvent('error', value))
|
|
]
|
|
initialized.value = true; await connect(); if (connected.value) { deviceInfo.value = await device.readDeviceInfo(); await startRead() }
|
|
} catch (e) { errorText.value = e instanceof Error ? e.message : String(e) }
|
|
})
|
|
onUnmounted(() => { unsubscribers.forEach(fn => fn()); device?.dispose() })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-shell">
|
|
<CutosTopbar :title="title" subtitle="Device Capability · CUTOS 4.0" :core-version="coreVersion" :lwa-name="lwaName" :lwa-version="lwaVersion" :connected="runtimeConnected" />
|
|
<main>
|
|
<section v-if="errorText" class="error-banner"><strong>Operation failed</strong><span>{{ errorText }}</span></section>
|
|
<section class="section-heading"><div><span class="eyebrow">{{ host }}</span><h2>ID Card Reader</h2></div><div class="dependency-tags"><span class="provider-tag">Capability: <strong>@cutos/device-id-card-reader 4.0.0</strong></span><span class="provider-tag">Provider: <strong>device-id-card-reader-provider-ghc825 0.2.1</strong></span></div></section>
|
|
<section class="result-grid">
|
|
<article class="result-card"><div class="result-header method-header"><div><h3>Connection</h3><code>connect() / disconnect()</code></div><span class="state-tag" :class="{ online: connected }">{{ connected ? 'online' : 'offline' }}</span></div><div class="card-body"><div class="button-row"><button class="button primary" :disabled="!initialized || !!busy || connected" @click="connect">Connect</button><button class="button secondary" :disabled="!connected || !!busy" @click="disconnect">Disconnect</button></div><pre>{{ format(connectResult) }}</pre></div></article>
|
|
<article class="result-card"><div class="result-header"><h3>Device information</h3><code>readDeviceInfo()</code></div><pre>{{ format(deviceInfo) }}</pre></article>
|
|
<article class="result-card"><div class="result-header method-header"><div><h3>Reader</h3><code>startRead({ image: {{ includeImage }} }) / stopRead()</code></div><span class="state-tag" :class="{ online: reading }">{{ readerState }}</span></div><div class="card-body"><div class="button-row"><button class="button primary" :disabled="!connected || !!busy || reading" @click="startRead">Start read</button><button class="button secondary" :disabled="!reading || !!busy" @click="stopRead">Stop</button><label class="image-switch"><input v-model="includeImage" type="checkbox" :disabled="reading || !!busy"><span>Read portrait</span></label></div><p class="hint">Keep the ID card steadily on the reader for at least 2 seconds. Portrait reading is optional.</p></div></article>
|
|
<article class="result-card identity-card"><div class="result-header method-header"><div><h3>ID card result</h3><code>onData()</code></div><button v-if="card" class="text-button" @click="clearCard">Clear</button></div><div v-if="card" class="identity"><div class="portrait"><img v-if="card.base64BMPData" :src="card.base64BMPData" alt="ID portrait"><span v-else>No image</span></div><dl><template v-for="(value, key) in card" :key="key"><template v-if="key !== 'base64BMPData'"><dt>{{ key }}</dt><dd>{{ value }}</dd></template></template></dl></div><p v-else class="empty-state">Place an ID card on the reader…</p></article>
|
|
<article class="result-card event-card"><div class="result-header"><h3>Status</h3><code>onStatus()</code></div><div class="event-list"><div v-for="event in statusEvents" :key="event.id" class="event-row"><time>{{ event.time }}</time><pre>{{ format(event.value) }}</pre></div><p v-if="!statusEvents.length" class="empty-state">Waiting for status…</p></div></article>
|
|
<article class="result-card event-card"><div class="result-header"><h3>Errors</h3><code>onError()</code></div><div class="event-list"><div v-for="event in errorEvents" :key="event.id" class="event-row error-event"><time>{{ event.time }}</time><pre>{{ format(event.value) }}</pre></div><p v-if="!errorEvents.length" class="empty-state">No errors</p></div></article>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</template>
|