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