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.
Files changed (30) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +10 -1
  3. package/dist/runtime/components/device/IdCardButton.vue +83 -0
  4. package/dist/runtime/components/device/IdCardWebSocket.vue +195 -0
  5. package/dist/runtime/components/device/Scanner.vue +338 -0
  6. package/dist/runtime/components/form/Table.vue +6 -2
  7. package/dist/runtime/components/form/TableData.vue +4 -1
  8. package/dist/runtime/components/model/Autocomplete.vue +4 -1
  9. package/dist/runtime/components/model/Combobox.vue +4 -1
  10. package/dist/runtime/components/model/Select.vue +4 -1
  11. package/dist/runtime/components/model/Table.vue +1 -1
  12. package/dist/runtime/composables/api.d.ts +4 -4
  13. package/dist/runtime/composables/api.js +33 -21
  14. package/dist/runtime/composables/clientConfig.d.ts +15 -0
  15. package/dist/runtime/composables/clientConfig.js +79 -0
  16. package/dist/runtime/composables/graphqlModel.js +5 -1
  17. package/dist/runtime/composables/hostAgent.d.ts +260 -0
  18. package/dist/runtime/composables/hostAgent.js +74 -0
  19. package/dist/runtime/composables/hostAgentWs.d.ts +272 -0
  20. package/dist/runtime/composables/hostAgentWs.js +145 -0
  21. package/dist/runtime/composables/localStorageModel.d.ts +38 -0
  22. package/dist/runtime/composables/localStorageModel.js +88 -0
  23. package/dist/runtime/plugins/clientConfig.d.ts +2 -0
  24. package/dist/runtime/plugins/clientConfig.js +22 -0
  25. package/dist/runtime/plugins/default.d.ts +2 -0
  26. package/dist/runtime/plugins/default.js +50 -0
  27. package/dist/runtime/types/clientConfig.d.ts +13 -0
  28. package/dist/runtime/utils/array.d.ts +1 -0
  29. package/dist/runtime/utils/array.js +4 -0
  30. package/package.json +3 -2
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0"
6
6
  },
7
- "version": "0.0.174",
7
+ "version": "0.0.176",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
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
- const typeFiles = ["modules", "alert", "menu", "graphqlOperation", "formDialog", "dialogManager", "permission"];
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&amp;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