tantee-nuxt-commons 0.0.174 → 0.0.176
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +10 -1
- package/dist/runtime/components/device/IdCardButton.vue +83 -0
- package/dist/runtime/components/device/IdCardWebSocket.vue +195 -0
- package/dist/runtime/components/device/Scanner.vue +338 -0
- package/dist/runtime/components/form/Table.vue +6 -2
- package/dist/runtime/components/form/TableData.vue +4 -1
- package/dist/runtime/components/model/Autocomplete.vue +4 -1
- package/dist/runtime/components/model/Combobox.vue +4 -1
- package/dist/runtime/components/model/Select.vue +4 -1
- package/dist/runtime/components/model/Table.vue +1 -1
- package/dist/runtime/composables/api.d.ts +4 -4
- package/dist/runtime/composables/api.js +33 -21
- package/dist/runtime/composables/clientConfig.d.ts +15 -0
- package/dist/runtime/composables/clientConfig.js +79 -0
- package/dist/runtime/composables/graphqlModel.js +5 -1
- package/dist/runtime/composables/hostAgent.d.ts +260 -0
- package/dist/runtime/composables/hostAgent.js +74 -0
- package/dist/runtime/composables/hostAgentWs.d.ts +272 -0
- package/dist/runtime/composables/hostAgentWs.js +145 -0
- package/dist/runtime/composables/localStorageModel.d.ts +38 -0
- package/dist/runtime/composables/localStorageModel.js +88 -0
- package/dist/runtime/plugins/clientConfig.d.ts +2 -0
- package/dist/runtime/plugins/clientConfig.js +22 -0
- package/dist/runtime/plugins/default.d.ts +2 -0
- package/dist/runtime/plugins/default.js +50 -0
- package/dist/runtime/types/clientConfig.d.ts +13 -0
- package/dist/runtime/utils/array.d.ts +1 -0
- package/dist/runtime/utils/array.js +4 -0
- package/package.json +3 -2
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -27,7 +27,15 @@ const module = defineNuxtModule({
|
|
|
27
27
|
src: resolver.resolve("runtime/plugins/dialogManager"),
|
|
28
28
|
mode: "client"
|
|
29
29
|
});
|
|
30
|
-
|
|
30
|
+
addPlugin({
|
|
31
|
+
src: resolver.resolve("runtime/plugins/clientConfig"),
|
|
32
|
+
mode: "client"
|
|
33
|
+
});
|
|
34
|
+
addPlugin({
|
|
35
|
+
src: resolver.resolve("runtime/plugins/default"),
|
|
36
|
+
mode: "client"
|
|
37
|
+
});
|
|
38
|
+
const typeFiles = ["modules", "alert", "menu", "graphqlOperation", "formDialog", "dialogManager", "permission", "clientConfig"];
|
|
31
39
|
for (const file of typeFiles) {
|
|
32
40
|
addTypeTemplate({
|
|
33
41
|
src: resolver.resolve(`runtime/types/${file}.d.ts`),
|
|
@@ -45,6 +53,7 @@ const module = defineNuxtModule({
|
|
|
45
53
|
_nuxt.options.vite.optimizeDeps.include.push("print-js");
|
|
46
54
|
_nuxt.options.vite.optimizeDeps.include.push("gql-query-builder");
|
|
47
55
|
_nuxt.options.vite.optimizeDeps.include.push("exif-rotate-js");
|
|
56
|
+
_nuxt.options.vite.optimizeDeps.include.push("localstorage-slim");
|
|
48
57
|
}
|
|
49
58
|
});
|
|
50
59
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, useAttrs } from 'vue'
|
|
3
|
+
import { VBtn } from 'vuetify/components/VBtn'
|
|
4
|
+
import { useAlert } from '../../composables/alert'
|
|
5
|
+
import { useHostAgent, type PatientRegisterPayload } from '../../composables/hostAgent'
|
|
6
|
+
|
|
7
|
+
interface Props extends /* @vue-ignore */ InstanceType<typeof VBtn['$props']> {
|
|
8
|
+
/** If true -> call /idcard/infoAndPhoto, else /idcard/info */
|
|
9
|
+
withPhoto?: boolean
|
|
10
|
+
/** Optional reader name/query string */
|
|
11
|
+
reader?: string | null
|
|
12
|
+
/** Auto-disable button while reading */
|
|
13
|
+
disableWhileLoading?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
17
|
+
withPhoto: false,
|
|
18
|
+
reader: null,
|
|
19
|
+
disableWhileLoading: true,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
(e: 'read', payload: PatientRegisterPayload): void
|
|
24
|
+
(e: 'error', err: unknown): void
|
|
25
|
+
(e: 'loading', loading: boolean): void
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const attrs = useAttrs()
|
|
29
|
+
const host = useHostAgent()
|
|
30
|
+
|
|
31
|
+
const loading = ref(false)
|
|
32
|
+
const disabled = computed(() => (props.disableWhileLoading ? loading.value : false))
|
|
33
|
+
|
|
34
|
+
async function onClick(ev: MouseEvent) {
|
|
35
|
+
// allow parent to prevent if they attach their own click handler and call preventDefault
|
|
36
|
+
if (ev.defaultPrevented) return
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
loading.value = true
|
|
40
|
+
emit('loading', true)
|
|
41
|
+
|
|
42
|
+
const payload = props.withPhoto
|
|
43
|
+
? await host.getIdCardInfoAndPhoto(props.reader ?? undefined)
|
|
44
|
+
: await host.getIdCardInfo(props.reader ?? undefined)
|
|
45
|
+
|
|
46
|
+
emit('read', payload)
|
|
47
|
+
} catch (e: any) {
|
|
48
|
+
emit('error', e)
|
|
49
|
+
|
|
50
|
+
const msg = e?.data?.detail || e?.data?.title || e?.message || 'ID card read failed'
|
|
51
|
+
useAlert()?.addAlert({ message: msg, alertType: 'error' })
|
|
52
|
+
} finally {
|
|
53
|
+
loading.value = false
|
|
54
|
+
emit('loading', false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<!--
|
|
61
|
+
Extends Vuetify VBtn:
|
|
62
|
+
- All unknown attrs (size, class, etc.) pass through via v-bind="attrs"
|
|
63
|
+
- You can override content via default slot; label+icon is fallback
|
|
64
|
+
-->
|
|
65
|
+
<v-btn
|
|
66
|
+
v-bind="attrs"
|
|
67
|
+
class="idcard-btn"
|
|
68
|
+
:loading="loading"
|
|
69
|
+
:disabled="disabled || (attrs as any).disabled"
|
|
70
|
+
@click="onClick"
|
|
71
|
+
>
|
|
72
|
+
<template
|
|
73
|
+
v-for="(_, name, index) in ($slots as {})"
|
|
74
|
+
:key="index"
|
|
75
|
+
#[name]="slotData"
|
|
76
|
+
>
|
|
77
|
+
<slot
|
|
78
|
+
:name="name"
|
|
79
|
+
v-bind="((slotData || {}) as object)"
|
|
80
|
+
/>
|
|
81
|
+
</template>
|
|
82
|
+
</v-btn>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { type IdCardWsServerMessage, type PatientRegisterPayload } from '../../composables/hostAgent'
|
|
4
|
+
import { useHostAgentWs } from "../../composables/hostAgentWs";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Subscribe withPhoto=true => server will push payload that includes photo when available */
|
|
8
|
+
withPhoto?: boolean
|
|
9
|
+
/** Optional reader name; null/undefined means "any/default" */
|
|
10
|
+
reader?: string | null
|
|
11
|
+
/** Auto-connect on mount (default true) */
|
|
12
|
+
autoConnect?: boolean
|
|
13
|
+
/** Auto-subscribe after connect (default true) */
|
|
14
|
+
autoSubscribe?: boolean
|
|
15
|
+
/** If true, try to reconnect when socket closes unexpectedly */
|
|
16
|
+
autoReconnect?: boolean
|
|
17
|
+
/** Reconnect delay in ms */
|
|
18
|
+
reconnectDelayMs?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
22
|
+
withPhoto: false,
|
|
23
|
+
reader: null,
|
|
24
|
+
autoConnect: true,
|
|
25
|
+
autoSubscribe: true,
|
|
26
|
+
autoReconnect: false,
|
|
27
|
+
reconnectDelayMs: 1000,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
(e: 'connected'): void
|
|
32
|
+
(e: 'disconnected', info?: { code?: number; reason?: string }): void
|
|
33
|
+
(e: 'subscribed', info?: { reader?: string | null; withPhoto?: boolean }): void
|
|
34
|
+
(e: 'unsubscribed'): void
|
|
35
|
+
(e: 'inserted', payload: PatientRegisterPayload): void
|
|
36
|
+
(e: 'removed'): void
|
|
37
|
+
(e: 'error', err: unknown): void
|
|
38
|
+
(e: 'message', msg: IdCardWsServerMessage): void
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const host = useHostAgentWs()
|
|
42
|
+
|
|
43
|
+
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
const mounted = ref(false)
|
|
45
|
+
const manuallyDisconnected = ref(false)
|
|
46
|
+
|
|
47
|
+
function clearReconnect() {
|
|
48
|
+
if (reconnectTimer.value) {
|
|
49
|
+
clearTimeout(reconnectTimer.value)
|
|
50
|
+
reconnectTimer.value = null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function connectAndSubscribe() {
|
|
55
|
+
manuallyDisconnected.value = false
|
|
56
|
+
host.connectIdCardWs()
|
|
57
|
+
|
|
58
|
+
// subscribe immediately; safe because connectIdCardWs sets handlers then WS opens soon
|
|
59
|
+
// but we must wait until OPEN to send; so we subscribe on "open" event in handler below.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeDisconnect() {
|
|
63
|
+
manuallyDisconnected.value = true
|
|
64
|
+
clearReconnect()
|
|
65
|
+
try {
|
|
66
|
+
// best-effort unsubscribe (only if connected)
|
|
67
|
+
if (host.wsConnected.value) {
|
|
68
|
+
try { host.unsubscribeIdCardWs() } catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
} catch { /* ignore */ }
|
|
71
|
+
host.disconnectIdCardWs()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const off = host.onIdCardWsEvent((ev) => {
|
|
75
|
+
if (ev.type === 'open') {
|
|
76
|
+
emit('connected')
|
|
77
|
+
|
|
78
|
+
if (props.autoSubscribe) {
|
|
79
|
+
try {
|
|
80
|
+
host.subscribeIdCardWs({ reader: props.reader ?? null, withPhoto: props.withPhoto })
|
|
81
|
+
} catch (e) {
|
|
82
|
+
emit('error', e)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (ev.type === 'close') {
|
|
89
|
+
emit('disconnected', { code: ev.code, reason: ev.reason })
|
|
90
|
+
|
|
91
|
+
if (props.autoReconnect && mounted.value && !manuallyDisconnected.value) {
|
|
92
|
+
clearReconnect()
|
|
93
|
+
reconnectTimer.value = setTimeout(() => {
|
|
94
|
+
try {
|
|
95
|
+
connectAndSubscribe()
|
|
96
|
+
} catch (e) {
|
|
97
|
+
emit('error', e)
|
|
98
|
+
}
|
|
99
|
+
}, props.reconnectDelayMs)
|
|
100
|
+
}
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (ev.type === 'error') {
|
|
105
|
+
emit('error', ev.error)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (ev.type === 'message') {
|
|
110
|
+
const msg = ev.data
|
|
111
|
+
emit('message', msg)
|
|
112
|
+
|
|
113
|
+
switch (msg.type) {
|
|
114
|
+
case 'monitor.subscribed':
|
|
115
|
+
if (msg.ok) emit('subscribed', { reader: msg.reader ?? null, withPhoto: !!msg.withPhoto })
|
|
116
|
+
break
|
|
117
|
+
case 'monitor.unsubscribed':
|
|
118
|
+
emit('unsubscribed')
|
|
119
|
+
break
|
|
120
|
+
case 'card.inserted':
|
|
121
|
+
emit('inserted', msg.payload)
|
|
122
|
+
break
|
|
123
|
+
case 'card.removed':
|
|
124
|
+
emit('removed')
|
|
125
|
+
break
|
|
126
|
+
case 'card.error':
|
|
127
|
+
case 'error':
|
|
128
|
+
emit('error', msg)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
onMounted(() => {
|
|
135
|
+
mounted.value = true
|
|
136
|
+
if (props.autoConnect) connectAndSubscribe()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
onBeforeUnmount(() => {
|
|
140
|
+
mounted.value = false
|
|
141
|
+
try { off?.() } catch { /* ignore */ }
|
|
142
|
+
safeDisconnect()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* If props change while connected, switch subscription behavior.
|
|
147
|
+
* - withPhoto changes => re-subscribe (server treats subscribe twice as error), so do unsubscribe -> subscribe
|
|
148
|
+
* - reader changes => use switchReader action when possible; if not connected, just store for next subscribe
|
|
149
|
+
*/
|
|
150
|
+
watch(
|
|
151
|
+
() => props.reader,
|
|
152
|
+
(r) => {
|
|
153
|
+
if (!mounted.value) return
|
|
154
|
+
if (!host.wsConnected.value) return
|
|
155
|
+
if (!host.wsSubscribed.value) return
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
host.switchReaderIdCardWs(r ?? null)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
emit('error', e)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
watch(
|
|
166
|
+
() => props.withPhoto,
|
|
167
|
+
(withPhoto) => {
|
|
168
|
+
if (!mounted.value) return
|
|
169
|
+
if (!host.wsConnected.value) return
|
|
170
|
+
|
|
171
|
+
// Server disallows re-subscribe without unsubscribe first.
|
|
172
|
+
// We do a clean unsubscribe -> resubscribe sequence.
|
|
173
|
+
try {
|
|
174
|
+
if (host.wsSubscribed.value) {
|
|
175
|
+
host.unsubscribeIdCardWs()
|
|
176
|
+
}
|
|
177
|
+
} catch { /* ignore */ }
|
|
178
|
+
|
|
179
|
+
// resubscribe after a tick; give server a moment to process unsubscribe
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
try {
|
|
182
|
+
if (host.wsConnected.value) {
|
|
183
|
+
host.subscribeIdCardWs({ reader: props.reader ?? null, withPhoto })
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
emit('error', e)
|
|
187
|
+
}
|
|
188
|
+
}, 0)
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<template>
|
|
194
|
+
<!-- Intentionally blank (headless component) -->
|
|
195
|
+
</template>
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed, ref, watch, reactive } from 'vue'
|
|
3
|
+
import type { Base64File } from '../../composables/assetFile'
|
|
4
|
+
import { useAlert } from '../../composables/alert'
|
|
5
|
+
import {
|
|
6
|
+
useHostAgent,
|
|
7
|
+
PAPER_SOURCE,
|
|
8
|
+
BIT_DEPTH,
|
|
9
|
+
type ScanRequest,
|
|
10
|
+
type ScanResult,
|
|
11
|
+
} from '../../composables/hostAgent'
|
|
12
|
+
|
|
13
|
+
const alert = useAlert()
|
|
14
|
+
const host = useHostAgent()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: 'scan', files: Base64File[]): void
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
feeder?: boolean
|
|
22
|
+
duplex?: boolean
|
|
23
|
+
dpi?: number
|
|
24
|
+
quality?: number
|
|
25
|
+
/** UI string: 'color' | 'grey' | 'bw' */
|
|
26
|
+
color?: string
|
|
27
|
+
maxSize?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
31
|
+
feeder: false,
|
|
32
|
+
duplex: false,
|
|
33
|
+
dpi: 200,
|
|
34
|
+
quality: 80,
|
|
35
|
+
color: 'color',
|
|
36
|
+
maxSize: 5,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ✅ Model directly matches HostAgent ScanRequest
|
|
41
|
+
*/
|
|
42
|
+
const scannerOptions = ref<ScanRequest>({
|
|
43
|
+
dpi: props.dpi,
|
|
44
|
+
quality: props.quality,
|
|
45
|
+
paperSource: props.duplex
|
|
46
|
+
? PAPER_SOURCE.Duplex
|
|
47
|
+
: props.feeder
|
|
48
|
+
? PAPER_SOURCE.Feeder
|
|
49
|
+
: PAPER_SOURCE.Flatbed,
|
|
50
|
+
bitDepth:
|
|
51
|
+
props.color === 'bw'
|
|
52
|
+
? BIT_DEPTH.BlackAndWhite
|
|
53
|
+
: props.color === 'grey'
|
|
54
|
+
? BIT_DEPTH.Grayscale
|
|
55
|
+
: BIT_DEPTH.Color,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
watch(() => props.dpi, () => {
|
|
59
|
+
if (props.dpi) scannerOptions.value.dpi = props.dpi
|
|
60
|
+
})
|
|
61
|
+
watch(() => props.quality, () => {
|
|
62
|
+
if (props.quality) scannerOptions.value.quality = props.quality
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mapping
|
|
67
|
+
* - Flatbed => feeder=false, duplex=false
|
|
68
|
+
* - Feeder => feeder=true, duplex=false
|
|
69
|
+
* - Duplex => feeder=true, duplex=true
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
// duplex is true only when paperSource is Duplex
|
|
73
|
+
const uiDuplex = computed<boolean>({
|
|
74
|
+
get: () => scannerOptions.value.paperSource === PAPER_SOURCE.Duplex,
|
|
75
|
+
set(v) {
|
|
76
|
+
if (v) {
|
|
77
|
+
// duplex ON => MUST be feeder => paperSource Duplex
|
|
78
|
+
scannerOptions.value.paperSource = PAPER_SOURCE.Duplex
|
|
79
|
+
} else {
|
|
80
|
+
// duplex OFF => if currently duplex, fall back to Feeder (because feeder is still on)
|
|
81
|
+
// user can still later turn feeder off to go Flatbed
|
|
82
|
+
if (scannerOptions.value.paperSource === PAPER_SOURCE.Duplex) {
|
|
83
|
+
scannerOptions.value.paperSource = PAPER_SOURCE.Feeder
|
|
84
|
+
}
|
|
85
|
+
// if already Feeder/Flatbed, do nothing
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// feeder is true when paperSource is Feeder or Duplex
|
|
91
|
+
const uiFeeder = computed<boolean>({
|
|
92
|
+
get: () =>
|
|
93
|
+
scannerOptions.value.paperSource === PAPER_SOURCE.Feeder ||
|
|
94
|
+
scannerOptions.value.paperSource === PAPER_SOURCE.Duplex,
|
|
95
|
+
set(v) {
|
|
96
|
+
if (v) {
|
|
97
|
+
// feeder ON => keep duplex state if duplex already on, else go Feeder
|
|
98
|
+
scannerOptions.value.paperSource = uiDuplex.value ? PAPER_SOURCE.Duplex : PAPER_SOURCE.Feeder
|
|
99
|
+
} else {
|
|
100
|
+
// feeder OFF => duplex must be OFF too => go Flatbed
|
|
101
|
+
scannerOptions.value.paperSource = PAPER_SOURCE.Flatbed
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
const uiColor = computed<string>({
|
|
108
|
+
get() {
|
|
109
|
+
const b = scannerOptions.value.bitDepth
|
|
110
|
+
if (b === BIT_DEPTH.BlackAndWhite) return 'bw'
|
|
111
|
+
if (b === BIT_DEPTH.Grayscale) return 'grey'
|
|
112
|
+
return 'color'
|
|
113
|
+
},
|
|
114
|
+
set(v) {
|
|
115
|
+
const vv = (v || 'color').toLowerCase()
|
|
116
|
+
scannerOptions.value.bitDepth =
|
|
117
|
+
vv === 'bw'
|
|
118
|
+
? BIT_DEPTH.BlackAndWhite
|
|
119
|
+
: vv === 'grey' || vv === 'gray'
|
|
120
|
+
? BIT_DEPTH.Grayscale
|
|
121
|
+
: BIT_DEPTH.Color
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
watch(() => props.duplex, () => {
|
|
126
|
+
uiDuplex.value = props.duplex
|
|
127
|
+
})
|
|
128
|
+
watch(() => props.feeder, () => {
|
|
129
|
+
uiFeeder.value = props.feeder
|
|
130
|
+
})
|
|
131
|
+
watch(() => props.color, () => {
|
|
132
|
+
if (props.color) uiColor.value = props.color
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
function scanResultsToBase64Files(results: ScanResult[]): Base64File[] {
|
|
136
|
+
return results.map((r, idx) => ({
|
|
137
|
+
fileName: `scan_${idx + 1}.png`,
|
|
138
|
+
base64String: r.base64String.startsWith('data:')
|
|
139
|
+
? r.base64String
|
|
140
|
+
: `data:image/png;base64,${r.base64String}`,
|
|
141
|
+
}))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isScanning = ref(false)
|
|
145
|
+
|
|
146
|
+
async function performScan() {
|
|
147
|
+
try {
|
|
148
|
+
isScanning.value = true
|
|
149
|
+
const results = await host.scan(scannerOptions.value)
|
|
150
|
+
emit('scan', scanResultsToBase64Files(results || []))
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
const msg = e?.data?.detail || e?.data?.title || e?.message || 'Scan failed'
|
|
153
|
+
alert?.addAlert({ message: msg, alertType: 'error' })
|
|
154
|
+
} finally {
|
|
155
|
+
isScanning.value = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ✅ Upload helpers (restored)
|
|
161
|
+
*/
|
|
162
|
+
function fileToBase64(file: File) {
|
|
163
|
+
const maxSize = props.maxSize * 1048576
|
|
164
|
+
|
|
165
|
+
return new Promise<Base64File>((resolve, reject) => {
|
|
166
|
+
if (file.size > maxSize) reject(`File (${file.name}) size exceeds the ${props.maxSize} MB limit.`)
|
|
167
|
+
|
|
168
|
+
const reader = new FileReader()
|
|
169
|
+
reader.onload = (event) => {
|
|
170
|
+
resolve({ fileName: file.name, base64String: event.target?.result as string })
|
|
171
|
+
}
|
|
172
|
+
reader.onerror = reject
|
|
173
|
+
reader.readAsDataURL(file)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function performFileUpload(files: File | File[] | undefined) {
|
|
178
|
+
if (!files) return
|
|
179
|
+
|
|
180
|
+
const allFiles = Array.isArray(files) ? files : [files]
|
|
181
|
+
const base64Promises = allFiles.map(fileToBase64)
|
|
182
|
+
|
|
183
|
+
Promise.all(base64Promises)
|
|
184
|
+
.then((base64Strings) => emit('scan', base64Strings))
|
|
185
|
+
.catch((error) => alert?.addAlert({ message: String(error), alertType: 'error' }))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Slot props grouping (so you can do ops.performScan etc.)
|
|
190
|
+
*/
|
|
191
|
+
const scannerOptionsProps = computed(() => scannerOptions.value)
|
|
192
|
+
const configHelper = reactive({
|
|
193
|
+
get feeder() { return uiFeeder.value },
|
|
194
|
+
set feeder(v: boolean) { uiFeeder.value = v },
|
|
195
|
+
|
|
196
|
+
get duplex() { return uiDuplex.value },
|
|
197
|
+
set duplex(v: boolean) { uiDuplex.value = v },
|
|
198
|
+
|
|
199
|
+
get color() { return uiColor.value },
|
|
200
|
+
set color(v: string) { uiColor.value = v },
|
|
201
|
+
})
|
|
202
|
+
const operation = { performScan, performFileUpload, fileToBase64}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<template>
|
|
206
|
+
<div class="scanner">
|
|
207
|
+
<slot :scannerOptions="scannerOptionsProps" :configHelper="configHelper" :operation="operation" :isScanning="isScanning">
|
|
208
|
+
<v-card>
|
|
209
|
+
<v-card-text>
|
|
210
|
+
<form-pad v-model="scannerOptions">
|
|
211
|
+
<template #default>
|
|
212
|
+
<!-- Upload -->
|
|
213
|
+
<v-row>
|
|
214
|
+
<v-col cols="12">
|
|
215
|
+
<p>Upload a New Files</p>
|
|
216
|
+
</v-col>
|
|
217
|
+
|
|
218
|
+
<v-col cols="12">
|
|
219
|
+
<file-btn
|
|
220
|
+
@update:modelValue="performFileUpload"
|
|
221
|
+
block
|
|
222
|
+
multiple
|
|
223
|
+
variant="tonal"
|
|
224
|
+
rounded="xl"
|
|
225
|
+
>
|
|
226
|
+
<v-icon>mdi mdi-tray-arrow-up</v-icon>
|
|
227
|
+
Upload file
|
|
228
|
+
</file-btn>
|
|
229
|
+
</v-col>
|
|
230
|
+
</v-row>
|
|
231
|
+
|
|
232
|
+
<!-- Divider -->
|
|
233
|
+
<v-row>
|
|
234
|
+
<v-col cols="12" style="text-align: center;">
|
|
235
|
+
<p>Or</p>
|
|
236
|
+
</v-col>
|
|
237
|
+
</v-row>
|
|
238
|
+
|
|
239
|
+
<!-- Scan title -->
|
|
240
|
+
<v-row>
|
|
241
|
+
<v-col cols="12">
|
|
242
|
+
<p>Scan a New Files</p>
|
|
243
|
+
</v-col>
|
|
244
|
+
</v-row>
|
|
245
|
+
|
|
246
|
+
<!-- Scan controls -->
|
|
247
|
+
<v-row>
|
|
248
|
+
<v-col cols="12" class="py-0">
|
|
249
|
+
<v-switch
|
|
250
|
+
color="primary"
|
|
251
|
+
v-model="uiFeeder"
|
|
252
|
+
label="Enable feeder"
|
|
253
|
+
hide-details
|
|
254
|
+
density="compact"
|
|
255
|
+
/>
|
|
256
|
+
</v-col>
|
|
257
|
+
|
|
258
|
+
<v-col cols="12" class="py-0">
|
|
259
|
+
<v-switch
|
|
260
|
+
color="primary"
|
|
261
|
+
v-model="uiDuplex"
|
|
262
|
+
label="Enable duplex"
|
|
263
|
+
hide-details
|
|
264
|
+
density="compact"
|
|
265
|
+
/>
|
|
266
|
+
</v-col>
|
|
267
|
+
|
|
268
|
+
<v-col cols="12">
|
|
269
|
+
<p>choose color mode</p>
|
|
270
|
+
|
|
271
|
+
<v-btn-toggle
|
|
272
|
+
v-model="uiColor"
|
|
273
|
+
variant="tonal"
|
|
274
|
+
mandatory
|
|
275
|
+
>
|
|
276
|
+
<v-btn value="color" prepend-icon="mdi mdi-palette-outline">
|
|
277
|
+
<span>Color</span>
|
|
278
|
+
</v-btn>
|
|
279
|
+
|
|
280
|
+
<v-btn value="grey" prepend-icon="mdi mdi-circle">
|
|
281
|
+
<span>Grayscale</span>
|
|
282
|
+
</v-btn>
|
|
283
|
+
|
|
284
|
+
<v-btn value="bw" prepend-icon="mdi mdi-circle-half-full">
|
|
285
|
+
<span>Black&White</span>
|
|
286
|
+
</v-btn>
|
|
287
|
+
</v-btn-toggle>
|
|
288
|
+
</v-col>
|
|
289
|
+
|
|
290
|
+
<v-col cols="12">
|
|
291
|
+
<v-slider
|
|
292
|
+
v-model="scannerOptions.dpi"
|
|
293
|
+
:max="200"
|
|
294
|
+
:min="100"
|
|
295
|
+
:ticks="{ 100: 'Low', 150: 'Standard', 200: 'High' }"
|
|
296
|
+
show-ticks="always"
|
|
297
|
+
step="50"
|
|
298
|
+
tick-size="4"
|
|
299
|
+
label="Resolution"
|
|
300
|
+
hide-details
|
|
301
|
+
/>
|
|
302
|
+
</v-col>
|
|
303
|
+
|
|
304
|
+
<v-col cols="12">
|
|
305
|
+
<v-slider
|
|
306
|
+
v-model="scannerOptions.quality"
|
|
307
|
+
:max="100"
|
|
308
|
+
:min="60"
|
|
309
|
+
:ticks="{ 60: 'Low', 80: 'Standard', 100: 'High' }"
|
|
310
|
+
show-ticks="always"
|
|
311
|
+
step="5"
|
|
312
|
+
tick-size="4"
|
|
313
|
+
label="Quality"
|
|
314
|
+
hide-details
|
|
315
|
+
/>
|
|
316
|
+
</v-col>
|
|
317
|
+
|
|
318
|
+
<v-col cols="12">
|
|
319
|
+
<v-btn
|
|
320
|
+
@click="performScan"
|
|
321
|
+
:loading="isScanning"
|
|
322
|
+
:disabled="isScanning"
|
|
323
|
+
block
|
|
324
|
+
variant="tonal"
|
|
325
|
+
rounded="xl"
|
|
326
|
+
>
|
|
327
|
+
<v-icon>mdi mdi-scanner</v-icon>
|
|
328
|
+
Start Scanning
|
|
329
|
+
</v-btn>
|
|
330
|
+
</v-col>
|
|
331
|
+
</v-row>
|
|
332
|
+
</template>
|
|
333
|
+
</form-pad>
|
|
334
|
+
</v-card-text>
|
|
335
|
+
</v-card>
|
|
336
|
+
</slot>
|
|
337
|
+
</div>
|
|
338
|
+
</template>
|
|
@@ -5,6 +5,7 @@ import {computed, defineOptions,defineExpose, nextTick, ref, useAttrs, watch, us
|
|
|
5
5
|
import {omit} from 'lodash-es'
|
|
6
6
|
import {useDialog} from "../../composables/dialog"
|
|
7
7
|
import type {FormDialogCallback} from '../../types/formDialog'
|
|
8
|
+
import { useLocalStorageModel, type PersistSlimProps } from '../../composables/localStorageModel'
|
|
8
9
|
|
|
9
10
|
defineOptions({
|
|
10
11
|
inheritAttrs: false,
|
|
@@ -32,7 +33,7 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataTable['$props
|
|
|
32
33
|
stringFields?: Array<string>
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
36
|
+
const props = withDefaults(defineProps<Props & PersistSlimProps>(), {
|
|
36
37
|
noDataText: 'ไม่พบข้อมูล',
|
|
37
38
|
dialogFullscreen: false,
|
|
38
39
|
modelKey: 'id',
|
|
@@ -59,6 +60,8 @@ const items = ref<Record<string, any>[]>([])
|
|
|
59
60
|
const search = ref<string>()
|
|
60
61
|
const currentItem = ref<Record<string, any> | undefined>(undefined)
|
|
61
62
|
|
|
63
|
+
useLocalStorageModel(items,props)
|
|
64
|
+
|
|
62
65
|
function setSearch(keyword: string) {
|
|
63
66
|
search.value = keyword
|
|
64
67
|
}
|
|
@@ -200,7 +203,8 @@ defineExpose({
|
|
|
200
203
|
reset: ()=>inputRef.value?.reset(),
|
|
201
204
|
resetValidation : ()=>inputRef.value?.resetValidation(),
|
|
202
205
|
validate : ()=>inputRef.value?.validate(),
|
|
203
|
-
operation
|
|
206
|
+
operation,
|
|
207
|
+
items
|
|
204
208
|
})
|
|
205
209
|
</script>
|
|
206
210
|
|