tantee-nuxt-commons 0.0.169 → 0.0.170
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/runtime/components/Alert.vue +0 -1
- package/dist/runtime/components/form/ActionPad.vue +1 -1
- package/dist/runtime/components/form/Dialog.vue +1 -1
- package/dist/runtime/components/form/EditPad.vue +1 -1
- package/dist/runtime/components/form/File.vue +189 -126
- package/dist/runtime/components/form/Iterator.vue +2 -0
- package/dist/runtime/components/form/Pad.vue +3 -72
- package/dist/runtime/components/form/SignPad.vue +126 -141
- package/dist/runtime/components/form/images/Field.vue +197 -168
- package/dist/runtime/components/label/DateCount.vue +13 -117
- package/dist/runtime/components/pdf/View.vue +32 -42
- package/dist/runtime/composables/assetFile.d.ts +31 -0
- package/dist/runtime/composables/assetFile.js +134 -0
- package/dist/runtime/composables/document/templateFormTable.js +1 -4
- package/package.json +1 -1
|
@@ -1,230 +1,262 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type
|
|
6
|
-
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
|
+
import { isEqual } from 'lodash-es'
|
|
4
|
+
import { useAlert } from '../../../composables/alert'
|
|
5
|
+
import {useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
|
|
6
|
+
import {useRuntimeConfig} from "#imports";
|
|
7
7
|
|
|
8
8
|
const emit = defineEmits<{
|
|
9
|
-
(e:
|
|
10
|
-
}>()
|
|
9
|
+
(e: 'update:modelValue', value: Base64Image[]): void
|
|
10
|
+
}>()
|
|
11
11
|
|
|
12
12
|
const alert = useAlert()
|
|
13
|
-
|
|
14
|
-
interface Image {
|
|
15
|
-
title: string;
|
|
16
|
-
data: string;
|
|
17
|
-
props: {};
|
|
18
|
-
}
|
|
13
|
+
const { fileToBase64, hydrateAssetFile } = useAssetFile()
|
|
19
14
|
|
|
20
15
|
interface Props {
|
|
21
|
-
modelValue?:
|
|
22
|
-
readonly?: boolean
|
|
23
|
-
label?: string
|
|
16
|
+
modelValue?: Base64Image[]
|
|
17
|
+
readonly?: boolean
|
|
18
|
+
label?: string
|
|
19
|
+
accept?: string
|
|
20
|
+
autoHydrate?: boolean
|
|
21
|
+
maxFileSize?: number
|
|
24
22
|
}
|
|
25
23
|
const props = withDefaults(defineProps<Props>(), {
|
|
26
|
-
modelValue: () => [] as
|
|
24
|
+
modelValue: () => [] as Base64Image[],
|
|
25
|
+
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
|
|
26
|
+
autoHydrate: false,
|
|
27
|
+
maxFileSize: 10,
|
|
27
28
|
})
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const uploadImages
|
|
32
|
-
// dialog = dialog for capturing image
|
|
33
|
-
const dialog: Ref<boolean> = ref(false);
|
|
34
|
-
// dialogUpdate, dataUpdate = dialog for editing image
|
|
35
|
-
const dialogUpdate: Ref<boolean> = ref(false);
|
|
36
|
-
const dataUpdate: Ref<Image> = ref({
|
|
37
|
-
title: "",
|
|
38
|
-
data: "",
|
|
39
|
-
props: {},
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// remove selected image
|
|
44
|
-
const remove = (index: number) => {
|
|
45
|
-
images.value.splice(index, 1);
|
|
46
|
-
};
|
|
30
|
+
/** Internal state (always Base64Image[]) */
|
|
31
|
+
const images = ref<Base64Image[]>([])
|
|
32
|
+
const uploadImages = ref<File[]>([])
|
|
47
33
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
34
|
+
/** Dialogs */
|
|
35
|
+
const dialog = ref(false) // capture dialog
|
|
36
|
+
const dialogUpdate = ref(false) // edit dialog
|
|
37
|
+
const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
|
|
53
38
|
|
|
39
|
+
/** Fullscreen preview */
|
|
40
|
+
const dialogImageFullScreen = ref(false)
|
|
41
|
+
const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
|
|
54
42
|
|
|
43
|
+
/** ---------- Stable keys + guards ---------- */
|
|
44
|
+
let internalSync = false
|
|
45
|
+
let lastEmittedSig = '' // signature of last emitted state
|
|
55
46
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
function imageKey(im: Base64Image): string {
|
|
48
|
+
const id = im.imageData?.id
|
|
49
|
+
if (id != null) return `id:${id}`
|
|
50
|
+
const title = im.imageTitle ?? ''
|
|
51
|
+
const len = im.imageData?.base64String?.length ?? 0
|
|
52
|
+
return `t:${title}|l:${len}`
|
|
61
53
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
54
|
+
|
|
55
|
+
function signature(arr: Base64Image[]): string {
|
|
56
|
+
return arr.map(imageKey).join('|')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Emit helper (guarded + updates last signature) */
|
|
60
|
+
function emitNow(next: Base64Image[]) {
|
|
61
|
+
const sig = signature(next)
|
|
62
|
+
if (sig === lastEmittedSig) return
|
|
63
|
+
internalSync = true
|
|
70
64
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const mineType = result.split(",")[0];
|
|
76
|
-
const base64 = result.split(",")[1];
|
|
77
|
-
resolve({
|
|
78
|
-
base64string: base64,
|
|
79
|
-
mineType: mineType,
|
|
80
|
-
filename: file.name,
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
reader.onerror = (error) => {
|
|
84
|
-
reject(error);
|
|
85
|
-
};
|
|
86
|
-
reader.readAsDataURL(file);
|
|
87
|
-
});
|
|
88
|
-
return await readPromise;
|
|
89
|
-
} catch (error: any) {
|
|
90
|
-
alert?.addAlert({message: error, alertType: 'error'})
|
|
65
|
+
emit('update:modelValue', next)
|
|
66
|
+
lastEmittedSig = sig
|
|
67
|
+
} finally {
|
|
68
|
+
queueMicrotask(() => { internalSync = false })
|
|
91
69
|
}
|
|
92
|
-
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ---------- Helpers ---------- */
|
|
93
73
|
|
|
94
|
-
|
|
95
|
-
const addImage = (data: Image) => {
|
|
74
|
+
const addImage = (img: Base64Image) => {
|
|
96
75
|
images.value.push({
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
})
|
|
101
|
-
dialog.value = false
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
76
|
+
imageData: img.imageData ?? {},
|
|
77
|
+
imageTitle: img.imageTitle ?? '',
|
|
78
|
+
imageProps: img.imageProps ?? {},
|
|
79
|
+
})
|
|
80
|
+
dialog.value = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const remove = (index: number) => {
|
|
84
|
+
images.value.splice(index, 1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const setDataUpdate = (img: Base64Image) => {
|
|
88
|
+
dataUpdate.value = {
|
|
89
|
+
imageData: { ...(img.imageData ?? {}) },
|
|
90
|
+
imageTitle: img.imageTitle ?? '',
|
|
91
|
+
imageProps: { ...(img.imageProps ?? {}) },
|
|
92
|
+
}
|
|
93
|
+
dialogUpdate.value = true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
|
|
97
|
+
|
|
98
|
+
const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
|
|
99
|
+
|
|
100
|
+
const imageSrcFromImageData = (imageData: Base64Image) => {
|
|
101
|
+
let assetUrl = useRuntimeConfig().public.ASSET_URL as string
|
|
102
|
+
if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
|
|
103
|
+
if (imageData?.imageData?.id) return (assetUrl || '/asset').replace(/\/+$/, '') +"/"+imageData?.imageData?.id
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** File → Base64Image using composable */
|
|
108
|
+
const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
|
|
109
|
+
try {
|
|
110
|
+
const base64 = await fileToBase64(file,props.maxFileSize)
|
|
111
|
+
const dataUrl = base64.base64String || ''
|
|
112
|
+
if (!isImageDataUrl(dataUrl)) {
|
|
113
|
+
alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
|
|
114
|
+
return null
|
|
123
115
|
}
|
|
116
|
+
return {
|
|
117
|
+
imageData: { base64String: dataUrl } as Base64Asset,
|
|
118
|
+
imageTitle: base64.fileName,
|
|
119
|
+
imageProps: {},
|
|
120
|
+
}
|
|
121
|
+
} catch (e: any) {
|
|
122
|
+
alert?.addAlert({ message: String(e), alertType: 'error' })
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Handle upload button update */
|
|
128
|
+
const uploadImageFile = async () => {
|
|
129
|
+
const duplicated: string[] = []
|
|
130
|
+
for (const file of uploadImages.value) {
|
|
131
|
+
if (checkDuplicationName(file.name)) {
|
|
132
|
+
duplicated.push(file.name)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const base64Image = await fileToBase64Image(file)
|
|
136
|
+
if (base64Image) addImage(base64Image)
|
|
124
137
|
}
|
|
125
138
|
uploadImages.value = []
|
|
126
|
-
if (
|
|
127
|
-
alert?.addAlert({message:
|
|
128
|
-
duplicatedFileName.value = ""
|
|
139
|
+
if (duplicated.length) {
|
|
140
|
+
alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
|
|
129
141
|
}
|
|
130
|
-
}
|
|
142
|
+
}
|
|
131
143
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
144
|
+
/** Capture flow (FormDialog) */
|
|
145
|
+
type FormDialogCallback = { done: () => void }
|
|
146
|
+
const modelData = ref()
|
|
147
|
+
|
|
148
|
+
const captureImage = (payload: any, cb: FormDialogCallback) => {
|
|
149
|
+
const dataUrl: string = payload?.imageCapture ?? ''
|
|
150
|
+
if (!dataUrl || !isImageDataUrl(dataUrl)) {
|
|
151
|
+
alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
135
154
|
addImage({
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
imageData: { base64String: dataUrl },
|
|
156
|
+
imageTitle: Math.random().toString(36).slice(2, 11),
|
|
157
|
+
imageProps: {},
|
|
139
158
|
})
|
|
140
|
-
|
|
159
|
+
cb?.done?.()
|
|
141
160
|
}
|
|
142
161
|
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const imageFullScreen = ref({
|
|
146
|
-
title: "",
|
|
147
|
-
image: ""
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
const openImageFullScreen = (image: { [key: string]: string }) => {
|
|
162
|
+
/** Fullscreen preview */
|
|
163
|
+
const openImageFullScreen = (img: Base64Image) => {
|
|
151
164
|
dialogImageFullScreen.value = true
|
|
152
|
-
imageFullScreen.value.title =
|
|
153
|
-
imageFullScreen.value.image =
|
|
165
|
+
imageFullScreen.value.title = img.imageTitle ?? ''
|
|
166
|
+
imageFullScreen.value.image = imageSrcFromImageData(img)
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
/** ---------- Watchers (signature-based) ---------- */
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
/* Parent → Internal */
|
|
172
|
+
watch(
|
|
173
|
+
() => props.modelValue,
|
|
174
|
+
async (val) => {
|
|
175
|
+
if (internalSync) return
|
|
160
176
|
|
|
177
|
+
const next = Array.isArray(val) ? [...val] : []
|
|
178
|
+
const nextSig = signature(next)
|
|
161
179
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}, {deep: true});
|
|
180
|
+
// Only reassign when truly different
|
|
181
|
+
if (nextSig !== signature(images.value)) {
|
|
182
|
+
images.value = next
|
|
166
183
|
|
|
184
|
+
// optional hydration
|
|
185
|
+
if (props.autoHydrate && images.value.length) {
|
|
186
|
+
const targets = images.value.filter(
|
|
187
|
+
(im) => im.imageData?.id != null && !im.imageData?.base64String
|
|
188
|
+
)
|
|
189
|
+
if (targets.length) {
|
|
190
|
+
await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
|
|
191
|
+
// After hydration, emit once (guarded) to update parent
|
|
192
|
+
emitNow(images.value)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
167
195
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
// sync lastEmittedSig to current internal state so next local change emits
|
|
197
|
+
lastEmittedSig = signature(images.value)
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{ deep: true, immediate: true }
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
/* Internal → Parent: watch the signature instead of deep structure */
|
|
204
|
+
watch(
|
|
205
|
+
() => signature(images.value),
|
|
206
|
+
() => {
|
|
207
|
+
if (internalSync) return
|
|
208
|
+
// Only emit when signature actually changes
|
|
209
|
+
emitNow(images.value)
|
|
210
|
+
},
|
|
211
|
+
{ immediate: false }
|
|
212
|
+
)
|
|
178
213
|
</script>
|
|
179
214
|
|
|
180
215
|
<template>
|
|
181
216
|
<VCard>
|
|
182
217
|
<VToolbar density="compact">
|
|
183
218
|
<VToolbarTitle>{{ label }}</VToolbarTitle>
|
|
184
|
-
<v-spacer
|
|
219
|
+
<v-spacer />
|
|
185
220
|
<VToolbarItems v-if="!readonly">
|
|
186
221
|
<FileBtn
|
|
187
|
-
ref="fileBtn"
|
|
188
222
|
v-model="uploadImages"
|
|
189
|
-
accept="
|
|
223
|
+
:accept="accept"
|
|
190
224
|
color="primary"
|
|
191
225
|
icon="mdi:mdi-image-plus"
|
|
192
|
-
|
|
226
|
+
icon-only
|
|
193
227
|
multiple
|
|
194
228
|
variant="text"
|
|
195
|
-
@update:model-value="
|
|
229
|
+
@update:model-value="uploadImageFile"
|
|
196
230
|
/>
|
|
197
231
|
<v-btn color="primary" icon @click="dialog = true">
|
|
198
232
|
<v-icon>mdi mdi-camera-plus</v-icon>
|
|
199
233
|
</v-btn>
|
|
200
234
|
</VToolbarItems>
|
|
201
235
|
</VToolbar>
|
|
236
|
+
|
|
202
237
|
<VCardText>
|
|
203
238
|
<VRow dense justify="center">
|
|
204
|
-
<VCol v-for="(image, index) in images" :key="index" cols="4">
|
|
239
|
+
<VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
|
|
205
240
|
<VCard>
|
|
206
241
|
<VToolbar density="compact">
|
|
207
242
|
<VToolbarTitle>
|
|
208
|
-
{{ image.
|
|
243
|
+
{{ image.imageTitle }}
|
|
209
244
|
</VToolbarTitle>
|
|
210
|
-
<VSpacer
|
|
245
|
+
<VSpacer />
|
|
211
246
|
<VToolbarItems v-if="!readonly">
|
|
212
247
|
<v-btn icon @click="remove(index)">
|
|
213
248
|
<v-icon>mdi mdi-delete-outline</v-icon>
|
|
214
249
|
</v-btn>
|
|
215
|
-
<v-btn
|
|
216
|
-
color="primary"
|
|
217
|
-
icon
|
|
218
|
-
@click="setDataUpdate(image)"
|
|
219
|
-
>
|
|
250
|
+
<v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id">
|
|
220
251
|
<v-icon>mdi mdi-image-edit-outline</v-icon>
|
|
221
252
|
</v-btn>
|
|
222
253
|
</VToolbarItems>
|
|
223
254
|
</VToolbar>
|
|
255
|
+
|
|
224
256
|
<v-img
|
|
225
|
-
:src="image
|
|
226
|
-
@click="() => { (props.readonly) ? openImageFullScreen(image) : setDataUpdate(image)}"
|
|
257
|
+
:src="imageSrcFromImageData(image)"
|
|
227
258
|
height="250"
|
|
259
|
+
@click="() => { (props.readonly || image.imageData?.id) ? openImageFullScreen(image) : setDataUpdate(image) }"
|
|
228
260
|
/>
|
|
229
261
|
</VCard>
|
|
230
262
|
</VCol>
|
|
@@ -232,32 +264,29 @@ onMounted(() => {
|
|
|
232
264
|
</VCardText>
|
|
233
265
|
</VCard>
|
|
234
266
|
|
|
235
|
-
|
|
236
|
-
<VDialog
|
|
237
|
-
v-model="dialogUpdate"
|
|
238
|
-
fullscreen
|
|
239
|
-
transition="dialog-bottom-transition"
|
|
240
|
-
>
|
|
267
|
+
<!-- Edit dialog -->
|
|
268
|
+
<VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
|
|
241
269
|
<FormImagesPad
|
|
242
|
-
v-model="dataUpdate.
|
|
270
|
+
v-model="dataUpdate.imageData.base64String"
|
|
243
271
|
@closedDialog="dialogUpdate = false"
|
|
244
|
-
|
|
272
|
+
/>
|
|
245
273
|
</VDialog>
|
|
246
274
|
|
|
275
|
+
<!-- Capture dialog -->
|
|
247
276
|
<FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
|
|
248
277
|
<template #default="{ data }">
|
|
249
|
-
<FormImagesCapture v-model="data.imageCapture"/>
|
|
278
|
+
<FormImagesCapture v-model="data.imageCapture" />
|
|
250
279
|
</template>
|
|
251
280
|
</FormDialog>
|
|
252
281
|
|
|
282
|
+
<!-- Fullscreen preview -->
|
|
253
283
|
<v-dialog v-model="dialogImageFullScreen">
|
|
254
284
|
<v-toolbar :title="imageFullScreen.title">
|
|
255
|
-
<v-spacer/>
|
|
256
|
-
<v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false"/>
|
|
285
|
+
<v-spacer />
|
|
286
|
+
<v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
|
|
257
287
|
</v-toolbar>
|
|
258
288
|
<v-card height="80vh">
|
|
259
|
-
<v-img :src="imageFullScreen.image"/>
|
|
289
|
+
<v-img :src="imageFullScreen.image" />
|
|
260
290
|
</v-card>
|
|
261
|
-
|
|
262
291
|
</v-dialog>
|
|
263
292
|
</template>
|
|
@@ -2,143 +2,39 @@
|
|
|
2
2
|
import { DateTime } from "luxon";
|
|
3
3
|
import { computed } from "vue";
|
|
4
4
|
|
|
5
|
-
type
|
|
6
|
-
type Locale = 'th' | 'en' | 'en-US' | 'th-TH';
|
|
5
|
+
type Locale = "th" | "en" | "en-US" | "th-TH";
|
|
7
6
|
|
|
8
7
|
interface Props {
|
|
9
8
|
modelValue: DateTime;
|
|
10
9
|
endDate?: DateTime;
|
|
11
10
|
locale?: Locale;
|
|
12
|
-
|
|
13
|
-
units?: Unit[];
|
|
14
|
-
zeroBase?: boolean; // true = start at 0, false = start at 1 (inclusive)
|
|
11
|
+
zeroBase?: boolean; // true = start at 0, false = start at 1
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
const props = withDefaults(defineProps<Props>(), {
|
|
18
|
-
locale:
|
|
19
|
-
showSuffix: true,
|
|
15
|
+
locale: "th",
|
|
20
16
|
zeroBase: true,
|
|
21
17
|
});
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (locale.startsWith('en')) return 'en';
|
|
26
|
-
if (locale.startsWith('th')) return 'th';
|
|
27
|
-
return 'th'; // default fallback
|
|
28
|
-
};
|
|
19
|
+
const normalizeLocale = (locale: Locale): "en" | "th" =>
|
|
20
|
+
locale.startsWith("en") ? "en" : "th";
|
|
29
21
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
months: 'months',
|
|
33
|
-
days: 'days',
|
|
34
|
-
hours: 'hours',
|
|
35
|
-
minutes: 'minutes',
|
|
36
|
-
seconds: 'seconds',
|
|
37
|
-
};
|
|
38
|
-
const labelsEnSingular: Record<Unit, string> = {
|
|
39
|
-
years: 'year',
|
|
40
|
-
months: 'month',
|
|
41
|
-
days: 'day',
|
|
42
|
-
hours: 'hour',
|
|
43
|
-
minutes: 'minute',
|
|
44
|
-
seconds: 'second',
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const localizedLabels: Record<'en' | 'th', Record<Unit, string>> = {
|
|
48
|
-
en: labelsEnPlural, // จะสลับเป็น singular ตามค่าจริงตอน render
|
|
49
|
-
th: {
|
|
50
|
-
years: 'ปี',
|
|
51
|
-
months: 'เดือน',
|
|
52
|
-
days: 'วัน',
|
|
53
|
-
hours: 'ชั่วโมง',
|
|
54
|
-
minutes: 'นาที',
|
|
55
|
-
seconds: 'วินาที',
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const localizedSuffix: Record<'en' | 'th', string> = {
|
|
60
|
-
en: 'ago',
|
|
61
|
-
th: 'ที่ผ่านมา',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const outputText = computed(() => {
|
|
65
|
-
const base = props.endDate ?? DateTime.now(); // ใช้ endDate ถ้ามี ไม่งั้น now
|
|
22
|
+
const countDate = computed(() => {
|
|
23
|
+
const base = props.endDate ?? DateTime.now();
|
|
66
24
|
const baseLocale = normalizeLocale(props.locale);
|
|
67
25
|
|
|
68
|
-
|
|
69
|
-
? props.units
|
|
70
|
-
: ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
|
|
71
|
-
|
|
72
|
-
const diffObj = base.diff(props.modelValue, units).toObject();
|
|
73
|
-
|
|
74
|
-
// helper: คืน label ตาม singular/plural (เฉพาะ en)
|
|
75
|
-
const labelFor = (unit: Unit, value: number) => {
|
|
76
|
-
if (baseLocale === 'en') {
|
|
77
|
-
return value === 1 ? labelsEnSingular[unit] : labelsEnPlural[unit];
|
|
78
|
-
}
|
|
79
|
-
return localizedLabels[baseLocale][unit];
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// ---------- โหมด single unit ----------
|
|
83
|
-
if (!props.units) {
|
|
84
|
-
const foundUnit = units.find((unit) => (diffObj[unit] ?? 0) >= 1);
|
|
85
|
-
if (foundUnit) {
|
|
86
|
-
const raw = Math.floor(diffObj[foundUnit] ?? 0); // >= 1
|
|
87
|
-
const value = props.zeroBase ? raw : raw + 1; // inclusive (+1)
|
|
88
|
-
const label = labelFor(foundUnit, value);
|
|
89
|
-
const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
|
|
90
|
-
return `${value} ${label}${suffix ? ` ${suffix}` : ''}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ถ้าไม่มีหน่วยใด >= 1 ให้ใช้หน่วยเล็กสุด
|
|
94
|
-
const lastUnit = units.at(-1)!;
|
|
95
|
-
const raw = Math.max(0, Math.floor(diffObj[lastUnit] ?? 0));
|
|
96
|
-
const value = props.zeroBase ? raw : 1; // inclusive: อย่างน้อย 1
|
|
97
|
-
const label = labelFor(lastUnit, value);
|
|
98
|
-
const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
|
|
99
|
-
return `${value} ${label}${suffix ? ` ${suffix}` : ''}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---------- โหมด multi-unit ----------
|
|
103
|
-
// เก็บค่าแบบตัวเลขก่อน แล้วค่อยเรนเดอร์
|
|
104
|
-
const values = units.map((unit) => ({
|
|
105
|
-
unit,
|
|
106
|
-
raw: Math.max(0, Math.floor(diffObj[unit] ?? 0)),
|
|
107
|
-
}));
|
|
108
|
-
|
|
109
|
-
// เลือกหน่วยที่เล็กที่สุด
|
|
110
|
-
const smallest = values[values.length - 1];
|
|
111
|
-
|
|
112
|
-
// ถ้ามีค่าอย่างน้อยหนึ่งหน่วย > 0 และเป็น inclusive (zeroBase=false) => +1 ที่หน่วยเล็กสุด
|
|
113
|
-
const anyPositive = values.some((v) => v.raw > 0);
|
|
114
|
-
const adjusted = values.map((v, idx) => {
|
|
115
|
-
if (!props.zeroBase && anyPositive && idx === values.length - 1) {
|
|
116
|
-
return { ...v, raw: v.raw + 1 };
|
|
117
|
-
}
|
|
118
|
-
return v;
|
|
119
|
-
});
|
|
26
|
+
let days = Math.floor(base.diff(props.modelValue, "days").days ?? 0);
|
|
120
27
|
|
|
121
|
-
|
|
122
|
-
const parts = adjusted
|
|
123
|
-
.filter((v) => v.raw > 0)
|
|
124
|
-
.map((v) => `${v.raw} ${labelFor(v.unit, v.raw)}`);
|
|
28
|
+
if (!props.zeroBase) days += 1;
|
|
125
29
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const minValue = props.zeroBase ? 0 : 1;
|
|
129
|
-
const label = labelFor(smallest.unit, minValue);
|
|
130
|
-
const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
|
|
131
|
-
return `${minValue} ${label}${suffix ? ` ${suffix}` : ''}`;
|
|
30
|
+
if (baseLocale === "en") {
|
|
31
|
+
return days === 1 ? "1 day" : `${days} days`;
|
|
132
32
|
}
|
|
133
33
|
|
|
134
|
-
|
|
135
|
-
return `${parts.join(' ')}${suffix ? ` ${suffix}` : ''}`;
|
|
34
|
+
return `${days} วัน`;
|
|
136
35
|
});
|
|
137
36
|
</script>
|
|
138
37
|
|
|
139
38
|
<template>
|
|
140
|
-
<span>
|
|
141
|
-
<i v-if="props.zeroBase" class="mdi mdi-clock-time-twelve-outline"></i>
|
|
142
|
-
<span v-if="props.zeroBase"> </span>{{ outputText }}
|
|
143
|
-
</span>
|
|
39
|
+
<span>{{ countDate }}</span>
|
|
144
40
|
</template>
|