Add CUTOS capability and core API examples

This commit is contained in:
yankun
2026-07-26 14:40:42 +08:00
commit 7cc505a5e2
61 changed files with 5136 additions and 0 deletions
@@ -0,0 +1,104 @@
<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,75 @@
<script setup lang="ts">
import cutosLogo from '@/assets/cutos.png'
const props = withDefaults(defineProps<{
title: string
subtitle?: string
coreVersion?: string
lwaName?: string
lwaVersion?: string
connected: boolean
statusLabel?: string
}>(), {
subtitle: 'CUTOS 4.0 LWA',
coreVersion: '',
lwaName: '',
lwaVersion: '',
statusLabel: ''
})
</script>
<template>
<header class="topbar">
<div class="brand">
<img :src="cutosLogo" alt="CUTOS" />
<div>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</div>
</div>
<div class="status-cluster">
<span class="version-tag">Core {{ coreVersion || '—' }}</span>
<span class="version-tag">LWA {{ lwaName }} {{ lwaVersion || '—' }}</span>
<span class="connection" :class="{ online: connected }">
<i></i>{{ statusLabel || (connected ? 'Connected' : 'Disconnected') }}
</span>
</div>
</header>
</template>
<style scoped>
.topbar {
min-height: 82px;
padding: 14px clamp(18px, 4vw, 52px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
color: #f8fafb;
background: #20262c;
border-bottom: 4px solid #16a394;
}
.brand { display: flex; align-items: center; gap: 15px; min-width: 0; }
.brand img { width: 42px; height: 42px; object-fit: contain; }
.brand h1 { margin: 0; font-size: 20px; line-height: 1.2; font-weight: 700; }
.brand p { margin: 4px 0 0; font-size: 12px; color: #aeb8bf; }
.status-cluster { display: flex; align-items: center; justify-content: flex-end; gap: 9px; flex-wrap: wrap; }
.version-tag, .connection {
height: 30px;
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
border: 1px solid #4b555d;
border-radius: 4px;
background: #2b3339;
}
.connection i { width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: #e06b65; }
.connection.online i { background: #42c6a8; box-shadow: 0 0 0 3px rgba(66, 198, 168, 0.14); }
@media (max-width: 640px) {
.topbar { align-items: flex-start; flex-direction: column; gap: 12px; }
.status-cluster { justify-content: flex-start; }
}
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string
cutos: string
lwa: string
}>()
</script>
<template>
<div class="m-[8px]">
<div class="text-[14px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ title }}</div>
<div class="my-[20px] text-[33px] font-semibold">Hello World!</div>
<div>
<span>We are using CUTOS {{ cutos }}. </span>
<span>LWA version {{ lwa }}.</span>
</div>
</div>
</template>
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { font-family: Inter,"Segoe UI",Arial,sans-serif; color:#20262c; background:#eef1f3; font-synthesis:none; }
* { box-sizing:border-box; } body { margin:0; min-width:320px; min-height:100vh; } button { font:inherit; cursor:pointer; } button:disabled { cursor:not-allowed; opacity:.48; }
.app-shell { min-height:100vh; background:#eef1f3; }
.dependency-tags,.button-row { display:flex; align-items:center; gap:9px; flex-wrap:wrap; }
main { width:100%; padding:28px clamp(18px,2vw,36px) 32px; } .error-banner { display:flex; gap:12px; margin-bottom:20px; padding:12px 16px; color:#7e2420; background:#fff0ef; border-left:4px solid #d54d47; }
.section-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:15px; } .section-heading h2 { margin:3px 0 0; font-size:18px; } .eyebrow { color:#64717a; font:11px "Cascadia Code",Consolas,monospace; }
.dependency-tags { justify-content:flex-end; } .provider-tag { padding:6px 9px; color:#5d6870; background:#f8fafb; border:1px solid #cbd3d8; border-radius:4px; font:11px "Cascadia Code",Consolas,monospace; } .provider-tag strong { color:#253038; }
.result-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } .result-card { min-width:0; min-height:220px; background:#fff; border:1px solid #d8dee2; border-radius:6px; overflow:hidden; }
.result-header { min-height:66px; padding:13px 15px; border-bottom:1px solid #e4e8eb; background:#f8fafb; } .result-header h3 { margin:0 0 6px; font-size:14px; } .result-header code { display:block; color:#68757d; font-size:11px; overflow-wrap:anywhere; }
.method-header { display:flex; align-items:center; justify-content:space-between; gap:14px; } .state-tag { padding:5px 8px; color:#8b3c37; background:#fff0ef; border-radius:3px; font:10px "Cascadia Code",Consolas,monospace; } .state-tag.online { color:#087b71; background:#e8f7f4; }
.card-body { padding:15px; } .button { min-height:34px; padding:7px 13px; border:1px solid transparent; border-radius:4px; font-weight:650; font-size:12px; } .button.primary { color:#fff; background:#138b80; border-color:#138b80; } .button.secondary { color:#2d373d; background:#fff; border-color:#bfc8cd; } .hint,.empty-state { margin:15px 0 0; color:#77838b; font-size:12px; }
.text-button { padding:5px 0; border:0; color:#138b80; background:transparent; font-size:12px; }
.image-switch { min-height:34px; display:inline-flex; align-items:center; gap:7px; margin-left:auto; color:#46535b; font-size:12px; cursor:pointer; } .image-switch input { width:15px; height:15px; margin:0; accent-color:#138b80; } .image-switch:has(input:disabled) { cursor:not-allowed; opacity:.48; }
pre { margin:0; padding:15px; font:12px/1.55 "Cascadia Code",Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; overflow:auto; } .result-card>pre { max-height:260px; }
.identity-card { min-height:330px; } .identity { display:grid; grid-template-columns:130px minmax(0,1fr); gap:20px; padding:16px; } .portrait { width:130px; height:166px; display:grid; place-items:center; color:#87939a; background:#f2f5f6; border:1px solid #dce2e5; } .portrait img { width:100%; height:100%; object-fit:cover; }
dl { display:grid; grid-template-columns:90px minmax(0,1fr); gap:0; margin:0; font-size:12px; } dt,dd { margin:0; padding:7px 8px; border-bottom:1px solid #edf0f2; } dt { color:#68757d; } dd { font-family:"Cascadia Code",Consolas,monospace; overflow-wrap:anywhere; }
.event-card { min-height:330px; display:grid; grid-template-rows:auto minmax(0,1fr); } .event-list { min-height:0; max-height:300px; overflow:auto; } .event-row { display:grid; grid-template-columns:78px minmax(0,1fr); gap:10px; padding:8px 13px; border-bottom:1px solid #edf0f2; } .event-row time { padding-top:4px; color:#77838b; font-size:11px; } .event-row pre { padding:4px 0; } .error-event pre { color:#b43b35; } .event-list .empty-state,.identity-card>.empty-state { padding:10px 15px; }
@media(max-width:980px){.result-grid{grid-template-columns:repeat(2,minmax(0,1fr));}}
@media(max-width:640px){.section-heading{align-items:flex-start;flex-direction:column}.result-grid{grid-template-columns:1fr}.identity-card{grid-column:auto}.identity{grid-template-columns:1fr}.portrait{width:110px;height:140px}}
@@ -0,0 +1,66 @@
export interface LwaConfig {
name: string
version: string
description?: string
params: {
title: string
host: string
[key: string]: unknown
}
drvDependencies?: Record<string, string>
}
const defaultConfig: LwaConfig = {
name: 'cutos-lwa',
version: '0.0.0',
params: {
title: 'CUTOS LWA',
host: 'localhost'
}
}
export let config: LwaConfig = { ...defaultConfig, params: { ...defaultConfig.params } }
function parse(str?: string) {
if (!str || !str.length) return {}
return str.split('&').reduce<Record<string, string>>((acc, row) => {
const [key, value] = row.split('=').map(decodeURIComponent)
acc[key] = value
return acc
}, {})
}
function mergeQueryParams(config: LwaConfig) {
if (typeof window === 'undefined') {
return config
}
const query = location.href.split('?')[1]
const { params } = parse(query) as { params?: string }
try {
if (params) config.params = { ...config.params, ...JSON.parse(params) }
} catch (error) {
console.error(error)
}
return config
}
export async function loadConfig(): Promise<LwaConfig> {
try {
const configUrl =
typeof window === 'undefined' ? 'config.json' : new URL('config.json', window.location.href).toString()
const response = await fetch(configUrl, { cache: 'no-cache' })
if (!response.ok) {
throw new Error(`Failed to load config.json: ${response.status}`)
}
config = mergeQueryParams((await response.json()) as LwaConfig)
return config
} catch (error) {
console.error(error)
config = mergeQueryParams({ ...defaultConfig, params: { ...defaultConfig.params } })
return config
}
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CUTOS_BROKER_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}